diff --git a/.gitignore b/.gitignore index ee2cfd052..871b209f0 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,5 @@ settings.json .agent apps/**/tsconfig.tsbuildinfo +packages/**/tsconfig.tsbuildinfo logs/ diff --git a/apps/as/app/(with-base-layout)/acntg-code-management/page.tsx b/apps/as/app/(with-base-layout)/acntg-code-management/page.tsx deleted file mode 100644 index c22d0f0b4..000000000 --- a/apps/as/app/(with-base-layout)/acntg-code-management/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { AcntgCodeManagementPage as default } from "@/pages/acntg-code-management"; diff --git a/apps/as/app/(with-base-layout)/cnstp-code-management/page.tsx b/apps/as/app/(with-base-layout)/cnstp-code-management/page.tsx deleted file mode 100644 index d20a49e4f..000000000 --- a/apps/as/app/(with-base-layout)/cnstp-code-management/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { CnstpCodeManagementPage as default } from "@/pages/cnstp-code-management"; diff --git a/apps/as/app/(with-base-layout)/code-management/page.tsx b/apps/as/app/(with-base-layout)/code-management/page.tsx new file mode 100644 index 000000000..7a4daf43f --- /dev/null +++ b/apps/as/app/(with-base-layout)/code-management/page.tsx @@ -0,0 +1 @@ +export { CodeManagementPage as default } from "@/pages/code-management"; diff --git a/apps/as/app/(with-base-layout)/fnrs-cnsltn/[id]/edit/page.tsx b/apps/as/app/(with-base-layout)/fnrs-cnsltn/[id]/edit/page.tsx new file mode 100644 index 000000000..f1371598e --- /dev/null +++ b/apps/as/app/(with-base-layout)/fnrs-cnsltn/[id]/edit/page.tsx @@ -0,0 +1,10 @@ +import { FnrsEditPage } from "@/pages/fnrs-cnsltn"; + +interface PageProps { + params: Promise<{ id: string }>; +} + +export default async function Page({ params }: PageProps) { + const { id } = await params; + return ; +} diff --git a/apps/as/app/(with-base-layout)/fnrs-cnsltn/[id]/page.tsx b/apps/as/app/(with-base-layout)/fnrs-cnsltn/[id]/page.tsx new file mode 100644 index 000000000..ec9a3911e --- /dev/null +++ b/apps/as/app/(with-base-layout)/fnrs-cnsltn/[id]/page.tsx @@ -0,0 +1,10 @@ +import { FnrsViewPage } from "@/pages/fnrs-cnsltn"; + +interface PageProps { + params: Promise<{ id: string }>; +} + +export default async function Page({ params }: PageProps) { + const { id } = await params; + return ; +} diff --git a/apps/as/app/(with-base-layout)/fnrs-cnsltn/page.tsx b/apps/as/app/(with-base-layout)/fnrs-cnsltn/page.tsx new file mode 100644 index 000000000..734a0afd9 --- /dev/null +++ b/apps/as/app/(with-base-layout)/fnrs-cnsltn/page.tsx @@ -0,0 +1,3 @@ +import { FnrsListPage } from "@/pages/fnrs-cnsltn"; + +export default FnrsListPage; diff --git a/apps/as/app/(with-base-layout)/fnrs-cnsltn/regist/page.tsx b/apps/as/app/(with-base-layout)/fnrs-cnsltn/regist/page.tsx new file mode 100644 index 000000000..4360a6979 --- /dev/null +++ b/apps/as/app/(with-base-layout)/fnrs-cnsltn/regist/page.tsx @@ -0,0 +1,12 @@ +import { FnrsRegistPage } from "@/pages/fnrs-cnsltn"; +import { parseTaskSeComCdFromApi } from "@/pages/fnrs-cnsltn/lib/fnrs-cnsltn-regist-schema"; + +interface PageProps { + searchParams: Promise<{ taskSeComCd?: string }>; +} + +export default async function Page({ searchParams }: PageProps) { + const params = await searchParams; + const taskSeComCd = parseTaskSeComCdFromApi(params.taskSeComCd); + return ; +} diff --git a/apps/as/app/(with-base-layout)/instl-sncn-old/[id]/modify/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn-old/[id]/modify/page.tsx deleted file mode 100644 index baf4de7af..000000000 --- a/apps/as/app/(with-base-layout)/instl-sncn-old/[id]/modify/page.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { CnfmModifyPage } from "@/pages/instl-sncn-old"; - -export default async function InstlSncnModifyPage({ params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - return ; -} diff --git a/apps/as/app/(with-base-layout)/instl-sncn-old/[id]/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn-old/[id]/page.tsx deleted file mode 100644 index 92769d3ef..000000000 --- a/apps/as/app/(with-base-layout)/instl-sncn-old/[id]/page.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { CnfmViewPage } from "@/pages/instl-sncn-old"; - -export default async function InstlSncnViewPage({ params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - return ; -} diff --git a/apps/as/app/(with-base-layout)/instl-sncn-old/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn-old/page.tsx deleted file mode 100644 index 6c1e37f16..000000000 --- a/apps/as/app/(with-base-layout)/instl-sncn-old/page.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { CnfmListPage } from "@/pages/instl-sncn-old"; - -export default CnfmListPage; diff --git a/apps/as/app/(with-base-layout)/instl-sncn-old/register/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn-old/register/page.tsx deleted file mode 100644 index 82a838f8a..000000000 --- a/apps/as/app/(with-base-layout)/instl-sncn-old/register/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { CnfmRegistPage } from "@/pages/instl-sncn-old"; - -export default function Page(props: any) { - return ; -} diff --git a/apps/as/app/api/proxy/[...url]/route.ts b/apps/as/app/api/proxy/[...url]/route.ts index 15abf4c72..b32008942 100644 --- a/apps/as/app/api/proxy/[...url]/route.ts +++ b/apps/as/app/api/proxy/[...url]/route.ts @@ -5,6 +5,7 @@ import { ACCESS_TOKEN, AUTH_USER_ID, CLAIM_NAME, + COOKIE_DOMAIN, REFRESH_TOKEN, SERVER_API_URL, SITE_ID, @@ -20,6 +21,10 @@ async function handler( "Content-Type": "application/json", "X-Site-Id": SITE_ID, }; + const deviceId = request.headers.get("X-Device-Id"); + if (deviceId) { + headers["X-Device-Id"] = deviceId; + } let body = {}; const isRefreshHandler = params.url.includes("refresh"); @@ -39,27 +44,13 @@ async function handler( } const targetUrl = `${SERVER_API_URL}/${params.url.join("/")}`; - console.log(`[Proxy API] ${method} ${params.url.join("/")} -> ${targetUrl}`); - console.log(`[Proxy API] Headers:`, headers); - if (!isRefreshHandler) { - console.log(`[Proxy API] Body:`, body); - } - const result = await fetch(targetUrl, { method, headers, body: JSON.stringify(body), }); - console.log(`[Proxy API] Response Status: ${result.status}`); - console.log(`[Proxy API] Response Headers:`, { - [ACCESS_TOKEN]: result.headers.get(ACCESS_TOKEN), - [REFRESH_TOKEN]: result.headers.get(REFRESH_TOKEN) ? '***hidden***' : undefined, - [AUTH_USER_ID]: result.headers.get(AUTH_USER_ID), - }); - if (!result.ok) { - console.error(`[Proxy API] Request failed with status ${result.status}`); return NextResponse.json({ message: "Invalid Credentials" }, { status: 401 }); } @@ -68,24 +59,16 @@ async function handler( const userId = result.headers.get(AUTH_USER_ID); if (accessToken && userId) { - console.log(`[Proxy API] Login successful for user: ${userId}`); const response = NextResponse.json({ [ACCESS_TOKEN]: accessToken, [AUTH_USER_ID]: userId, }); - const forwardedHost = request.headers.get("x-forwarded-host"); - const host = request.headers.get("host"); - const actualHost = forwardedHost || host || new URL(request.url).hostname.toString(); - - // 운영 환경에서 domain 설정 - const domain = actualHost.includes("hasuon.or.kr") ? ".hasuon.or.kr" : actualHost; - if (refreshToken) { cookieStore.set(REFRESH_TOKEN, refreshToken, { httpOnly: true, sameSite: "strict", - domain: domain, + domain: getCookieDomain(), path: "/", }); } @@ -109,3 +92,14 @@ export async function PUT(request: Request, props: { params: Promise<{ url: stri const params = await props.params; return handler(request, params, "PUT"); } + +function getCookieDomain(): string | undefined { + if (typeof window === "undefined") return undefined; + + if (COOKIE_DOMAIN) return COOKIE_DOMAIN; + + const { hostname } = window.location; + if (hostname === "localhost" || hostname === "127.0.0.1") return undefined; + + return hostname; +} diff --git a/apps/as/docs/fnrs-cnsltn-vs-instl-sncn-diff.md b/apps/as/docs/fnrs-cnsltn-vs-instl-sncn-diff.md new file mode 100644 index 000000000..c90dec22f --- /dev/null +++ b/apps/as/docs/fnrs-cnsltn-vs-instl-sncn-diff.md @@ -0,0 +1,367 @@ +# 재원협의(fnrs-cnsltn) vs 설치인가(instl-sncn) 차이 분석 + +| 항목 | 내용 | +| --------- | ----------------------------------------------------------------------------- | +| 작성일 | 2026-07-09 | +| 기준 구현 | `fnrs-cnsltn` (추가 요구사항 반영 완료) | +| 비교 대상 | `instl-sncn` | +| 목적 | 두 페이지의 동작·구조 차이를 정리하고, 설치인가 정렬 시 이식 대상을 명확히 함 | + +--- + +## 1. 요약 + +두 모듈은 **동일한 PBADMS(공공행정 지원) 업무 패턴**을 따르는 형제 페이지다. + +공통 흐름: + +``` +목록 → TaskSe 선택 후 신청 → 저장(SV) → 신청완료(ST) + → 환경청제출(LE) → 기술검토 → 검토결과 회신(EP) + → 인가승인(TA) → 고시(TB) +``` + +| 구분 | fnrs-cnsltn | instl-sncn | +| ------------- | ----------------- | ---------------- | +| 업무명 | 재원협의 | 설치인가 | +| 문서번호 | `fnrsCnsltnDocNo` | `instlSncnDocNo` | +| docSeComCd | `FNRS` | `INSTL` | +| 라우트 | `/fnrs-cnsltn/*` | `/instl-sncn/*` | +| pages 파일 수 | 39 | 32 | + +**결론:** `fnrs-cnsltn`이 추가 요구사항이 반영된 기준(reference) 구현이고, `instl-sncn`은 동일 골격이지만 보완요청·결재선 편집·등록/수정 폼 완성도 등에서 미반영·TODO·단순화 상태다. + +--- + +## 2. 파일/폴더 구조 + +### 2-1. fnrs-cnsltn에만 존재 + +| 경로 | 역할 | +| -------------------------------------------- | ------------------------- | +| `ui/sections/fnrs-shar-section.tsx` | 재원분담 조회 섹션 | +| `ui/sections/supplement-demand-section.tsx` | 보완요청 이력 섹션 | +| `ui/components/sortable-prgrs-stts-row.tsx` | 결재선 DnD 편집 행 | +| `ui/components/sortable-apln-picker-row.tsx` | 결재선 템플릿 피커 DnD 행 | +| `ui/components/aprvr-picker-dialog.tsx` | 결재자 선택 다이얼로그 | +| `ui/components/elapr-apln-picker-grid.tsx` | 결재선 편집 그리드 | +| `ui/components/elapr-tmplt-select-table.tsx` | 결재서식 선택 테이블 | +| `model/reviewer-option.ts` | 결재자 옵션 타입 | + +### 2-2. instl-sncn에만 존재 + +| 경로 | 역할 | 비고 | +| -------------------------------------- | ------------------- | ------------------------------------------ | +| `ui/sections/history-info-section.tsx` | 등록/수정 이력 표시 | **어디에서도 import되지 않음 (dead code)** | + +### 2-3. 공통 구조 + +``` +index.ts +ui/ + {list,view,form,regist,edit}-page.tsx + sections/ (basic-info, contact, scale, cost, checklist, attachments, tech-review) + components/ (floating-action-buttons, elapr-approval-dialog, elapr-tmplt-picker-dialog, checklist-task-panel) +lib/ button-config, save-payload-utils, form-data-mapper, regist-schema, checklist/review utils +model/ columns.tsx, view-types.ts +``` + +App Router도 동일: `page.tsx`, `regist/page.tsx`, `[id]/page.tsx`, `[id]/edit/page.tsx` + +--- + +## 3. 목록 페이지 + +### 3-1. 검색 필터 + +| 항목 | fnrs-cnsltn | instl-sncn | +| ------------------ | ----------------------------------------- | --------------------------------- | +| 시도/시군구 API | `@/entities/adstdstr` | 동일 | +| 지역 검색 파라미터 | **`admdstCd`** (시군구 우선, 없으면 시도) | **`sidoComCd` + `sggComCd`** 분리 | +| "전체" 옵션 | `ALL_SELECT_VALUE = "__ALL__"` SelectItem | 없음 (placeholder만) | +| 구분 CodeSelect | `upCd="INSTL"` ※ | `upCd="INSTL"` | +| 사업명 | 동일 | 동일 | + +> ※ fnrs 목록의 구분 필터가 `INSTL` 코드를 사용 중이며, TaskSePickerDialog는 `codeUpCd="FNRS"`를 쓴다. **불일치 가능성**이 있어 확인이 필요하다. + +### 3-2. 정렬·페이지네이션 + +| 항목 | fnrs-cnsltn | instl-sncn | +| ------------------------ | ---------------------- | ------------------------------------- | +| 기본 sort (list-page) | `fnrsCnsltnDocNo,desc` | `instlSncnDocNo,desc` | +| fallback sort (list.tsx) | `fnrsCnsltnDocNo,desc` | **`regDt,desc`** (list-page와 불일치) | +| 페이지 크기 | 10 | 10 | +| URL 동기화 | 없음 (`useState` 로컬) | 없음 | + +### 3-3. 테이블 컬럼 + +`model/columns.tsx` 로직은 사실상 동일하다. DTO 타입명만 다르다. + +컬럼: 시도, 시군구, 사업명, 업무구분, 담당자, 해당기관, 진행상황, 신청일 + +### 3-4. 행 클릭·신청 + +| 항목 | fnrs-cnsltn | instl-sncn | +| ------------ | ------------------------------------------------------------------ | ------------------ | +| 행 클릭 | **조건부 라우팅**: `SV`/`SL` + 등록자 본인 → `/edit`, 그 외 → view | **항상 view** | +| useUserStore | 사용 | 미사용 | +| 신청 버튼 | TaskSePickerDialog `codeUpCd="FNRS"` | `codeUpCd="INSTL"` | + +```46:54:apps/as/src/pages/fnrs-cnsltn/ui/fnrs-list.tsx + const handleRowClick = (row: FnrsCnsltnMngResponseDto) => { + const canEdit = + (row.prgrsSttsComCd === "SV" || row.prgrsSttsComCd === "SL") && + row.rgtr === userInfo?.userUnqId; + + router.push( + canEdit ? `/fnrs-cnsltn/${row.fnrsCnsltnDocNo}/edit` : `/fnrs-cnsltn/${row.fnrsCnsltnDocNo}`, + ); + }; +``` + +--- + +## 4. 상세(View) 페이지 + +### 4-1. 섹션 구성 + +| 섹션 | fnrs-cnsltn | instl-sncn | +| ------------------------------- | ------------------------------------------ | -------------------------- | +| BasicInfo | 환경청구분, 확정예산사업번호, 예산신청연도 | **사업구분(`sncnSeCdNm`)** | +| ContactInfo | 동일 패턴 | 동일 | +| BusinessScale / Cost | 동일 | 동일 | +| **FnrsShar (재원분담)** | **있음** | **없음** (도메인 고유) | +| Checklist | 동일 | 동일 | +| Attachments | 동일 | 동일 | +| **SupplementDemand (보완요청)** | **있음** | **없음** | +| TechReviewOpinion | 동일 | 동일 | +| HistoryInfo | 없음 | 파일만 존재, view 미연결 | +| FloatingActionButtons | `docSeComCd="FNRS"` | `docSeComCd="INSTL"` | + +### 4-2. 데이터 훅 + +| 항목 | fnrs-cnsltn | instl-sncn | +| --------- | ---------------------------- | -------------------- | +| 상세 훅 | `useFnrsCnsltnMng` | `useInstlSncnDetail` | +| 에러 판정 | `isError \|\| !data?.detDto` | `isError \|\| !data` | +| 로딩 | Skeleton | Skeleton | + +--- + +## 5. 등록/수정(Form) 페이지 + +### 5-1. 폼 완성도 + +| 항목 | fnrs-cnsltn | instl-sncn | +| ------------- | --------------------------------- | --------------- | +| 저장(SV) | O | O (SV 고정) | +| 신청완료(ST) | O (edit 모드) | **없음** | +| 삭제 | O (edit 모드, `useConfirm`) | **없음** | +| 재원분담 섹션 | `FnrsSharFormSection` + 잔액 검증 | 없음 | +| 저장 후 이동 | **`/edit` 유지** | **view로 이동** | +| 목록 버튼 | `/fnrs-cnsltn` 고정 | `router.back()` | + +### 5-2. 기본정보 Form 필드 + +| 필드 | fnrs | instl | +| ----------------- | ---------------- | --------------------------------------- | +| taskSe 코드 | `FNRS` | `INSTL` | +| 사업구분 | 없음 | **`sncnSeComCd` (SNCNSE 라디오, 필수)** | +| BnsPicker 연동 | bizNm ~ bizCn | + **`bgtBizCd`, `bgtAplyYr`** | +| 재이용(RE) 조건부 | rtlztnFclt / Usg | 동일 | + +### 5-3. Schema + +| 항목 | fnrs | instl | +| ------------------- | ------------------- | -------------- | +| 관리 객체 | `fnrsCnsltnMng` | `instlSncnMng` | +| 예산 필드 | `cfmtnBgtBizNo` | `bgtBizCd` | +| 재원분담 | `fnrsSharList` 배열 | 없음 | +| sharAtchFileGroupId | 있음 | 없음 | +| privacyConsent | 있음 | 있음 | + +### 5-4. Edit 페이지 래퍼 + +| 항목 | fnrs | instl | +| ------------------ | ------------------------------- | ------------------------------- | +| 로딩 Skeleton | 있음 | **없음** | +| 데이터 없음 메시지 | "상세 정보를 찾을 수 없습니다." | "수정 대상을 찾을 수 없습니다." | + +--- + +## 6. FloatingActionButtons / 결재 / 보완 + +### 6-1. FAB 동작 차이 + +공통: 역할×상태 기반 버튼(`button-config.ts`), checklist 진행률 검증, elapr pending approval 체크 + +| 기능 | fnrs-cnsltn | instl-sncn | +| ------------------------------- | ------------------------------------------------------- | ------------------------------------------------------- | +| 보완완료 (`splmntCmptn`) | **`submitSupplement` API 연동** | **`// TODO` stub** | +| 보완요청 (`dmndSplmnt`) | **`buildSupplementReqPayload` + `submitReqSupplement`** | **`// TODO` stub** (문자열만 전달) | +| 검토완료 (`rvwCmptn`) | `techReViewSaveCd: "SV"` 후 **결재 다이얼로그 오픈** | `techReViewSaveCd: "CP"`, **다이얼로그 없이 즉시 완료** | +| 검토자지정 | `buildElaprPrgrsSttsListFromAplnRows(aplnRows)` | `buildElaprPrgrsSttsListFromApln(aplnPage.content)` | +| atrzAprv / atrzRply / grndsChck | TODO handler | TODO handler | + +```576:617:apps/as/src/pages/instl-sncn/ui/components/floating-action-buttons.tsx +async function handleSplmntCmptn(context: ActionHandlerContext): Promise { + // TODO: 보완완료 API 호출 + context.onSuccess?.(); +} +// ... +async function handleDmndSplmnt(context: ActionHandlerContext): Promise { + // TODO: 보완요청 API 호출 + context.onSuccess?.(); +} +``` + +### 6-2. ElaprApprovalDialog (가장 큰 UX 격차) + +| 항목 | fnrs (419줄) | instl (231줄) | +| ---------------- | ------------------------------------------------------ | -------------------------------- | +| 결재선 표시 | DnD sortable 편집 테이블 | **읽기 전용** 테이블 | +| 결재선 추가/저장 | **지원** (`usePrgrsSttsRows`, `useSaveElaprPrgrsStts`) | 없음 | +| 결재자 변경 | **AprvrPickerDialog** | 없음 | +| 전결 체크박스 | **있음** (`finalApprovalYn`) | 없음 | +| 반려 사유 | API에 `rjctRsn` 전달 | UI는 있으나 **payload에 미전달** | +| 저장 전 검증 | `hasUnsavedPrgrsSttsChanges` | 없음 | + +### 6-3. ElaprTmpltPickerDialog + +| 항목 | fnrs | instl | +| --------------- | -------------------------------------------------------------------- | ----------------------- | +| UI | 2-pane: 서식 테이블 + **결재선 편집 그리드** | 단일 테이블 + 선택 버튼 | +| docSeComCd 필터 | `FNRS` | `INSTL` | +| onConfirm | `{ tmplt, aplnRows }` 편집 결과 | `onSelect(tmplt)`만 | +| 검증 | `validateAllRowsForSave`, `validateAtrzOrder`, `validateUniqueSeqNo` | 없음 | + +--- + +## 7. Entity / API / Hook + +### 7-1. Service 메서드 + +| 메서드 | fnrs | instl | +| ----------------------- | --------------------------- | --------- | +| 목록 | `findPage` | `getPage` | +| 상세 | `findById` | `getById` | +| saveApply | O | O | +| techReview | O | O | +| changePrgrsSttsComCd | O | O | +| remove | O | O | +| **submitSupplement** | **O** (`/submitsupplement`) | **X** | +| **submitReqSupplement** | **O** (`/reqsupplement`) | **X** | + +### 7-2. 상세 DTO 확장 + +**fnrs 추가 필드** + +- `fnrsSharList`, `sharAtchFileList` +- `splmntDmndList` (typed) + +**instl 추가 필드** + +- `sncnSeComCd` / `sncnSeCdNm` (인가구분) +- `bgtBizCd` (예산사업코드) +- `rtlztnFcltSeComNm`, `rtlztnUsgSeComNm` +- `splmntDmndList` 타입은 base에 존재하나 view/API 미연결 +- `instlSncnRegionService` (시도/시군구 — 목록은 adstdstr 사용, 미활용) + +### 7-3. save-payload-utils + +| 함수 | fnrs | instl | +| -------------------------------- | ---------------- | --------- | +| buildSavePayload | + `fnrsSharList` | shar 없음 | +| buildTechReviewReplyPayload | O | O | +| **buildSupplementReqPayload** | **O** | **X** | +| **buildSupplementSubmitPayload** | **O** | **X** | + +--- + +## 8. 비즈니스 도메인 차이 + +| 영역 | fnrs-cnsltn | instl-sncn | +| -------------- | ----------------------------------------- | ------------------------ | +| 업무 코드 | `FNRS` | `INSTL` | +| TaskSe | FNRS 하위 | INSTL 하위 | +| 고시 버튼 라벨 | "재원협의 고시" | "설치인가 고시" | +| 재원분담 | **핵심 도메인** (`fnrs-shar-se`) | 해당 없음 | +| 인가구분 | 없음 | **`SNCNSE` 코드 (필수)** | +| 예산 식별 | `cfmtnBgtBizNo` | `bgtBizCd` | +| 진행상태 코드 | SV, ST, LE, ER, TE, EP, TA, TB, SL, TP 등 | **동일 세트 공유** | +| 역할 매핑 | ROLE_USER05/04/03/01 | 동일 | + +--- + +## 9. fnrs-cnsltn에만 있는 기능 (추가 요구사항 후보) + +instl-sncn 정렬 시 **우선 이식 대상**: + +1. 목록 행 클릭 조건부 edit 라우팅 (`SV`/`SL` + 등록자) +2. 보완요청 전체 플로우 — view 섹션, payload builder, API, FAB handler +3. 보완완료 API 연동 (`submitSupplement`) +4. 등록/수정 폼 완성 — 신청완료, 삭제, 저장 후 edit 유지 +5. 결재선 DnD 편집형 `ElaprApprovalDialog` (+ aprvr-picker, sortable rows) +6. 결재선 편집형 `ElaprTmpltPickerDialog` (+ apln grid, validation) +7. 검토완료 → 결재 다이얼로그 연계 흐름 +8. 검색 필터 "전체" 옵션 및 `admdstCd` 통합 방식 (백엔드 API에 맞게) +9. Edit 페이지 로딩 Skeleton +10. entity에 supplement API/hooks 추가 + +--- + +## 10. instl-sncn에만 있는 기능 (유지 필요) + +1. **`sncnSeComCd` (사업구분/인가구분)** — 도메인 고유 +2. **`bgtBizCd` / `bgtAplyYr`** BnsPicker 연동 +3. **`history-info-section.tsx`** — 구현만 있고 view 미연결 (활성화 또는 삭제 결정 필요) +4. entity **`instlSncnRegionService`** — 목록은 adstdstr 사용 중이라 중복/레거시 가능 +5. **`INSTL_SNCN_REGISTER_SELECTION_KEY`** sessionStorage 키 + +--- + +## 11. 동일·거의 동일 패턴 + +| 영역 | 동일도 | +| ----------------------------------------------------- | --------------------- | +| App Router 4-route 구조 | 100% | +| `button-config.ts` 역할×상태 매트릭스 | ~99% (액션명만 다름) | +| `columns.tsx` 진행상황 Badge | ~100% | +| checklist / contact / scale / cost / attachments 섹션 | ~95% (import 경로만) | +| review-step-utils 로직 | ~100% (상수명만 다름) | +| TaskSePickerDialog 신청 플로우 | 100% | +| FAB 전체 구조·대부분 handler | ~85% | +| checklist-form-state, business-cost-utils | 거의 동일 | +| shared widgets (`@/widgets/pbadms-sprt`) | 동일 | + +--- + +## 12. instl-sncn 정렬 권장 우선순위 + +| 우선순위 | 항목 | 근거 | +| -------- | ------------------------------------------------------- | ---------------------------- | +| P0 | 보완요청/보완완료 API + UI | fnrs 완전 구현 vs instl TODO | +| P0 | ElaprApprovalDialog 고도화 | 결재선 편집·전결·반려사유 | +| P0 | ElaprTmpltPickerDialog 2-pane | 검토자지정 품질 | +| P1 | Form 신청완료/삭제 + 저장 후 edit 유지 | 등록 UX parity | +| P1 | 목록 행 클릭 edit 라우팅 | fnrs 추가 요구사항 | +| P1 | 검토완료 → 결재 다이얼로그 연계 | 흐름 통일 | +| P2 | sort fallback 불일치 수정 (`regDt` vs `instlSncnDocNo`) | instl 내부 버그성 | +| P2 | history-info-section 연결 또는 삭제 | dead code 정리 | +| P3 | URL search params 동기화 | 양쪽 모두 미구현 (공통 개선) | + +--- + +## 13. 참고 코드 위치 + +| 주제 | 경로 | +| ---------------------------- | ------------------------------------------------------------ | +| 목록 edit 라우팅 (fnrs) | `pages/fnrs-cnsltn/ui/fnrs-list.tsx` | +| 목록 항상 view (instl) | `pages/instl-sncn/ui/instl-list.tsx` | +| view 추가 섹션 (fnrs) | `pages/fnrs-cnsltn/ui/fnrs-view-page.tsx` | +| FAB 보완 TODO (instl) | `pages/instl-sncn/ui/components/floating-action-buttons.tsx` | +| 결재 다이얼로그 (fnrs) | `pages/fnrs-cnsltn/ui/components/elapr-approval-dialog.tsx` | +| 결재 다이얼로그 (instl) | `pages/instl-sncn/ui/components/elapr-approval-dialog.tsx` | +| 폼 완성도 (fnrs) | `pages/fnrs-cnsltn/ui/fnrs-form-page.tsx` | +| 폼 단순화 (instl) | `pages/instl-sncn/ui/instl-form-page.tsx` | +| supplement API (fnrs entity) | `entities/fnrs-cnsltn/api/fnrs-cnsltn-service.ts` | diff --git a/apps/as/package.json b/apps/as/package.json index ea9e0545a..cebdf28ee 100644 --- a/apps/as/package.json +++ b/apps/as/package.json @@ -20,6 +20,7 @@ "@faker-js/faker": "^9.9.0", "@frontend-opensource/use-react-hooks": "^1.1.4", "@hookform/resolvers": "^4.1.3", + "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.75.5", "@tanstack/react-query-devtools": "^5.75.5", "@tanstack/react-table": "^8.21.2", @@ -31,6 +32,7 @@ "next": "15.5.18", "react-hook-form": "^7.54.2", "tus-js-client": "^4.3.1", + "uuid": "^14.0.1", "zod": "^3.24.2" }, "devDependencies": { diff --git a/apps/as/public/favicon.ico b/apps/as/public/favicon.ico new file mode 100644 index 000000000..d304b6981 Binary files /dev/null and b/apps/as/public/favicon.ico differ diff --git a/apps/as/src/app/layouts/base-layout.tsx b/apps/as/src/app/layouts/base-layout.tsx index fc84a5e3e..ebe24b41c 100644 --- a/apps/as/src/app/layouts/base-layout.tsx +++ b/apps/as/src/app/layouts/base-layout.tsx @@ -7,6 +7,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useMenuStore } from "@/shared/lib"; import { PageLayout } from "@/shared/ui/page-layout"; +import { authService } from "@/entities/auth"; import type { MenuItem } from "@/entities/menu"; import { BaseLayout } from "@common/components"; @@ -32,6 +33,7 @@ export function BaseLayoutWrapper({ children }: BaseLayoutWrapperProps) { return ( diff --git a/apps/as/src/app/providers/index.tsx b/apps/as/src/app/providers/index.tsx index 20ec0b72e..2a0ac6e69 100644 --- a/apps/as/src/app/providers/index.tsx +++ b/apps/as/src/app/providers/index.tsx @@ -27,7 +27,9 @@ export function Providers({ children }: ProvidersProps) { - +
+ +
); } diff --git a/apps/as/src/entities/acntg/model/acntg-interface.ts b/apps/as/src/entities/acntg/model/acntg-interface.ts index c1457208d..ea64fc218 100644 --- a/apps/as/src/entities/acntg/model/acntg-interface.ts +++ b/apps/as/src/entities/acntg/model/acntg-interface.ts @@ -16,6 +16,7 @@ export interface AcntgSeMngResponseDto { cdLvNo: number; useYn: string; expln?: string; + sortSeq?: number; rgtr?: string; rgtrNm?: string; mdfr?: string; @@ -39,6 +40,7 @@ export interface AcntgSeMngSaveRequestDto { cdLvNo: number; useYn?: string; expln?: string; + sortSeq?: number; } /** PUT /{acntgSeCd} 요청 (mode는 서비스에서 U로 설정) */ diff --git a/apps/as/src/entities/auth/api/auth-service.ts b/apps/as/src/entities/auth/api/auth-service.ts index 0c1730054..5615f55f8 100644 --- a/apps/as/src/entities/auth/api/auth-service.ts +++ b/apps/as/src/entities/auth/api/auth-service.ts @@ -1,13 +1,16 @@ import { ACCESS_TOKEN, AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "@/shared/config"; -import { getRemainingMs } from "@common/auth"; +import { type AuthPayload, type AuthService, getRemainingMs } from "@common/auth"; -import { clearAuthToken, setToken, setupRefreshTimer } from "../lib/token-manager"; -import type { AuthPayload, AuthService } from "../model/auth-interface"; +import { clearDeviceId, getOrCreateDeviceId } from "../lib/device-id"; +import { clearRefreshTimer, setToken, setupRefreshTimer, tokenManager } from "../lib/token-manager"; async function login(payload: AuthPayload): Promise { const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + "X-Device-Id": getOrCreateDeviceId(), + }, body: JSON.stringify(payload), }); @@ -34,6 +37,9 @@ async function silentRefresh(): Promise { try { const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`, { method: "PUT", + headers: { + "X-Device-Id": getOrCreateDeviceId(), + }, }); const data = await response.json(); @@ -58,7 +64,7 @@ async function silentRefresh(): Promise { } } catch (error) { console.error(`[silentRefresh] Failed at ${new Date().toISOString()}`, error); - clearAuthToken(); + tokenManager.clearAuthToken(); if (typeof window !== "undefined") { window.dispatchEvent(new CustomEvent("auth:logout")); } @@ -66,7 +72,9 @@ async function silentRefresh(): Promise { } async function logout(): Promise { - clearAuthToken(); + tokenManager.clearAuthToken(); + clearDeviceId(); + if (typeof window !== "undefined") { window.dispatchEvent(new CustomEvent("auth:logout")); } @@ -75,5 +83,6 @@ async function logout(): Promise { export const authService: AuthService = { login, silentRefresh, + declineSessionExtend: clearRefreshTimer, logout, }; diff --git a/apps/as/src/entities/auth/lib/device-id.ts b/apps/as/src/entities/auth/lib/device-id.ts new file mode 100644 index 000000000..ce173b141 --- /dev/null +++ b/apps/as/src/entities/auth/lib/device-id.ts @@ -0,0 +1,17 @@ +import { v4 as uuidv4 } from "uuid"; + +const DEVICE_ID_KEY = "x-device-id"; + +export function getOrCreateDeviceId(): string { + if (typeof window === "undefined") return ""; + const existing = localStorage.getItem(DEVICE_ID_KEY); + if (existing) return existing; + const id = uuidv4(); + localStorage.setItem(DEVICE_ID_KEY, id); + return id; +} + +export function clearDeviceId(): void { + if (typeof window === "undefined") return; + localStorage.removeItem(DEVICE_ID_KEY); +} diff --git a/apps/as/src/entities/auth/lib/token-manager.ts b/apps/as/src/entities/auth/lib/token-manager.ts index d449e711b..78d24bd5e 100644 --- a/apps/as/src/entities/auth/lib/token-manager.ts +++ b/apps/as/src/entities/auth/lib/token-manager.ts @@ -8,7 +8,7 @@ export const { setToken, removeToken, setupRefreshTimer, - clearAuthToken, + clearRefreshTimer, getAccessToken, getRefreshToken, getUserId, diff --git a/apps/as/src/entities/auth/model/auth-interface.ts b/apps/as/src/entities/auth/model/auth-interface.ts deleted file mode 100644 index fb6e10630..000000000 --- a/apps/as/src/entities/auth/model/auth-interface.ts +++ /dev/null @@ -1,10 +0,0 @@ -export interface AuthPayload { - userId: string; - password: string; -} - -export interface AuthService { - login(payload: AuthPayload): Promise; - silentRefresh(): Promise; - logout(): Promise; -} diff --git a/apps/as/src/entities/cnstp/model/cnstp-interface.ts b/apps/as/src/entities/cnstp/model/cnstp-interface.ts index def16901a..eabd12f46 100644 --- a/apps/as/src/entities/cnstp/model/cnstp-interface.ts +++ b/apps/as/src/entities/cnstp/model/cnstp-interface.ts @@ -16,6 +16,7 @@ export interface CnstpSeMngResponseDto { cdLvNo: number; useYn: string; expln?: string; + sortSeq?: number; rgtr?: string; rgtrNm?: string; mdfr?: string; @@ -39,6 +40,7 @@ export interface CnstpSeMngSaveRequestDto { cdLvNo?: number; useYn?: string; expln?: string; + sortSeq?: number; } /** PUT /{cnstpSeCd} 요청 (mode는 서비스에서 U로 설정) */ diff --git a/apps/as/src/entities/elapr/api/elapr-prgrs-service.ts b/apps/as/src/entities/elapr/api/elapr-prgrs-service.ts deleted file mode 100644 index 97f925113..000000000 --- a/apps/as/src/entities/elapr/api/elapr-prgrs-service.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { client } from "@/shared/api"; - -export async function processElaprPrgrs(elaprPrgrsId: string, atrzPrgrsSttsComCd = "ATRZ_STTS_003"): Promise { - await client.put(`/as-elapr-service/api/v1/elapr-prgrs-stts/${elaprPrgrsId}/process`, { - atrzPrgrsSttsComCd, - }); -} diff --git a/apps/as/src/entities/elapr/api/elapr-prgrs-stts-service.ts b/apps/as/src/entities/elapr/api/elapr-prgrs-stts-service.ts new file mode 100644 index 000000000..f1931d556 --- /dev/null +++ b/apps/as/src/entities/elapr/api/elapr-prgrs-stts-service.ts @@ -0,0 +1,104 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { PageResponse } from "@/shared/model"; + +import type { + ElaprApproverChangeRequestDto, + ElaprApproverInsertRequestDto, + ElaprPrgrsSttsMyDocSearchParams, + ElaprPrgrsSttsResponseDto, + ElaprPrgrsSttsSaveRequestDto, + ElaprPrgrsSttsService, + ElaprProcessRequestDto, +} from "../model/elapr-prgrs-stts-interface"; + +const BASE = ENDPOINT.AS_ELAPR_SERVICE.ELAPR_PRGRS_STTS; + +async function findAllByDmndId(elaprDmndId?: string): Promise { + const response = await client.get(BASE, { + params: elaprDmndId ? { elaprDmndId } : undefined, + }); + return response.data; +} + +async function findById(elaprPrgrsId: string): Promise { + const response = await client.get(`${BASE}/${elaprPrgrsId}`); + return response.data; +} + +async function save( + requestDtos: ElaprPrgrsSttsSaveRequestDto[], +): Promise { + const response = await client.post(BASE, requestDtos); + return response.data; +} + +async function process( + elaprPrgrsId: string, + body: ElaprProcessRequestDto, +): Promise { + const response = await client.put( + `${BASE}/${elaprPrgrsId}/process`, + body, + ); + return response.data; +} + +async function processByDocNoAndAprvrUuid( + elaprDocNo: string, + aprvrUuid: string, + body: ElaprProcessRequestDto, +): Promise { + const response = await client.put( + `${BASE}/doc/${elaprDocNo}/aprvr/${aprvrUuid}/process`, + body, + ); + return response.data; +} + +async function deletePrgrsStts(elaprPrgrsId: string): Promise { + await client.delete(`${BASE}/${elaprPrgrsId}`); +} + +async function updateApprover( + elaprPrgrsId: string, + body: ElaprApproverChangeRequestDto, +): Promise { + const response = await client.put( + `${BASE}/${elaprPrgrsId}/approver`, + body, + ); + return response.data; +} + +async function insertApprover( + elaprDmndId: string, + body: ElaprApproverInsertRequestDto, +): Promise { + const response = await client.post( + `${BASE}/${elaprDmndId}/approver/insert`, + body, + ); + return response.data; +} + +async function findMyElaperDocList( + params: ElaprPrgrsSttsMyDocSearchParams, +): Promise> { + const response = await client.get>(`${BASE}/my/doc`, { + params, + }); + return response.data; +} + +export const elaprPrgrsSttsService = { + findAllByDmndId, + findById, + save, + process, + processByDocNoAndAprvrUuid, + delete: deletePrgrsStts, + updateApprover, + insertApprover, + findMyElaperDocList, +} satisfies ElaprPrgrsSttsService; diff --git a/apps/as/src/entities/elapr/api/queries.ts b/apps/as/src/entities/elapr/api/queries.ts index fae6a0d03..e93c1540e 100644 --- a/apps/as/src/entities/elapr/api/queries.ts +++ b/apps/as/src/entities/elapr/api/queries.ts @@ -9,6 +9,13 @@ import type { ElaprAplnUpdateRequest, } from "../model/elapr-apln-interface"; import type { ElaprDmndSaveRequestDto, ElaprDmndSearchDto } from "../model/elapr-dmnd-interface"; +import type { + ElaprApproverChangeRequestDto, + ElaprApproverInsertRequestDto, + ElaprPrgrsSttsMyDocSearchParams, + ElaprPrgrsSttsSaveRequestDto, + ElaprProcessRequestDto, +} from "../model/elapr-prgrs-stts-interface"; import type { ElaprTmpltSaveRequest, ElaprTmpltSearchParams, @@ -17,11 +24,18 @@ import type { import { elaprAplnService } from "./elapr-apln-service"; import { elaprDmndService } from "./elapr-dmnd-service"; +import { elaprPrgrsSttsService } from "./elapr-prgrs-stts-service"; import { elaprTmpltService } from "./elapr-tmplt-service"; const elaprTmpltBaseKey = [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_TMPLT] as const; const elaprAplnBaseKey = [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN] as const; const elaprDmndBaseKey = [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND] as const; +const elaprPrgrsSttsBaseKey = [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_PRGRS_STTS] as const; + +function invalidateElaprPrgrsSttsQueries(queryClient: ReturnType) { + queryClient.invalidateQueries({ queryKey: elaprPrgrsSttsBaseKey }); + queryClient.invalidateQueries({ queryKey: elaprDmndBaseKey }); +} export function useElaprTmpltPage(params: ElaprTmpltSearchParams) { return useQuery({ @@ -230,3 +244,119 @@ export function useRemoveElaprDmnd() { }, }); } + +export function useElaprPrgrsSttsByDmndId(elaprDmndId: string | null | undefined) { + return useQuery({ + queryKey: [...elaprPrgrsSttsBaseKey, "findAllByDmndId", elaprDmndId], + queryFn: () => elaprPrgrsSttsService.findAllByDmndId(elaprDmndId!), + enabled: Boolean(elaprDmndId), + }); +} + +export function useElaprPrgrsStts(elaprPrgrsId: string | null | undefined) { + return useQuery({ + queryKey: [...elaprPrgrsSttsBaseKey, elaprPrgrsId], + queryFn: () => elaprPrgrsSttsService.findById(elaprPrgrsId!), + enabled: Boolean(elaprPrgrsId), + }); +} + +export function useElaprPrgrsSttsMyDocPage(params: ElaprPrgrsSttsMyDocSearchParams) { + return useQuery({ + queryKey: [...elaprPrgrsSttsBaseKey, "findMyElaperDocList", params], + queryFn: () => elaprPrgrsSttsService.findMyElaperDocList(params), + }); +} + +export function useSaveElaprPrgrsStts() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (requestDtos: ElaprPrgrsSttsSaveRequestDto[]) => + elaprPrgrsSttsService.save(requestDtos), + onSuccess: () => { + invalidateElaprPrgrsSttsQueries(queryClient); + }, + }); +} + +export function useProcessElaprPrgrsStts() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + elaprPrgrsId, + body, + }: { + elaprPrgrsId: string; + body: ElaprProcessRequestDto; + }) => elaprPrgrsSttsService.process(elaprPrgrsId, body), + onSuccess: () => { + invalidateElaprPrgrsSttsQueries(queryClient); + }, + }); +} + +export function useProcessElaprPrgrsSttsByDoc() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + elaprDocNo, + aprvrUuid, + body, + }: { + elaprDocNo: string; + aprvrUuid: string; + body: ElaprProcessRequestDto; + }) => elaprPrgrsSttsService.processByDocNoAndAprvrUuid(elaprDocNo, aprvrUuid, body), + onSuccess: () => { + invalidateElaprPrgrsSttsQueries(queryClient); + }, + }); +} + +export function useDeleteElaprPrgrsStts() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (elaprPrgrsId: string) => elaprPrgrsSttsService.delete(elaprPrgrsId), + onSuccess: () => { + invalidateElaprPrgrsSttsQueries(queryClient); + }, + }); +} + +export function useUpdateElaprPrgrsSttsApprover() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + elaprPrgrsId, + body, + }: { + elaprPrgrsId: string; + body: ElaprApproverChangeRequestDto; + }) => elaprPrgrsSttsService.updateApprover(elaprPrgrsId, body), + onSuccess: () => { + invalidateElaprPrgrsSttsQueries(queryClient); + }, + }); +} + +export function useInsertElaprPrgrsSttsApprover() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + elaprDmndId, + body, + }: { + elaprDmndId: string; + body: ElaprApproverInsertRequestDto; + }) => elaprPrgrsSttsService.insertApprover(elaprDmndId, body), + onSuccess: () => { + invalidateElaprPrgrsSttsQueries(queryClient); + }, + }); +} diff --git a/apps/as/src/entities/elapr/index.ts b/apps/as/src/entities/elapr/index.ts index 4b14ea0d9..adec3e931 100644 --- a/apps/as/src/entities/elapr/index.ts +++ b/apps/as/src/entities/elapr/index.ts @@ -1,48 +1,122 @@ -export { elaprAplnService } from "./api/elapr-apln-service"; -export { elaprDmndService } from "./api/elapr-dmnd-service"; -export { elaprTmpltService } from "./api/elapr-tmplt-service"; -export { - useCreateElaprTmplt, - useDeleteElaprApln, - useDeleteElaprTmplt, - useElaprApln, - useElaprAplnPage, - useElaprDmnd, - useElaprDmndPage, - useElaprDocPrgrsByDocNo, - useElaprTmplt, - useElaprTmpltPage, - useRemoveElaprDmnd, - useReorderElaprApln, - useSaveElaprDmnd, - useSaveMultiElaprApln, - useUpdateElaprApln, - useUpdateElaprDmnd, - useUpdateElaprDmndStatus, - useUpdateElaprTmplt, -} from "./api/queries"; -export { - ELAPR_APLN_SAVE_MODE, - type ElaprApln, - type ElaprAplnListParams, - type ElaprAplnReorderItem, - type ElaprAplnReorderRequest, - type ElaprAplnSaveMode, - type ElaprAplnSaveRequest, - type ElaprAplnService, - type ElaprAplnUpdateRequest, -} from "./model/elapr-apln-interface"; -export type { - ElaprDmndResponseDto, - ElaprDmndSaveRequestDto, - ElaprDmndSearchDto, - ElaprDmndService, - ElaprDocPrgrsResponseDto, -} from "./model/elapr-dmnd-interface"; -export type { - ElaprTmplt, - ElaprTmpltSaveRequest, - ElaprTmpltSearchParams, - ElaprTmpltService, - ElaprTmpltUpdateRequest, -} from "./model/elapr-tmplt-interface"; +export { elaprAplnService } from "./api/elapr-apln-service"; +export { elaprDmndService } from "./api/elapr-dmnd-service"; +export { elaprPrgrsSttsService } from "./api/elapr-prgrs-stts-service"; +export { elaprTmpltService } from "./api/elapr-tmplt-service"; +export { + useCreateElaprTmplt, + useDeleteElaprApln, + useDeleteElaprPrgrsStts, + useDeleteElaprTmplt, + useElaprApln, + useElaprAplnPage, + useElaprDmnd, + useElaprDmndPage, + useElaprDocPrgrsByDocNo, + useElaprPrgrsStts, + useElaprPrgrsSttsByDmndId, + useElaprPrgrsSttsMyDocPage, + useElaprTmplt, + useElaprTmpltPage, + useInsertElaprPrgrsSttsApprover, + useProcessElaprPrgrsStts, + useProcessElaprPrgrsSttsByDoc, + useRemoveElaprDmnd, + useReorderElaprApln, + useSaveElaprDmnd, + useSaveElaprPrgrsStts, + useSaveMultiElaprApln, + useUpdateElaprApln, + useUpdateElaprDmnd, + useUpdateElaprDmndStatus, + useUpdateElaprPrgrsSttsApprover, + useUpdateElaprTmplt, +} from "./api/queries"; +export { + assignAtrzSeqNumbers, + buildSaveMultiPayload, + hasUnsavedChanges, +} from "./lib/apln-save-utils"; +export { + getVisibleRows, + validateAllRowsForSave, + validateAplnRow, + validateAtrzOrder, + validateUniqueSeqNo, +} from "./lib/apln-validators"; +export { + findCurrentUserPendingApprovalRequest, + hasCurrentUserPendingApprovalRequest, +} from "./lib/approval-request-utils"; +export { + APPROVER_STATUS_MAP, + type ApproverStatusConfig, + getApproverStatus, + sortByAtrzSeqNo, +} from "./lib/approval-utils"; +export { + buildElaprPrgrsSttsListFromApln, + buildElaprPrgrsSttsListFromAplnRows, +} from "./lib/dmnd-save-utils"; +export type { ElaprPrgrsSttsRow } from "./lib/prgrs-stts-save-utils"; +export { + assignPrgrsSttsAtrzSeqNumbers, + buildPrgrsSttsSavePayload, + hasUnsavedPrgrsSttsChanges, + isPrgrsSttsRowEditable, + PRGRS_STTS_WAITING_CD, + validateAllPrgrsSttsRows, + validatePrgrsSttsRow, +} from "./lib/prgrs-stts-save-utils"; +export { createDraftRowKey, useAplnRows } from "./lib/use-apln-rows"; +export { useDndSensors } from "./lib/use-dnd-sensors"; +export { usePrgrsSttsRows } from "./lib/use-prgrs-stts-rows"; +export { + ELAPR_APLN_SAVE_MODE, + type ElaprApln, + type ElaprAplnListParams, + type ElaprAplnReorderItem, + type ElaprAplnReorderRequest, + type ElaprAplnSaveMode, + type ElaprAplnSaveRequest, + type ElaprAplnService, + type ElaprAplnUpdateRequest, +} from "./model/elapr-apln-interface"; +export type { ElaprAplnRow } from "./model/elapr-apln-row-types"; +export type { + ElaprDmndResponseDto, + ElaprDmndSaveRequestDto, + ElaprDmndSearchDto, + ElaprDmndService, + ElaprDocPrgrsResponseDto, +} from "./model/elapr-dmnd-interface"; +export type { + ElaprApproverChangeRequestDto, + ElaprApproverInsertRequestDto, + ElaprPrgrsSttsMyDocSearchParams, + ElaprPrgrsSttsResponseDto, + ElaprPrgrsSttsSaveMode, + ElaprPrgrsSttsSaveRequestDto, + ElaprPrgrsSttsSearchDto, + ElaprPrgrsSttsService, + ElaprProcessRequestDto, +} from "./model/elapr-prgrs-stts-interface"; +export { ELAPR_PRGRS_STTS_SAVE_MODE } from "./model/elapr-prgrs-stts-interface"; +export type { + ElaprTmplt, + ElaprTmpltSaveRequest, + ElaprTmpltSearchParams, + ElaprTmpltService, + ElaprTmpltUpdateRequest, +} from "./model/elapr-tmplt-interface"; +export type { ReviewerOption } from "./model/reviewer-option"; +export { AprvrPickerDialog } from "./ui/aprvr-picker-dialog"; +export { ElaprAplnPickerGrid } from "./ui/elapr-apln-picker-grid"; +export { ElaprApprovalDialog } from "./ui/elapr-approval-dialog"; +export { ElaprDocPrgrsStatus } from "./ui/elapr-doc-prgrs-status"; +export { + type ElaprTmpltPickerConfirmPayload, + ElaprTmpltPickerDialog, +} from "./ui/elapr-tmplt-picker-dialog"; +export { ElaprTmpltSelectTable } from "./ui/elapr-tmplt-select-table"; +export { SortableAplnPickerRow } from "./ui/sortable-apln-picker-row"; +export { SortablePrgrsSttsRow } from "./ui/sortable-prgrs-stts-row"; diff --git a/apps/as/src/pages/approval-line-management/lib/build-save-multi-payload.ts b/apps/as/src/entities/elapr/lib/apln-save-utils.ts similarity index 57% rename from apps/as/src/pages/approval-line-management/lib/build-save-multi-payload.ts rename to apps/as/src/entities/elapr/lib/apln-save-utils.ts index 0c66b7623..17974a166 100644 --- a/apps/as/src/pages/approval-line-management/lib/build-save-multi-payload.ts +++ b/apps/as/src/entities/elapr/lib/apln-save-utils.ts @@ -1,8 +1,16 @@ -import { ELAPR_APLN_SAVE_MODE, type ElaprApln, type ElaprAplnSaveRequest } from "@/entities/elapr"; +import { + ELAPR_APLN_SAVE_MODE, + type ElaprApln, + type ElaprAplnSaveRequest, +} from "../model/elapr-apln-interface"; +import type { ElaprAplnRow } from "../model/elapr-apln-row-types"; -import { assignAtrzSeqNumbers } from "./assign-atrz-seq"; -import { getVisibleRows } from "./validators"; -import type { ElaprAplnRow } from "../model/apln-row-types"; +import { getVisibleRows } from "./apln-validators"; + +/** 그리드 행 순서(1-based)로 결재순서번호 일괄 채번 */ +export function assignAtrzSeqNumbers(rows: ElaprAplnRow[]): ElaprAplnRow[] { + return rows.map((row, index) => ({ ...row, atrzSeqNo: index + 1 })); +} function rowDiffersFromServer(row: ElaprAplnRow, serverRow: ElaprApln): boolean { return ( @@ -22,6 +30,25 @@ function shouldIncludeUpdate(row: ElaprAplnRow, serverRows: ElaprApln[]): boolea return rowDiffersFromServer(row, serverRow); } +export function hasUnsavedChanges(rows: ElaprAplnRow[], serverRows: ElaprApln[]): boolean { + if (rows.some((row) => row.isDeleted && row.aplnId)) return true; + if (rows.some((row) => row.isNew && !row.isDeleted)) return true; + + const visibleWithSeq = assignAtrzSeqNumbers(getVisibleRows(rows)); + + for (const row of visibleWithSeq) { + if (row.isNew) continue; + if (!row.aplnId) continue; + if (row.isDirty) return true; + + const serverRow = serverRows.find((s) => s.aplnId === row.aplnId); + if (!serverRow) continue; + if (rowDiffersFromServer(row, serverRow)) return true; + } + + return false; +} + export function buildSaveMultiPayload( rows: ElaprAplnRow[], elaprTmpltId: string, diff --git a/apps/as/src/pages/approval-line-management/lib/validators.ts b/apps/as/src/entities/elapr/lib/apln-validators.ts similarity index 71% rename from apps/as/src/pages/approval-line-management/lib/validators.ts rename to apps/as/src/entities/elapr/lib/apln-validators.ts index 4e301afa9..91f1a95cf 100644 --- a/apps/as/src/pages/approval-line-management/lib/validators.ts +++ b/apps/as/src/entities/elapr/lib/apln-validators.ts @@ -1,23 +1,19 @@ -import type { ElaprAplnRow } from "../model/apln-row-types"; +import type { ElaprAplnRow } from "../model/elapr-apln-row-types"; -const ATRZ_RECEIVER_CD = "ATRZ01"; const ATRZ_FINAL_CD = "ATRZ99"; +interface AtrzOrderValidateRow { + atrzSeComCd?: string; + isDeleted?: boolean; +} + export function getVisibleRows(rows: ElaprAplnRow[]): ElaprAplnRow[] { return rows.filter((row) => !row.isDeleted); } /** 접수자·최종승인자 위치·중복 검증 (최종 저장 시점) */ -export function validateAtrzOrder(rows: ElaprAplnRow[]): string | null { - const visibleRows = getVisibleRows(rows); - - const receiverCount = visibleRows.filter((row) => row.atrzSeComCd === ATRZ_RECEIVER_CD).length; - if (receiverCount === 0) { - return "접수자는 반드시 등록해야 합니다."; - } - if (receiverCount > 1) { - return "접수자는 한 명만 등록할 수 있습니다."; - } +export function validateAtrzOrder(rows: AtrzOrderValidateRow[]): string | null { + const visibleRows = rows.filter((row) => !row.isDeleted); const finalCount = visibleRows.filter((row) => row.atrzSeComCd === ATRZ_FINAL_CD).length; if (finalCount === 0) { @@ -27,10 +23,6 @@ export function validateAtrzOrder(rows: ElaprAplnRow[]): string | null { return "최종승인자는 한 명만 등록할 수 있습니다."; } - if (receiverCount === 1 && visibleRows[0]?.atrzSeComCd !== ATRZ_RECEIVER_CD) { - return "접수자는 결재선 첫 번째에 배치해야 합니다."; - } - if (finalCount === 1) { const lastRow = visibleRows[visibleRows.length - 1]; if (lastRow?.atrzSeComCd !== ATRZ_FINAL_CD) { diff --git a/apps/as/src/entities/elapr/lib/approval-request-utils.ts b/apps/as/src/entities/elapr/lib/approval-request-utils.ts new file mode 100644 index 000000000..7886e0ad2 --- /dev/null +++ b/apps/as/src/entities/elapr/lib/approval-request-utils.ts @@ -0,0 +1,23 @@ +import type { ElaprDocPrgrsResponseDto } from "../model/elapr-dmnd-interface"; + +export function hasCurrentUserPendingApprovalRequest( + list: ElaprDocPrgrsResponseDto[], + currentUserUuid: string | undefined, +): boolean { + if (!currentUserUuid) return false; + + return list.some( + (row) => row.aprvrUuid === currentUserUuid && row.atrzPrgrsSttsComCd === "ATRZ_PRGRS_002", + ); +} + +export function findCurrentUserPendingApprovalRequest( + list?: ElaprDocPrgrsResponseDto[], + currentUserUuid?: string | undefined, +): ElaprDocPrgrsResponseDto | undefined { + if (!currentUserUuid) return undefined; + + return list?.find( + (row) => row.aprvrUuid === currentUserUuid && row.atrzPrgrsSttsComCd === "ATRZ_PRGRS_002", + ); +} diff --git a/apps/as/src/pages/instl-sncn/lib/approval-utils.ts b/apps/as/src/entities/elapr/lib/approval-utils.ts similarity index 78% rename from apps/as/src/pages/instl-sncn/lib/approval-utils.ts rename to apps/as/src/entities/elapr/lib/approval-utils.ts index 83f22a011..00d7d7b2a 100644 --- a/apps/as/src/pages/instl-sncn/lib/approval-utils.ts +++ b/apps/as/src/entities/elapr/lib/approval-utils.ts @@ -1,4 +1,4 @@ -import type { ElaprDocPrgrsClientResponseDto } from "@/entities/instl-sncn"; +import type { ElaprDocPrgrsClientResponseDto } from "@/shared/model"; export interface ApproverStatusConfig { label: string; @@ -7,9 +7,10 @@ export interface ApproverStatusConfig { export const APPROVER_STATUS_MAP: Record = { ATRZ_PRGRS_001: { label: "대기", badgeClass: "bg-gray-100 text-gray-700" }, - ATRZ_PRGRS_002: { label: "요청", badgeClass: "bg-yellow-100 text-yellow-800" }, - ATRZ_PRGRS_003: { label: "승인", badgeClass: "bg-green-100 text-green-800" }, - ATRZ_PRGRS_004: { label: "반려", badgeClass: "bg-red-100 text-red-800" }, + ATRZ_PRGRS_002: { label: "요청", badgeClass: "bg-yellow-100 text-yellow-700" }, + ATRZ_PRGRS_003: { label: "승인", badgeClass: "bg-green-100 text-green-700" }, + ATRZ_PRGRS_004: { label: "반려", badgeClass: "bg-red-100 text-red-700" }, + ATRZ_PRGRS_999: { label: "결재안함", badgeClass: "bg-blue-100 text-blue-700" }, }; export function sortByAtrzSeqNo( diff --git a/apps/as/src/entities/elapr/lib/dmnd-save-utils.ts b/apps/as/src/entities/elapr/lib/dmnd-save-utils.ts new file mode 100644 index 000000000..7eee2894d --- /dev/null +++ b/apps/as/src/entities/elapr/lib/dmnd-save-utils.ts @@ -0,0 +1,43 @@ +import type { ElaprApln } from "../model/elapr-apln-interface"; +import type { ElaprAplnRow } from "../model/elapr-apln-row-types"; +import type { ElaprPrgrsSttsSaveRequestDto } from "../model/elapr-prgrs-stts-interface"; + +import { getVisibleRows } from "./apln-validators"; + +function toPrgrsSttsItem( + aprvrUuid: string | undefined, + atrzSeComCd: string | undefined, + atrzSeqNo: number | undefined, +): ElaprPrgrsSttsSaveRequestDto { + if (!aprvrUuid?.trim()) { + throw new Error("결재자가 지정되지 않은 행이 있습니다."); + } + if (!atrzSeComCd?.trim()) { + throw new Error("결재구분이 지정되지 않은 행이 있습니다."); + } + if (atrzSeqNo == null) { + throw new Error("결재순서번호가 지정되지 않은 행이 있습니다."); + } + + return { + aprvrUuid, + atrzSeComCd, + atrzSeqNo, + }; +} + +/** 서식 결재선 API 응답 → 기안 요청용 진행상태 목록 */ +export function buildElaprPrgrsSttsListFromApln( + items: ElaprApln[], +): ElaprPrgrsSttsSaveRequestDto[] { + return items.map((item) => toPrgrsSttsItem(item.aprvrUuid, item.atrzSeComCd, item.atrzSeqNo)); +} + +/** 결재선 그리드 행 → 기안 요청용 진행상태 목록 (서버가 seq 기준 상태 자동 결정) */ +export function buildElaprPrgrsSttsListFromAplnRows( + rows: ElaprAplnRow[], +): ElaprPrgrsSttsSaveRequestDto[] { + return getVisibleRows(rows).map((row) => + toPrgrsSttsItem(row.aprvrUuid, row.atrzSeComCd, row.atrzSeqNo), + ); +} diff --git a/apps/as/src/entities/elapr/lib/prgrs-stts-save-utils.ts b/apps/as/src/entities/elapr/lib/prgrs-stts-save-utils.ts new file mode 100644 index 000000000..6ea409344 --- /dev/null +++ b/apps/as/src/entities/elapr/lib/prgrs-stts-save-utils.ts @@ -0,0 +1,182 @@ +import { + ELAPR_PRGRS_STTS_SAVE_MODE, + type ElaprPrgrsSttsSaveRequestDto, +} from "../model/elapr-prgrs-stts-interface"; + +export const PRGRS_STTS_WAITING_CD = "ATRZ_PRGRS_001"; +export const PRGRS_STTS_APPROVED_CD = "ATRZ_PRGRS_003"; + +/** 서버 행 비교용 최소 필드 */ +export interface PrgrsSttsServerRow { + elaprPrgrsId?: string; + aprvrUuid?: string; + atrzSeComCd?: string; + atrzSeqNo?: number; + dcarbYn?: string; +} + +/** 결재선 그리드 로컬 행 — 진행상태 API 응답 + UI 보조 필드 */ +export interface ElaprPrgrsSttsRow { + rowKey: string; + elaprPrgrsId?: string; + elaprDmndId?: string; + aprvrUuid?: string; + aprvrNm?: string; + atrzSeComCd?: string; + atrzSeComNm?: string; + atrzSeqNo?: number; + atrzPrgrsSttsComCd?: string; + atrzPrgrsSttsComNm?: string; + rjctRsn?: string | null; + ognzNm?: string; + jbpsSeNm?: string; + jbgdSeNm?: string; + dcarbYn?: string; + isNew?: boolean; + isDirty?: boolean; + /** 소프트 삭제 — 최종 저장 시 mode:D */ + isDeleted?: boolean; + /** 수정 취소용 스냅샷 */ + snapshot?: ElaprPrgrsSttsRow; +} + +export function isPrgrsSttsRowEditable(row: ElaprPrgrsSttsRow): boolean { + return row.isNew === true || row.atrzPrgrsSttsComCd === PRGRS_STTS_WAITING_CD; +} + +export function isPrgrsSttsRowDcarbable(row: ElaprPrgrsSttsRow): boolean { + return row.atrzPrgrsSttsComCd !== PRGRS_STTS_APPROVED_CD; +} + +function getVisibleRows(rows: ElaprPrgrsSttsRow[]): ElaprPrgrsSttsRow[] { + return rows.filter((row) => !row.isDeleted); +} + +/** 그리드 행 순서(1-based)로 결재순서번호 일괄 채번 */ +export function assignPrgrsSttsAtrzSeqNumbers(rows: ElaprPrgrsSttsRow[]): ElaprPrgrsSttsRow[] { + return rows.map((row, index) => ({ ...row, atrzSeqNo: index + 1 })); +} + +export function validatePrgrsSttsRow(row: ElaprPrgrsSttsRow): string | null { + if (!row.atrzSeComCd?.trim()) { + return "결재구분을 선택해 주세요."; + } + if (!row.aprvrUuid?.trim()) { + return "결재자를 선택해 주세요."; + } + return null; +} + +export function validateAllPrgrsSttsRows( + rows: ElaprPrgrsSttsRow[], + isEditingLocked: boolean, +): string | null { + if (isEditingLocked) { + return "편집 중인 행을 먼저 저장하거나 취소해 주세요."; + } + + const visibleRows = getVisibleRows(rows); + if (visibleRows.length === 0) { + return "저장할 결재선이 없습니다."; + } + + for (const row of visibleRows) { + const fieldError = validatePrgrsSttsRow(row); + if (fieldError) return fieldError; + } + + return null; +} + +function rowDiffersFromServer(row: ElaprPrgrsSttsRow, serverRow: PrgrsSttsServerRow): boolean { + return ( + row.aprvrUuid !== serverRow.aprvrUuid || + row.atrzSeComCd !== serverRow.atrzSeComCd || + row.atrzSeqNo !== serverRow.atrzSeqNo || + (row.dcarbYn ?? "N") !== (serverRow.dcarbYn ?? "N") + ); +} + +function shouldIncludeUpdate(row: ElaprPrgrsSttsRow, serverRows: PrgrsSttsServerRow[]): boolean { + if (row.isDirty) return true; + if (!row.elaprPrgrsId) return false; + + const serverRow = serverRows.find((s) => s.elaprPrgrsId === row.elaprPrgrsId); + if (!serverRow) return false; + + return rowDiffersFromServer(row, serverRow); +} + +export function hasUnsavedPrgrsSttsChanges( + rows: ElaprPrgrsSttsRow[], + serverRows: PrgrsSttsServerRow[], +): boolean { + if (rows.some((row) => row.isDeleted && row.elaprPrgrsId)) return true; + if (rows.some((row) => row.isNew && !row.isDeleted)) return true; + + const visibleWithSeq = assignPrgrsSttsAtrzSeqNumbers(getVisibleRows(rows)); + + for (const row of visibleWithSeq) { + if (row.isNew) continue; + if (!row.elaprPrgrsId) continue; + if (row.isDirty) return true; + + const serverRow = serverRows.find((s) => s.elaprPrgrsId === row.elaprPrgrsId); + if (!serverRow) continue; + if (rowDiffersFromServer(row, serverRow)) return true; + } + + return false; +} + +export function buildPrgrsSttsSavePayload( + rows: ElaprPrgrsSttsRow[], + elaprDmndId: string, + serverRows: PrgrsSttsServerRow[], +): ElaprPrgrsSttsSaveRequestDto[] { + const payload: ElaprPrgrsSttsSaveRequestDto[] = []; + + for (const row of rows) { + if (row.isDeleted && row.elaprPrgrsId) { + payload.push({ + mode: ELAPR_PRGRS_STTS_SAVE_MODE.DELETE, + elaprPrgrsId: row.elaprPrgrsId, + elaprDmndId, + }); + } + } + + const visibleWithSeq = assignPrgrsSttsAtrzSeqNumbers(getVisibleRows(rows)); + + for (const row of visibleWithSeq) { + if (row.isNew) { + payload.push({ + mode: ELAPR_PRGRS_STTS_SAVE_MODE.CREATE, + elaprDmndId, + aprvrUuid: row.aprvrUuid, + atrzSeComCd: row.atrzSeComCd, + atrzSeqNo: row.atrzSeqNo, + atrzPrgrsSttsComCd: row.atrzPrgrsSttsComCd, + dcarbYn: row.dcarbYn ?? "N", + }); + continue; + } + + if (!row.elaprPrgrsId) continue; + + if (shouldIncludeUpdate(row, serverRows)) { + payload.push({ + mode: ELAPR_PRGRS_STTS_SAVE_MODE.UPDATE, + elaprPrgrsId: row.elaprPrgrsId, + elaprDmndId, + aprvrUuid: row.aprvrUuid, + atrzSeComCd: row.atrzSeComCd, + atrzSeqNo: row.atrzSeqNo, + atrzPrgrsSttsComCd: row.atrzPrgrsSttsComCd, + dcarbYn: row.dcarbYn ?? "N", + }); + } + } + + return payload; +} diff --git a/apps/as/src/pages/approval-line-management/lib/use-apln-rows.ts b/apps/as/src/entities/elapr/lib/use-apln-rows.ts similarity index 80% rename from apps/as/src/pages/approval-line-management/lib/use-apln-rows.ts rename to apps/as/src/entities/elapr/lib/use-apln-rows.ts index 01c71ce3b..384c8ffc1 100644 --- a/apps/as/src/pages/approval-line-management/lib/use-apln-rows.ts +++ b/apps/as/src/entities/elapr/lib/use-apln-rows.ts @@ -4,12 +4,11 @@ import { useCallback, useEffect, useState } from "react"; import { arrayMove } from "@dnd-kit/sortable"; -import type { ElaprApln } from "@/entities/elapr"; +import type { ElaprApln } from "../model/elapr-apln-interface"; +import type { ElaprAplnRow } from "../model/elapr-apln-row-types"; -import type { ElaprAplnRow } from "../model/apln-row-types"; - -import { assignAtrzSeqNumbers } from "./assign-atrz-seq"; -import { getVisibleRows } from "./validators"; +import { assignAtrzSeqNumbers } from "./apln-save-utils"; +import { getVisibleRows } from "./apln-validators"; function toRow(item: ElaprApln): ElaprAplnRow { return { @@ -101,7 +100,13 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) { return prev.map((row) => { if (row.rowKey !== editingRowKey) return row; if (row.snapshot) { - const { snapshot: _s, isNew: _n, isDirty: _d, isDeleted: _del, ...restored } = row.snapshot; + const { + snapshot: _s, + isNew: _n, + isDirty: _d, + isDeleted: _del, + ...restored + } = row.snapshot; return { ...restored, isNew: false, isDirty: false, isDeleted: false }; } return { ...row, isDirty: false }; @@ -110,19 +115,16 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) { setEditingRowKey(null); }, [editingRowKey]); - const commitEdit = useCallback( - (rowKey: string) => { - setRows((prev) => - prev.map((row) => { - if (row.rowKey !== rowKey) return row; - const { snapshot: _s, ...committed } = row; - return { ...committed, isDirty: true }; - }), - ); - setEditingRowKey(null); - }, - [], - ); + const commitEdit = useCallback((rowKey: string) => { + setRows((prev) => + prev.map((row) => { + if (row.rowKey !== rowKey) return row; + const { snapshot: _s, ...committed } = row; + return { ...committed, isDirty: true }; + }), + ); + setEditingRowKey(null); + }, []); const markDeleted = useCallback((rowKey: string) => { setRows((prev) => { @@ -158,7 +160,10 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) { const visibleKeys = new Set(reorderedVisible.map((r) => r.rowKey)); const deletedRows = prev.filter((r) => r.isDeleted); - const merged = [...reorderedVisible, ...deletedRows.filter((r) => !visibleKeys.has(r.rowKey))]; + const merged = [ + ...reorderedVisible, + ...deletedRows.filter((r) => !visibleKeys.has(r.rowKey)), + ]; payloadRows = reorderedVisible; return merged; diff --git a/apps/as/src/pages/approval-line-management/lib/use-dnd-sensors.ts b/apps/as/src/entities/elapr/lib/use-dnd-sensors.ts similarity index 93% rename from apps/as/src/pages/approval-line-management/lib/use-dnd-sensors.ts rename to apps/as/src/entities/elapr/lib/use-dnd-sensors.ts index 7c4e82ba9..039414303 100644 --- a/apps/as/src/pages/approval-line-management/lib/use-dnd-sensors.ts +++ b/apps/as/src/entities/elapr/lib/use-dnd-sensors.ts @@ -1,3 +1,5 @@ +"use client"; + import { KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; import { sortableKeyboardCoordinates } from "@dnd-kit/sortable"; diff --git a/apps/as/src/entities/elapr/lib/use-prgrs-stts-rows.ts b/apps/as/src/entities/elapr/lib/use-prgrs-stts-rows.ts new file mode 100644 index 000000000..f4a884e75 --- /dev/null +++ b/apps/as/src/entities/elapr/lib/use-prgrs-stts-rows.ts @@ -0,0 +1,240 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +import { arrayMove } from "@dnd-kit/sortable"; + +import type { ElaprDocPrgrsClientResponseDto } from "@/shared/model"; + +import { + assignPrgrsSttsAtrzSeqNumbers, + type ElaprPrgrsSttsRow, + isPrgrsSttsRowEditable, + PRGRS_STTS_WAITING_CD, +} from "./prgrs-stts-save-utils"; +import { createDraftRowKey } from "./use-apln-rows"; + +function getVisibleRows(rows: ElaprPrgrsSttsRow[]): ElaprPrgrsSttsRow[] { + return rows.filter((row) => !row.isDeleted); +} + +function toRow(item: ElaprDocPrgrsClientResponseDto): ElaprPrgrsSttsRow { + return { + rowKey: item.elaprPrgrsId ?? createDraftRowKey(), + elaprPrgrsId: item.elaprPrgrsId, + elaprDmndId: item.elaprDmndId, + aprvrUuid: item.aprvrUuid, + aprvrNm: item.aprvrNm, + atrzSeComCd: item.atrzSeComCd, + atrzSeComNm: item.atrzSeComNm, + atrzSeqNo: item.atrzSeqNo, + atrzPrgrsSttsComCd: item.atrzPrgrsSttsComCd, + atrzPrgrsSttsComNm: item.atrzPrgrsSttsComNm, + rjctRsn: item.rjctRsn, + ognzNm: item.ognzNm, + jbpsSeNm: item.jbpsSeNm, + jbgdSeNm: item.jbgdSeNm, + dcarbYn: item.dcarbYn ?? "N", + isNew: false, + isDirty: false, + isDeleted: false, + }; +} + +interface UsePrgrsSttsRowsOptions { + serverRows?: ElaprDocPrgrsClientResponseDto[]; + elaprDmndId?: string | null; +} + +export function usePrgrsSttsRows({ serverRows, elaprDmndId }: UsePrgrsSttsRowsOptions) { + const [rows, setRows] = useState([]); + const [editingRowKey, setEditingRowKey] = useState(null); + + useEffect(() => { + setRows((serverRows ?? []).map(toRow)); + setEditingRowKey(null); + }, [serverRows]); + + const isEditingLocked = editingRowKey != null; + const visibleRows = getVisibleRows(rows); + + const addRow = useCallback(() => { + if (!elaprDmndId || isEditingLocked) return; + + const rowKey = createDraftRowKey(); + + setRows((prev) => [ + ...prev, + { + rowKey, + elaprDmndId, + atrzSeComCd: "", + aprvrUuid: "", + aprvrNm: "", + atrzPrgrsSttsComCd: PRGRS_STTS_WAITING_CD, + dcarbYn: "N", + isNew: true, + isDirty: true, + isDeleted: false, + }, + ]); + setEditingRowKey(rowKey); + }, [elaprDmndId, isEditingLocked]); + + const startEdit = useCallback( + (rowKey: string) => { + if (isEditingLocked) return; + + const target = rows.find((r) => r.rowKey === rowKey); + if (!target || !isPrgrsSttsRowEditable(target)) return; + + setEditingRowKey(rowKey); + setRows((prev) => + prev.map((row) => (row.rowKey === rowKey ? { ...row, snapshot: { ...row } } : row)), + ); + }, + [isEditingLocked, rows], + ); + + const updateRow = useCallback( + (rowKey: string, patch: Partial) => { + if (editingRowKey !== rowKey) return; + setRows((prev) => + prev.map((row) => (row.rowKey === rowKey ? { ...row, ...patch, isDirty: true } : row)), + ); + }, + [editingRowKey], + ); + + const setDcarbYn = useCallback( + (rowKey: string, checked: boolean) => { + if (editingRowKey != null && editingRowKey !== rowKey) return; + + setRows((prev) => { + const target = prev.find((r) => r.rowKey === rowKey); + if (!target || target.isDeleted) return prev; + + const nextYn = checked ? "Y" : "N"; + + return prev.map((row) => { + if (row.isDeleted) return row; + + if (row.rowKey === rowKey) { + if ((row.dcarbYn ?? "N") === nextYn) return row; + return { ...row, dcarbYn: nextYn, isDirty: true }; + } + + if (!checked) return row; + if ((row.dcarbYn ?? "N") === "N") return row; + return { ...row, dcarbYn: "N", isDirty: true }; + }); + }); + }, + [editingRowKey], + ); + + const cancelEdit = useCallback(() => { + if (!editingRowKey) return; + + setRows((prev) => { + const target = prev.find((r) => r.rowKey === editingRowKey); + if (target?.isNew) { + return prev.filter((r) => r.rowKey !== editingRowKey); + } + return prev.map((row) => { + if (row.rowKey !== editingRowKey) return row; + if (row.snapshot) { + const { + snapshot: _s, + isNew: _n, + isDirty: _d, + isDeleted: _del, + ...restored + } = row.snapshot; + return { ...restored, isNew: false, isDirty: false, isDeleted: false }; + } + return { ...row, isDirty: false }; + }); + }); + setEditingRowKey(null); + }, [editingRowKey]); + + const commitEdit = useCallback((rowKey: string) => { + setRows((prev) => + prev.map((row) => { + if (row.rowKey !== rowKey) return row; + const { snapshot: _s, ...committed } = row; + return { ...committed, isDirty: true }; + }), + ); + setEditingRowKey(null); + }, []); + + const markDeleted = useCallback((rowKey: string) => { + setRows((prev) => { + const target = prev.find((r) => r.rowKey === rowKey); + if (!target || !isPrgrsSttsRowEditable(target)) return prev; + + if (target.isNew) { + return prev.filter((r) => r.rowKey !== rowKey); + } + + return prev.map((row) => + row.rowKey === rowKey ? { ...row, isDeleted: true, isDirty: true } : row, + ); + }); + setEditingRowKey((key) => (key === rowKey ? null : key)); + }, []); + + const reorderRows = useCallback( + (activeKey: string, overKey: string): ElaprPrgrsSttsRow[] => { + if (isEditingLocked) return []; + + let payloadRows: ElaprPrgrsSttsRow[] = []; + + setRows((prev) => { + const visible = getVisibleRows(prev); + const activeRow = visible.find((r) => r.rowKey === activeKey); + const overRow = visible.find((r) => r.rowKey === overKey); + if (!activeRow || !overRow) return prev; + if (!isPrgrsSttsRowEditable(activeRow) || !isPrgrsSttsRowEditable(overRow)) return prev; + + const oldIndex = visible.findIndex((r) => r.rowKey === activeKey); + const newIndex = visible.findIndex((r) => r.rowKey === overKey); + if (oldIndex < 0 || newIndex < 0) return prev; + + const reorderedVisible = assignPrgrsSttsAtrzSeqNumbers( + arrayMove(visible, oldIndex, newIndex), + ).map((row) => (isPrgrsSttsRowEditable(row) ? { ...row, isDirty: true } : row)); + + const visibleKeys = new Set(reorderedVisible.map((r) => r.rowKey)); + const deletedRows = prev.filter((r) => r.isDeleted); + const merged = [ + ...reorderedVisible, + ...deletedRows.filter((r) => !visibleKeys.has(r.rowKey)), + ]; + + payloadRows = reorderedVisible.filter(isPrgrsSttsRowEditable); + return merged; + }); + + return payloadRows; + }, + [isEditingLocked], + ); + + return { + rows, + visibleRows, + editingRowKey, + isEditingLocked, + addRow, + startEdit, + updateRow, + setDcarbYn, + cancelEdit, + commitEdit, + markDeleted, + reorderRows, + }; +} diff --git a/apps/as/src/entities/elapr/model/elapr-apln-interface.ts b/apps/as/src/entities/elapr/model/elapr-apln-interface.ts index a41add2cd..31e0d8a25 100644 --- a/apps/as/src/entities/elapr/model/elapr-apln-interface.ts +++ b/apps/as/src/entities/elapr/model/elapr-apln-interface.ts @@ -6,14 +6,14 @@ export const ELAPR_APLN_SAVE_MODE = { DELETE: "D", } as const; -export type ElaprAplnSaveMode = - (typeof ELAPR_APLN_SAVE_MODE)[keyof typeof ELAPR_APLN_SAVE_MODE]; +export type ElaprAplnSaveMode = (typeof ELAPR_APLN_SAVE_MODE)[keyof typeof ELAPR_APLN_SAVE_MODE]; export interface ElaprApln { aplnId: string; elaprTmpltId: string; aprvrUuid?: string; aprvrNm?: string; + aprvrUserId?: string; atrzSeComCd?: string; atrzSeComNm?: string; atrzSeqNo?: number; diff --git a/apps/as/src/pages/approval-line-management/model/apln-row-types.ts b/apps/as/src/entities/elapr/model/elapr-apln-row-types.ts similarity index 87% rename from apps/as/src/pages/approval-line-management/model/apln-row-types.ts rename to apps/as/src/entities/elapr/model/elapr-apln-row-types.ts index cabc7bb08..b3532df8a 100644 --- a/apps/as/src/pages/approval-line-management/model/apln-row-types.ts +++ b/apps/as/src/entities/elapr/model/elapr-apln-row-types.ts @@ -1,4 +1,4 @@ -import type { ElaprApln } from "@/entities/elapr"; +import type { ElaprApln } from "./elapr-apln-interface"; /** 그리드 로컬 행 — API 응답 + UI 보조 필드 */ export interface ElaprAplnRow extends Omit { diff --git a/apps/as/src/entities/elapr/model/elapr-dmnd-interface.ts b/apps/as/src/entities/elapr/model/elapr-dmnd-interface.ts index f653249f2..a4eb14c7c 100644 --- a/apps/as/src/entities/elapr/model/elapr-dmnd-interface.ts +++ b/apps/as/src/entities/elapr/model/elapr-dmnd-interface.ts @@ -1,11 +1,13 @@ -import type { PageableParams, PageResponse } from "@/shared/model"; +import type { ElaprDocPrgrsClientResponseDto, PageableParams, PageResponse } from "@/shared/model"; + +import type { ElaprPrgrsSttsSaveRequestDto } from "./elapr-prgrs-stts-interface"; /** 백엔드 ElaprDmndSaveRequestDto */ export interface ElaprDmndSaveRequestDto { elaprDocNo: string; - elaprTmpltId: string; dmndTtl: string; dmndCn: string; + elaprPrgrsSttsList?: ElaprPrgrsSttsSaveRequestDto[]; } /** 백엔드 ElaprDmndResponseDto */ @@ -13,7 +15,6 @@ export interface ElaprDmndResponseDto { elaprDmndId: string; rqstrUuid?: string; elaprDocNo: string; - elaprTmpltId: string; dmndTtl: string; dmndCn: string; atrzSttsComCd: string; @@ -32,23 +33,7 @@ export interface ElaprDmndSearchDto extends PageableParams { } /** 백엔드 ElaprDocPrgrsResponseDto */ -export interface ElaprDocPrgrsResponseDto { - elaprDocNo: string; - elaprDmndId: string; - elaprPrgrsId: string; - atrzSeqNo?: number; - atrzSeComCd: string; - atrzSeComNm?: string; - aprvrUuid?: string; - aprvrUuidNm?: string; - atrzPrgrsSttsComCd: string; - atrzPrgrsSttsComNm?: string; - rjctRsn?: string; - atrzDt?: string; - jbpsSeNm?: string; - jbgdSeNm?: string; - ognzNm?: string; -} +export type ElaprDocPrgrsResponseDto = ElaprDocPrgrsClientResponseDto; /** Controller public 메서드명·시그니처와 동일 */ export interface ElaprDmndService { diff --git a/apps/as/src/entities/elapr/model/elapr-prgrs-stts-interface.ts b/apps/as/src/entities/elapr/model/elapr-prgrs-stts-interface.ts new file mode 100644 index 000000000..7c24ff8eb --- /dev/null +++ b/apps/as/src/entities/elapr/model/elapr-prgrs-stts-interface.ts @@ -0,0 +1,92 @@ +import type { PageableParams, PageResponse } from "@/shared/model"; + +export const ELAPR_PRGRS_STTS_SAVE_MODE = { + CREATE: "C", + READ: "R", + UPDATE: "U", + DELETE: "D", +} as const; + +export type ElaprPrgrsSttsSaveMode = + (typeof ELAPR_PRGRS_STTS_SAVE_MODE)[keyof typeof ELAPR_PRGRS_STTS_SAVE_MODE]; + +/** 백엔드 ElaprPrgrsSttsResponseDto */ +export interface ElaprPrgrsSttsResponseDto { + elaprPrgrsId: string; + elaprDmndId?: string; + aprvrUuid?: string; + atrzSeComCd?: string; + atrzSeComNm?: string; + atrzSeqNo?: number; + atrzPrgrsSttsComCd?: string; + atrzPrgrsSttsComNm?: string; + rjctRsn?: string; + atrzDt?: string; + dcarbYn?: string; +} + +/** 백엔드 ElaprPrgrsSttsSaveRequestDto */ +export interface ElaprPrgrsSttsSaveRequestDto { + mode?: ElaprPrgrsSttsSaveMode; + elaprPrgrsId?: string; + elaprDmndId?: string; + aprvrUuid?: string; + atrzSeComCd?: string; + atrzSeqNo?: number; + atrzPrgrsSttsComCd?: string; + dcarbYn?: string; +} + +/** 백엔드 ElaprPrgrsSttsSearchDto */ +export interface ElaprPrgrsSttsSearchDto { + atrzSeComCd: string; + atrzSeqNo: number; + atrzPrgrsSttsComCd?: string; +} + +/** 백엔드 ElaprPrgrsSttsSearchDto + Pageable */ +export interface ElaprPrgrsSttsMyDocSearchParams extends ElaprPrgrsSttsSearchDto, PageableParams {} + +/** 백엔드 ElaprProcessRequestDto */ +export interface ElaprProcessRequestDto { + atrzPrgrsSttsComCd: string; + rjctRsn?: string; + finalApprovalYn?: boolean; +} + +/** 백엔드 ElaprApproverChangeRequestDto */ +export interface ElaprApproverChangeRequestDto { + aprvrUuid: string; +} + +/** 백엔드 ElaprApproverInsertRequestDto */ +export interface ElaprApproverInsertRequestDto { + aprvrUuid: string; + atrzSeComCd: string; + atrzSeqNo: number; +} + +/** Controller public 메서드명·시그니처와 동일 */ +export interface ElaprPrgrsSttsService { + findAllByDmndId(elaprDmndId?: string): Promise; + findById(elaprPrgrsId: string): Promise; + save(requestDtos: ElaprPrgrsSttsSaveRequestDto[]): Promise; + process(elaprPrgrsId: string, body: ElaprProcessRequestDto): Promise; + processByDocNoAndAprvrUuid( + elaprDocNo: string, + aprvrUuid: string, + body: ElaprProcessRequestDto, + ): Promise; + delete(elaprPrgrsId: string): Promise; + updateApprover( + elaprPrgrsId: string, + body: ElaprApproverChangeRequestDto, + ): Promise; + insertApprover( + elaprDmndId: string, + body: ElaprApproverInsertRequestDto, + ): Promise; + findMyElaperDocList( + params: ElaprPrgrsSttsMyDocSearchParams, + ): Promise>; +} diff --git a/apps/as/src/entities/elapr/model/reviewer-option.ts b/apps/as/src/entities/elapr/model/reviewer-option.ts new file mode 100644 index 000000000..2c9a72ef8 --- /dev/null +++ b/apps/as/src/entities/elapr/model/reviewer-option.ts @@ -0,0 +1,5 @@ +export interface ReviewerOption { + uuid: string; + name: string; + deptNm: string; +} diff --git a/apps/as/src/entities/elapr/ui/aprvr-picker-dialog.tsx b/apps/as/src/entities/elapr/ui/aprvr-picker-dialog.tsx new file mode 100644 index 000000000..272f1f3cf --- /dev/null +++ b/apps/as/src/entities/elapr/ui/aprvr-picker-dialog.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { RvwMnpwMngResponseDto, useRvwMnpwMngPage } from "@/entities/rvw-mnpw"; +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Skeleton, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@common/components"; + +import { type ReviewerOption } from "../model/reviewer-option"; + +const PAGE_SIZE = 500; + +interface AprvrPickerDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + atrzSeComCd: string; + onSelect: (reviewer: ReviewerOption) => void; +} + +function AprvrPickerDialog({ open, onOpenChange, atrzSeComCd, onSelect }: AprvrPickerDialogProps) { + const { data, isPending, isError } = useRvwMnpwMngPage( + { + atrzSeComCd, + page: 0, + size: PAGE_SIZE, + sort: "userUuidNm,asc", + }, + { enabled: open }, + ); + + const items = data?.content ?? []; + + function fromRvwMnpwMng(item: RvwMnpwMngResponseDto): ReviewerOption { + return { + uuid: item.userUuid, + name: item.userUuidNm ?? "", + deptNm: item.ognzNm ?? "", + }; + } + + function handleSelect(item: (typeof items)[number]) { + onSelect(fromRvwMnpwMng(item)); + onOpenChange(false); + } + + return ( + + + + 검토인력 선택 + + +
+ {isPending ? ( +
+ + +
+ ) : isError ? ( +

+ 검토인력 목록을 불러오지 못했습니다. +

+ ) : items.length === 0 ? ( +

+ 배치된 검토인력이 없습니다. +

+ ) : ( + + + + + + + + + + + 성명 + 부서 + 직위 + 직급 + 선택 + + + + {items.map((item) => ( + + {item.userUuidNm || item.userUuid} + {item.ognzNm || "-"} + {item.jbpsSeNm || "-"} + {item.jbgdSeNm || "-"} + + + + + ))} + +
+ )} +
+ + + + +
+
+ ); +} + +export { AprvrPickerDialog }; diff --git a/apps/as/src/entities/elapr/ui/elapr-apln-picker-grid.tsx b/apps/as/src/entities/elapr/ui/elapr-apln-picker-grid.tsx new file mode 100644 index 000000000..92c3d58c5 --- /dev/null +++ b/apps/as/src/entities/elapr/ui/elapr-apln-picker-grid.tsx @@ -0,0 +1,202 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { closestCenter, DndContext, type DragEndEvent } from "@dnd-kit/core"; +import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; + +import { useAlertStore } from "@/shared/lib"; +import { + Button, + Skeleton, + Table, + TableBody, + TableHead, + TableHeader, + TableRow, +} from "@common/components"; + +import { useElaprAplnPage } from "../api/queries"; +import { validateAplnRow } from "../lib/apln-validators"; +import { useAplnRows } from "../lib/use-apln-rows"; +import { useDndSensors } from "../lib/use-dnd-sensors"; +import type { ElaprAplnRow } from "../model/elapr-apln-row-types"; +import type { ReviewerOption } from "../model/reviewer-option"; + +import { AprvrPickerDialog } from "./aprvr-picker-dialog"; +import { SortableAplnPickerRow } from "./sortable-apln-picker-row"; + +interface ElaprAplnPickerGridProps { + elaprTmpltId: string | null; + onRowsChange?: (rows: ElaprAplnRow[]) => void; + onEditingLockedChange?: (locked: boolean) => void; +} + +function ElaprAplnPickerGrid({ + elaprTmpltId, + onRowsChange, + onEditingLockedChange, +}: ElaprAplnPickerGridProps) { + const [pickerOpen, setPickerOpen] = useState(false); + const [pickingRowKey, setPickingRowKey] = useState(null); + + const alert = useAlertStore((state) => state.setMessage); + + const aplnQuery = useElaprAplnPage({ + elaprTmpltId: elaprTmpltId ?? undefined, + page: 0, + size: 200, + sort: "atrzSeqNo,asc", + }); + + const { + rows, + visibleRows, + editingRowKey, + isEditingLocked, + addRow, + startEdit, + updateRow, + cancelEdit, + commitEdit, + markDeleted, + reorderRows, + } = useAplnRows({ + elaprTmpltId, + serverRows: aplnQuery.data?.content, + }); + + useEffect(() => { + onRowsChange?.(rows); + }, [rows, onRowsChange]); + + useEffect(() => { + onEditingLockedChange?.(isEditingLocked); + }, [isEditingLocked, onEditingLockedChange]); + + const sensors = useDndSensors(); + const isDisabled = !elaprTmpltId; + const pickingRow = pickingRowKey ? rows.find((r) => r.rowKey === pickingRowKey) : undefined; + + function handleDragEnd(event: DragEndEvent) { + const { active, over } = event; + if (!over || active.id === over.id) return; + + reorderRows(String(active.id), String(over.id)); + } + + function handleRowChange(row: ElaprAplnRow, patch: Partial) { + updateRow(row.rowKey, patch); + } + + function handleRowSave(row: ElaprAplnRow) { + const fieldError = validateAplnRow(row); + if (fieldError) { + alert(fieldError); + return; + } + + commitEdit(row.rowKey); + } + + function handleRowDelete(row: ElaprAplnRow) { + markDeleted(row.rowKey); + } + + function handleReviewerSelect(reviewer: ReviewerOption) { + if (!pickingRowKey) return; + updateRow(pickingRowKey, { + aprvrUuid: reviewer.uuid, + aprvrNm: reviewer.name, + }); + setPickingRowKey(null); + } + + return ( +
+
+

결재선 관리

+
+ +
+
+ + {aplnQuery.isPending && elaprTmpltId ? ( +
+ + +
+ ) : !elaprTmpltId ? ( +

결재서식을 선택하세요.

+ ) : visibleRows.length === 0 ? ( +

+ 등록된 결재선이 없습니다. [결재선 추가]로 결재자를 등록하세요. +

+ ) : ( + + + + + + + + + + + + + 결재순서 + 결재구분 + 결재자 + 관리 + + + + r.rowKey)} + strategy={verticalListSortingStrategy} + > + {visibleRows.map((row, index) => ( + startEdit(row.rowKey)} + onSave={() => handleRowSave(row)} + onCancel={cancelEdit} + onDelete={() => handleRowDelete(row)} + onChange={(patch) => handleRowChange(row, patch)} + onPickAprvr={() => { + setPickingRowKey(row.rowKey); + setPickerOpen(true); + }} + /> + ))} + + +
+
+ )} + + +
+ ); +} + +export { ElaprAplnPickerGrid }; diff --git a/apps/as/src/entities/elapr/ui/elapr-approval-dialog.tsx b/apps/as/src/entities/elapr/ui/elapr-approval-dialog.tsx new file mode 100644 index 000000000..c4cb3956d --- /dev/null +++ b/apps/as/src/entities/elapr/ui/elapr-approval-dialog.tsx @@ -0,0 +1,408 @@ +"use client"; + +import { useState } from "react"; + +import { closestCenter, DndContext, type DragEndEvent } from "@dnd-kit/core"; +import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { useQueryClient } from "@tanstack/react-query"; +import { AxiosError } from "axios"; + +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore } from "@/shared/lib"; +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Skeleton, + Table, + TableBody, + TableHead, + TableHeader, + TableRow, + Textarea, +} from "@common/components"; + +import { elaprPrgrsSttsService } from "../api/elapr-prgrs-stts-service"; +import { useElaprDocPrgrsByDocNo, useSaveElaprPrgrsStts } from "../api/queries"; +import { validateAtrzOrder } from "../lib/apln-validators"; +import { findCurrentUserPendingApprovalRequest } from "../lib/approval-request-utils"; +import { + buildPrgrsSttsSavePayload, + hasUnsavedPrgrsSttsChanges, + isPrgrsSttsRowEditable, + validateAllPrgrsSttsRows, + validatePrgrsSttsRow, +} from "../lib/prgrs-stts-save-utils"; +import { useDndSensors } from "../lib/use-dnd-sensors"; +import { usePrgrsSttsRows } from "../lib/use-prgrs-stts-rows"; +import type { ReviewerOption } from "../model/reviewer-option"; + +import { AprvrPickerDialog } from "./aprvr-picker-dialog"; +import { SortablePrgrsSttsRow } from "./sortable-prgrs-stts-row"; + +interface ElaprApprovalDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + elaprDocNo: string; + currentUserUuid: string; + onSuccess: () => void; +} + +const UNSAVED_PRGRS_STTS_MESSAGE = "결재선 변경 이력이 있습니다.\n결재선 저장을 해 주세요."; + +function ElaprApprovalDialog({ + open, + onOpenChange, + elaprDocNo, + currentUserUuid, + onSuccess, +}: ElaprApprovalDialogProps) { + const queryClient = useQueryClient(); + const alert = useAlertStore((state) => state.setMessage); + const [rjctRsn, setRjctRsn] = useState(""); + const [isProcessing, setIsProcessing] = useState(false); + const [pickerOpen, setPickerOpen] = useState(false); + const [pickingRowKey, setPickingRowKey] = useState(null); + + const { data: elaprDocPrgrsList, isLoading } = useElaprDocPrgrsByDocNo(open ? elaprDocNo : null); + + const elaprDmndId = elaprDocPrgrsList?.[0]?.elaprDmndId; + + const { + rows, + visibleRows, + editingRowKey, + isEditingLocked, + addRow, + startEdit, + updateRow, + setDcarbYn, + cancelEdit, + commitEdit, + markDeleted, + reorderRows, + } = usePrgrsSttsRows({ + serverRows: elaprDocPrgrsList, + elaprDmndId, + }); + + const savePrgrsSttsMutation = useSaveElaprPrgrsStts(); + const sensors = useDndSensors(); + + const currentUserPrgrs = findCurrentUserPendingApprovalRequest( + elaprDocPrgrsList, + currentUserUuid, + ); + const canApprove = Boolean(currentUserPrgrs?.elaprPrgrsId); + + const pickingRow = pickingRowKey ? rows.find((r) => r.rowKey === pickingRowKey) : undefined; + const isBusy = isProcessing || savePrgrsSttsMutation.isPending; + const isPrgrsSttsUnsaved = hasUnsavedPrgrsSttsChanges(rows, elaprDocPrgrsList ?? []); + + function handleDragEnd(event: DragEndEvent) { + const { active, over } = event; + if (!over || active.id === over.id) return; + + reorderRows(String(active.id), String(over.id)); + } + + function handleRowSave(rowKey: string) { + const row = rows.find((r) => r.rowKey === rowKey); + if (!row) return; + + const fieldError = validatePrgrsSttsRow(row); + if (fieldError) { + alert(fieldError); + return; + } + + commitEdit(rowKey); + } + + function handleReviewerSelect(reviewer: ReviewerOption) { + if (!pickingRowKey) return; + updateRow(pickingRowKey, { + aprvrUuid: reviewer.uuid, + aprvrNm: reviewer.name, + ognzNm: reviewer.deptNm, + }); + setPickingRowKey(null); + } + + function assertPrgrsSttsSaved(): boolean { + if (isEditingLocked) { + alert(UNSAVED_PRGRS_STTS_MESSAGE); + return false; + } + + if (isPrgrsSttsUnsaved) { + alert(UNSAVED_PRGRS_STTS_MESSAGE); + return false; + } + + return true; + } + + async function handleSavePrgrsStts() { + if (!elaprDmndId) { + alert("결재 요청 정보가 없습니다."); + return; + } + + const validationError = validateAllPrgrsSttsRows(rows, isEditingLocked); + if (validationError) { + alert(validationError); + return; + } + + const orderError = validateAtrzOrder(rows); + if (orderError) { + alert(orderError); + return; + } + + const payload = buildPrgrsSttsSavePayload(rows, elaprDmndId, elaprDocPrgrsList ?? []); + if (payload.length === 0) { + alert("변경된 결재선이 없습니다."); + return; + } + + setIsProcessing(true); + try { + await savePrgrsSttsMutation.mutateAsync(payload); + alert("결재선이 저장되었습니다."); + await queryClient.invalidateQueries({ + queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", elaprDocNo], + }); + onSuccess(); + } catch (error: unknown) { + let errorMessage = "결재선 저장 중 오류가 발생했습니다."; + if (error instanceof AxiosError) { + errorMessage = error.response?.data?.message ?? errorMessage; + } + alert(errorMessage); + } finally { + setIsProcessing(false); + } + } + + async function handleApprove() { + if (!currentUserPrgrs?.elaprPrgrsId) return; + if (!assertPrgrsSttsSaved()) return; + + setIsProcessing(true); + try { + await elaprPrgrsSttsService.process(currentUserPrgrs.elaprPrgrsId, { + atrzPrgrsSttsComCd: "ATRZ_PRGRS_003", + }); + alert("결재가 완료되었습니다."); + queryClient.invalidateQueries({ + queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", elaprDocNo], + }); + onSuccess(); + onOpenChange(false); + } catch (error: unknown) { + let errorMessage = ""; + if (error instanceof AxiosError) { + errorMessage = error.response?.data?.message ?? ""; + } else { + errorMessage = "결재 처리 중 오류가 발생했습니다."; + } + alert(errorMessage); + } finally { + setIsProcessing(false); + } + } + + async function handleReject() { + if (!currentUserPrgrs?.elaprPrgrsId) return; + if (!rjctRsn.trim()) { + alert("반려 사유를 입력해주세요."); + return; + } + if (!assertPrgrsSttsSaved()) return; + + setIsProcessing(true); + try { + await elaprPrgrsSttsService.process(currentUserPrgrs.elaprPrgrsId, { + atrzPrgrsSttsComCd: "ATRZ_PRGRS_004", + rjctRsn: rjctRsn.trim(), + }); + alert("반려 처리가 완료되었습니다."); + queryClient.invalidateQueries({ + queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", elaprDocNo], + }); + onSuccess(); + onOpenChange(false); + } catch (error: unknown) { + let errorMessage = ""; + if (error instanceof AxiosError) { + errorMessage = error.response?.data?.message ?? ""; + } else { + errorMessage = "반려 처리 중 오류가 발생했습니다."; + } + alert(errorMessage); + } finally { + setIsProcessing(false); + } + } + + function handleClose() { + setRjctRsn(""); + setPickingRowKey(null); + onOpenChange(false); + } + + return ( + + + + 전자결재 + + +
+
+
+

결재 진행 단계

+
+ + {elaprDmndId && ( + + )} +
+
+ + {isLoading ? ( +
+ + +
+ ) : visibleRows.length === 0 ? ( +
+ {elaprDmndId + ? "등록된 결재선이 없습니다. [결재선 추가]로 결재자를 등록하세요." + : "결재 정보가 없습니다."} +
+ ) : ( + + + + + + + + + + + + + + + 결재순서 + 결재구분 + 결재자 + 전결자 지정 + 결재상태 + 관리 + + + + r.rowKey)} + strategy={verticalListSortingStrategy} + > + {visibleRows.map((row, index) => ( + startEdit(row.rowKey)} + onSave={() => handleRowSave(row.rowKey)} + onCancel={cancelEdit} + onDelete={() => markDeleted(row.rowKey)} + onChange={(patch) => updateRow(row.rowKey, patch)} + onPickAprvr={() => { + setPickingRowKey(row.rowKey); + setPickerOpen(true); + }} + onToggleDcarb={(checked) => setDcarbYn(row.rowKey, checked)} + /> + ))} + + +
+
+ )} +
+ + {canApprove && ( +
+

반려 의견

+