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/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..ea1cd8dc1 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,88 @@ +{ + // IntelliSense를 사용하여 가능한 특성에 대해 알아보세요. + // 기존 특성에 대한 설명을 보려면 가리킵니다. + // 자세한 내용을 보려면 https://go.microsoft.com/fwlink/?linkid=830387을(를) 방문하세요. + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "dev:as 디버그", + "runtimeExecutable": "pnpm", + "runtimeArgs": [ + "run", + "dev:as" + ], + "skipFiles": [ + "/**" + ], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "sourceMaps": true, + "env": { + "NODE_OPTIONS": "--inspect" + } + }, + { + "type": "node", + "request": "launch", + "name": "dev:cs 디버그", + "runtimeExecutable": "pnpm", + "runtimeArgs": [ + "run", + "dev:cs" + ], + "skipFiles": [ + "/**" + ], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "sourceMaps": true, + "env": { + "NODE_OPTIONS": "--inspect" + } + }, + { + "type": "node", + "request": "attach", + "name": "dev:cs 서버 연결 (attach)", + "port": 9229, + "restart": true, + "skipFiles": [ + "/**" + ], + "sourceMaps": true, + "resolveSourceMapLocations": [ + "${workspaceFolder}/apps/cs/**", + "!**/node_modules/**" + ] + }, + { + "type": "chrome", + "request": "launch", + "name": "dev:cs 클라이언트 디버그 (Chrome)", + "url": "http://localhost:3000", + "webRoot": "${workspaceFolder}/apps/cs", + "sourceMaps": true + }, + { + "type": "node", + "request": "launch", + "name": "dev:st 디버그", + "runtimeExecutable": "pnpm", + "runtimeArgs": [ + "run", + "dev:st" + ], + "skipFiles": [ + "/**" + ], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "sourceMaps": true, + "env": { + "NODE_OPTIONS": "--inspect" + } + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..f60bf453a --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "chat.tools.terminal.autoApprove": { + "pnpm": true + } +} \ No newline at end of file diff --git a/apps/_template/package.json b/apps/_template/package.json index 9227dcf64..7f1f98585 100644 --- a/apps/_template/package.json +++ b/apps/_template/package.json @@ -19,7 +19,7 @@ "@tanstack/react-table": "^8.21.2", "axios": "^1.8.1", "dayjs": "^1.11.13", - "next": "15.3.8", + "next": "15.5.18", "react-hook-form": "^7.54.2", "zod": "^3.24.2" }, @@ -33,4 +33,4 @@ "tailwindcss": "^3.4.14", "typescript": "^5.7.2" } -} \ No newline at end of file +} diff --git a/apps/as/app/(with-base-layout)/approval-line-management/page.tsx b/apps/as/app/(with-base-layout)/approval-line-management/page.tsx new file mode 100644 index 000000000..956e03dae --- /dev/null +++ b/apps/as/app/(with-base-layout)/approval-line-management/page.tsx @@ -0,0 +1 @@ +export { ApprovalLineManagementPage as default } from "@/pages/approval-line-management"; diff --git a/apps/as/app/(with-base-layout)/checklist-management/page.tsx b/apps/as/app/(with-base-layout)/checklist-management/page.tsx new file mode 100644 index 000000000..1c8de0adc --- /dev/null +++ b/apps/as/app/(with-base-layout)/checklist-management/page.tsx @@ -0,0 +1 @@ +export { ChecklistManagementPage as default } from "@/pages/checklist-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)/esntl-atch-file-mng/page.tsx b/apps/as/app/(with-base-layout)/esntl-atch-file-mng/page.tsx new file mode 100644 index 000000000..9516e46c5 --- /dev/null +++ b/apps/as/app/(with-base-layout)/esntl-atch-file-mng/page.tsx @@ -0,0 +1 @@ +export { EsntlAtchFileMngPage as default } from "@/pages/esntl-atch-file-mng"; 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/[id]/edit/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn/[id]/edit/page.tsx new file mode 100644 index 000000000..35d336bd3 --- /dev/null +++ b/apps/as/app/(with-base-layout)/instl-sncn/[id]/edit/page.tsx @@ -0,0 +1,10 @@ +import { InstlEditPage } from "@/pages/instl-sncn"; + +interface InstlSncnEditRouteProps { + params: Promise<{ id: string }>; +} + +export default async function Page({ params }: InstlSncnEditRouteProps) { + const { id } = await params; + return ; +} diff --git a/apps/as/app/(with-base-layout)/instl-sncn/[id]/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn/[id]/page.tsx new file mode 100644 index 000000000..89cd7a329 --- /dev/null +++ b/apps/as/app/(with-base-layout)/instl-sncn/[id]/page.tsx @@ -0,0 +1,6 @@ +import { InstlViewPage } from "@/pages/instl-sncn"; + +export default async function InstlSncnDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return ; +} diff --git a/apps/as/app/(with-base-layout)/instl-sncn/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn/page.tsx new file mode 100644 index 000000000..a3d6e05ac --- /dev/null +++ b/apps/as/app/(with-base-layout)/instl-sncn/page.tsx @@ -0,0 +1,3 @@ +import { InstlListPage } from "@/pages/instl-sncn"; + +export default InstlListPage; diff --git a/apps/as/app/(with-base-layout)/instl-sncn/regist/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn/regist/page.tsx new file mode 100644 index 000000000..476d0b7c2 --- /dev/null +++ b/apps/as/app/(with-base-layout)/instl-sncn/regist/page.tsx @@ -0,0 +1,14 @@ +import { InstlRegistPage } from "@/pages/instl-sncn"; + +interface InstlSncnRegistRouteProps { + searchParams: Promise<{ taskSeComCd?: string }>; +} + +export default async function Page({ searchParams }: InstlSncnRegistRouteProps) { + const params = await searchParams; + const taskSeComCd = params.taskSeComCd + ? params.taskSeComCd.split(",").filter(Boolean) + : undefined; + + return ; +} diff --git a/apps/as/app/(with-base-layout)/rvw-mnpw-mng/page.tsx b/apps/as/app/(with-base-layout)/rvw-mnpw-mng/page.tsx new file mode 100644 index 000000000..01cf17fb5 --- /dev/null +++ b/apps/as/app/(with-base-layout)/rvw-mnpw-mng/page.tsx @@ -0,0 +1 @@ +export { RvwMnpwMngPage as default } from "@/pages/rvw-mnpw-mng"; diff --git a/apps/as/app/api/proxy/[...url]/route.ts b/apps/as/app/api/proxy/[...url]/route.ts index 36ec17557..d54f08656 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"); @@ -38,7 +43,8 @@ async function handler( body = await request.json(); } - const result = await fetch(`${SERVER_API_URL}/${params.url.join("/")}`, { + const targetUrl = `${SERVER_API_URL}/${params.url.join("/")}`; + const result = await fetch(targetUrl, { method, headers, body: JSON.stringify(body), @@ -58,18 +64,11 @@ async function handler( [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: COOKIE_DOMAIN, path: "/", }); } @@ -79,6 +78,7 @@ async function handler( return NextResponse.json({ message: "Invalid Credentials" }, { status: 401 }); } catch (error) { + console.error(`[Proxy API] Error caught in handler:`, error); return NextResponse.json({ message: "Server Error", error }, { status: 500 }); } } diff --git a/apps/as/docs/approval-line-management/SPEC.md b/apps/as/docs/approval-line-management/SPEC.md index 8528f8241..1ae5e65ae 100644 --- a/apps/as/docs/approval-line-management/SPEC.md +++ b/apps/as/docs/approval-line-management/SPEC.md @@ -4,7 +4,7 @@ | 항목 | 내용 | | ---------- | -------------------------------------------- | -| 버전 | 0.1 (초안) | +| 버전 | 0.3 | | 프로세스명 | UI설계 | | 화면명 | 결재선 관리 | | 화면 유형 | [v] 조회 [v] 입력 [v] 수정 [v] 삭제 [ ] 출력 | @@ -14,7 +14,7 @@ ## 2. 화면 목적 -전자결재 처리에 사용할 **결재서식**(템플릿)을 등록·관리하고, 각 서식별로 **결재선**(접수 -> 검토 -> 승인 -> 최종승인 단계)을 구성한다. 상단의 결재서식 목록에서 하나를 선택하면 하단 영역에 해당 서식의 결재 단계(결재자/순서/구분)가 표시되며, 사용자는 결재자 추가·수정·삭제와 결재선 저장을 수행한다. +전자결재 처리에 사용할 **결재서식**(템플릿)을 등록·관리하고, 각 서식별로 **결재선**(결재구분·결재자·순서)을 구성한다. 상단의 결재서식 목록에서 하나를 선택하면 하단 영역에 해당 서식의 결재선이 표시되며, 사용자는 **그리드 인라인 폼**으로 결재자를 등록·수정한다. **행 추가 또는 한 행 수정 중에는 다른 행을 편집할 수 없다**(단일 행 편집 잠금). 드래그로 순서를 변경한 뒤 **[결재선 저장]** 으로 서버에 반영한다. --- @@ -23,30 +23,27 @@ ### 3-1. 전체 레이아웃 ``` -┌──────────────────────────────────────────────────────────────────────────────┐ -│ 페이지 타이틀: 결재선 관리 브레드크럼: 디지털행정지원 > … > │ -├──────────────────────────────────────────────────────────────────────────────┤ -│ [영역 A] 전자결재선 관리 목록 [서식 등록] │ -│ ┌──────────────────────────────────────────────────────────────────────┐ │ -│ │ 전자결재서식아이디 | 결재서식명 | 문서구분 | 등록일시 | 관리 │ │ -│ ├──────────────────────────────────────────────────────────────────────┤ │ -│ │ TMPL-0001 (선택 행 강조) … [수정][삭제] │ │ -│ │ TMPL-0002 … [수정][삭제] │ │ -│ └──────────────────────────────────────────────────────────────────────┘ │ -├──────────────────────────────────────────────────────────────────────────────┤ -│ [영역 B] 결재선 관리 선택된 서식: TMPL-0001 │ -│ ┌──────────────────────────────────────────────────────────────────────┐ │ -│ │ [영역 B-1] 결재자 입력 폼 │ │ -│ │ 결재선아이디(자동발급) | 결재구분 ▾ | 결재자명 [검토인력 선택] │ │ -│ │ 결재순서번호 [결재자 추가] [결재선 저장] [초기화] │ │ -│ ├──────────────────────────────────────────────────────────────────────┤ │ -│ │ [영역 B-2] 결재선 그리드 │ │ -│ │ 결재선아이디 | 결재순서번호 | 결재구분 | 결재자명 | 등록일시 | 관리 │ │ -│ │ APLN-0001 | 1 | 접수 | 김민수 | … |[수정][삭제] │ -│ │ APLN-0002 | 2 | 검토 | 박지훈 | … |[수정][삭제] │ -│ │ APLN-0003 | 3 | 최종승인 | 이서연 | … |[수정][삭제] │ -│ └──────────────────────────────────────────────────────────────────────┘ │ -└──────────────────────────────────────────────────────────────────────────────┘ +┌───────────────────────────────────────────────────────────────────────────────────────────────┐ +│ 페이지 타이틀: 결재선 관리 브레드크럼: 디지털행정지원 > … > │ +├───────────────────────────────────────────────────────────────────────────────────────────────┤ +│ [영역 A] 전자결재선 관리 목록 [서식 등록] │ +│ ┌──────────────────────────────────────────────────────────────────────┐ │ +│ │ 전자결재서식아이디 | 결재서식명 | 문서구분 | 등록일시 | 관리 │ │ +│ ├──────────────────────────────────────────────────────────────────────┤ │ +│ │ TMPL-0001 (선택 행 강조) … [수정][삭제] │ │ +│ └──────────────────────────────────────────────────────────────────────┘ │ +├───────────────────────────────────────────────────────────────────────────────────────────────┤ +│ [영역 B] 결재선 관리 선택된 서식: TMPL-0001 [결재선 저장] │ +│ ┌─────────────────────────────────────────────────────────────────────────────────────────┐ │ +│ │ [영역 B-2] 결재선 그리드 (인라인 편집 + 행 드래그 — @dnd-kit) │ │ +│ │ ⋮⋮ | 결재선아이디 | 결재순서 | 결재구분 ▼ | 결재자 [선택] | 등록일시 | 관리 │ │ +│ │ ⋮⋮ | APLN-0001 | [ 1 ] | [검토자 ▼] | [김민수 ][선택] | … | [저장][삭제] │ │ +│ │ ⋮⋮ | (신규) | [ 2 ] | [승인자 ▼] | [ ][선택] | — | [저장][취소] │ │ +│ └─────────────────────────────────────────────────────────────────────────────────────────┘ │ +│ [행 추가] │ +│ ※ 결재자 등록·수정은 그리드 셀에서 직접 입력. 검토인력만 [선택] 팝업/다이얼로그 │ +│ ※ 행 추가·수정 중: 해당 행만 편집 가능, 나머지 행·[행 추가]·드래그 비활성 │ +└───────────────────────────────────────────────────────────────────────────────────────────────┘ ``` ### 3-2. 화면 데이터 흐름 @@ -55,102 +52,116 @@ BEGIN // 최초 진입 - CALL TBD.결재서식목록조회() + CALL GET /as-elapr-service/api/v1/elapr-tmplt DISPLAY 결재서식 그리드 (영역 A) - DISPLAY 빈 상태 안내 (영역 B) // 서식 미선택 시 폼·그리드 비활성 + DISPLAY 빈 상태 안내 (영역 B) // 서식 미선택 시 그리드·툴바 비활성 // 영역 A: 서식 행 선택 ON 영역 A 행 CLICK DO - SET 선택된 서식 = 행.전자결재서식아이디 // 영역 B 상단 라벨에 표시 - CALL TBD.결재선목록조회(서식아이디) - DISPLAY 결재선 그리드 (영역 B-2) - RESET 결재자 입력 폼 (영역 B-1) + SET 선택된 서식 = 행.elaprTmpltId + CALL GET /as-elapr-service/api/v1/elapr-apln?elaprTmpltId={id} + DISPLAY 결재선 그리드 (영역 B-2) — 조회 모드 END ON // 영역 A: 신규 서식 등록 ON [서식 등록] 버튼 CLICK DO OPEN 결재서식 등록 다이얼로그 ON 확인 DO - CALL TBD.결재서식등록(서식정보) - CALL TBD.결재서식목록조회() // → 영역 A 갱신 + CALL POST /as-elapr-service/api/v1/elapr-tmplt + CALL GET /as-elapr-service/api/v1/elapr-tmplt END ON END ON - // 영역 A: 서식 수정·삭제 - ON 영역 A [수정] 버튼 CLICK DO - OPEN 결재서식 수정 다이얼로그(선택 행) - ON 확인 DO - CALL TBD.결재서식수정(서식아이디, 서식정보) - CALL TBD.결재서식목록조회() - END ON + // 영역 A: 서식 수정·삭제 (다이얼로그 유지) + ON 영역 A [수정] / [삭제] DO + (기존과 동일 — PUT / DELETE elapr-tmplt) END ON - ON 영역 A [삭제] 버튼 CLICK DO - CONFIRM "선택한 결재서식을 삭제하시겠습니까?" - CALL TBD.결재서식삭제(서식아이디) - CALL TBD.결재서식목록조회() - IF 삭제된 서식이 현재 선택된 서식 THEN - RESET 영역 B (그리드 비우기, 폼 비활성) - END IF - END ON - - // 영역 B-1: 결재자 입력 폼 - ON [검토인력 선택] 버튼 CLICK DO - VALIDATE 서식 선택됨 // 미선택 시 안내 후 종료 - OPEN 검토인력 선택 다이얼로그 - ON 선택 DO - SET 결재자명 = 선택인력.성명 - SET 결재자ID = 선택인력.사용자ID // 내부 상태로 보관 - END ON - END ON - - ON 결재구분 SELECT CHANGE DO - SET 결재구분 = 선택값 // 접수 / 검토 / 최종승인 - END ON - - ON 결재순서번호 INPUT CHANGE DO - SET 결재순서번호 = 값(숫자) - END ON - - // 영역 B-1: 결재자 추가 (그리드에 행 추가, 미저장 상태) - ON [결재자 추가] 버튼 CLICK DO + // 영역 B: 행 추가 (인라인 신규 행) + ON [행 추가] 버튼 CLICK DO VALIDATE 서식 선택됨 - · 결재구분 입력 - · 결재자 선택됨 - · 결재순서번호 > 0 (중복 시 안내) - ADD 입력 폼 값을 영역 B-2 그리드에 임시 행으로 추가 - RESET 결재자 입력 폼 (영역 B-1) - // 결재선아이디는 저장 시점에 서버에서 자동 발급되므로 - // 임시 행은 클라이언트 임시 키로 식별 (예: APLN-0007 같은 미발급 표기 또는 임시 ID) + VALIDATE 편집 중인 행 없음 (editingRowKey == null) + ADD 영역 B-2 그리드 맨 아래 편집 가능 행 + SET editingRowKey = rowKey (draft-{timestamp}) + SET 편집상태 = 신규 + SET aplnId = 미설정 (POST 저장 시 서버 fn_gen_apln_id 발급) + SET atrzSeqNo = 현재 행 수 + 1 + SET atrzSeComCd, aprvrUuid = 빈 값 + DISABLE 나머지 행 편집 · [행 추가] · 드래그 · [결재선 저장](정책에 따라) END ON - // 영역 B-1: 결재선 저장 (그리드 전체 일괄 저장) + // 영역 B-2: 기존 행 수정 진입 + ON 행별 [수정] CLICK DO + VALIDATE 편집 중인 행 없음 + SET editingRowId = 행.aplnId + SET 해당 행만 편집 모드 + DISABLE 나머지 행 편집 · [행 추가] · 드래그 + END ON + + // 영역 B-2: 인라인 편집 + ON 그리드 셀 변경 DO + VALIDATE 변경 대상 행 == editingRowId + SET 해당 행 dirty = true + END ON + + ON 셀 [선택] (검토인력) CLICK DO + OPEN 검토인력 선택 다이얼로그 (단일 선택) + ON 선택 DO + SET 행.aprvrUuid = 선택인력.uuid + SET 행.aprvrNm = 선택인력.성명 // UI 표시용 (응답 DTO에 없음 — 로컬 보조 필드) + END ON + END ON + + // 영역 B-2: 행별 저장 + ON 행별 [저장] CLICK DO + VALIDATE atrzSeComCd, aprvrUuid, atrzSeqNo + IF 편집상태 = 신규 THEN + CALL POST /as-elapr-service/api/v1/elapr-apln + ELSE + CALL PUT /as-elapr-service/api/v1/elapr-apln/{aplnId} + END IF + CALL GET /as-elapr-service/api/v1/elapr-apln?elaprTmpltId={id} + CLEAR editingRowId + ENABLE 전체 행 조회 모드 · [행 추가] · 드래그 + END ON + + // 영역 B: 결재선 저장 (드래그 순서 일괄 반영) ON [결재선 저장] 버튼 CLICK DO - VALIDATE 서식 선택됨 · 결재선 1건 이상 · 순서번호 중복 없음 - CALL TBD.결재선일괄저장(서식아이디, 결재선목록) - CALL TBD.결재선목록조회(서식아이디) // → 영역 B-2 갱신 + VALIDATE 서식 선택됨 + VALIDATE 편집 중인 행 없음 + REORDER atrzSeqNo = 그리드 행 순서 (1..n) + FOR EACH dirty 또는 순서 변경된 행 DO + CALL PUT /as-elapr-service/api/v1/elapr-apln/{aplnId} // atrzSeqNo 등 반영 + END FOR + FOR EACH 신규 미저장 행 DO + CALL POST /as-elapr-service/api/v1/elapr-apln + END FOR + CALL GET /as-elapr-service/api/v1/elapr-apln?elaprTmpltId={id} TOAST "결재선이 저장되었습니다." END ON - // 영역 B-1: 초기화 - ON [초기화] 버튼 CLICK DO - RESET 결재자 입력 폼 (영역 B-1) → 기본값 - (결재구분=접수, 결재자명="", 결재순서번호=1) + // 영역 B-2: 행 드래그 + ON 영역 B-2 행 DRAG END DO + VALIDATE 편집 중인 행 없음 + REORDER 그리드 행 + SET 각 행 atrzSeqNo = 행 인덱스 + 1 (UI 즉시 반영, 서버는 [결재선 저장] 또는 행별 [저장]) END ON - // 영역 B-2: 그리드 행 수정·삭제 - ON 영역 B-2 [수정] 버튼 CLICK DO - OPEN 결재자 수정 다이얼로그(선택 행) - ON 확인 DO - CALL TBD.결재선수정(결재선아이디, 결재선정보) - CALL TBD.결재선목록조회(서식아이디) - END ON + // 영역 B-2: 행 삭제 + ON 행별 [삭제] CLICK DO + CONFIRM "선택한 결재선을 삭제하시겠습니까?" + IF 서버 저장된 행 THEN + CALL DELETE /as-elapr-service/api/v1/elapr-apln/{aplnId} + END IF + REMOVE 그리드 행 (신규 미저장 행은 API 호출 없이 제거) + CALL GET /as-elapr-service/api/v1/elapr-apln?elaprTmpltId={id} END ON - ON 영역 B-2 [삭제] 버튼 CLICK DO - CONFIRM "선택한 결재자를 삭제하시겠습니까?" - CALL TBD.결재선삭제(결재선아이디) - CALL TBD.결재선목록조회(서식아이디) + ON 행별 [취소] CLICK DO // 신규·수정 공통 + IF 편집상태 = 신규 THEN REMOVE 임시 행 + ELSE RESTORE 행 스냅샷 (수정 취소) + CLEAR editingRowId + ENABLE 전체 행 조회 모드 · [행 추가] · 드래그 END ON END @@ -171,135 +182,179 @@ END ### 4-2. 그리드 컬럼 -| 컬럼명 | Field | 타입 | 비고 | -| ------------------ | ----------------- | ------ | --------------------------------------------- | -| 전자결재서식아이디 | `elctAtrzTmpltId` | string | 예) `TMPL-0001` (서버 자동 발급, TBD) | -| 결재서식명 | `atrzTmpltNm` | string | 예) `설치인가 검토 결재` | -| 문서구분 | `docSeNm` | string | 예) `설치인가`, `기본계획` (코드/명 매핑 TBD) | -| 등록일시 | `regDt` | string | `YYYY-MM-DD HH:mm:ss` | -| 관리 | — | — | 행별 **[수정] · [삭제]** 버튼 | +| 컬럼명 | Field | 타입 | 비고 | +| ------------------ | -------------- | ------ | --------------------------------------- | +| 전자결재서식아이디 | `elaprTmpltId` | string | 등록 시 서버(DB 함수) 자동 발급 | +| 결재서식명 | `elaprTmpltNm` | string | | +| 문서구분 | `docSeComNm` | string | 코드 `docSeComCd`, 상위코드 `ASDOCSECD` | +| 등록일시 | `regDt` | string | `yyyy-MM-dd HH:mm:ss` | +| 관리 | — | — | 행별 **[수정] · [삭제]** 버튼 | -- 행 클릭 시: 해당 서식을 **선택 상태**로 만들고 영역 B 라벨(`선택된 서식: {전자결재서식아이디}`)과 영역 B-2 그리드를 갱신한다. -- 페이지네이션: TBD (스크린샷에는 미표시). +- 행 클릭 시: `elaprTmpltId` 선택 → 영역 B 라벨·B-2 그리드 갱신. +- 페이지네이션: Spring `Pageable` (`page`, `size`, `sort`). ### 4-3. 버튼 -| 버튼명 | 위치 | 동작 | -| --------- | -------------- | ----------------------------------------------------------------- | -| 서식 등록 | 섹션 우측 상단 | 결재서식 등록 다이얼로그 오픈 (입력 항목·필수값 TBD) | -| 수정 | 행별 | 결재서식 수정 다이얼로그 오픈 (해당 행 값으로 초기화) | -| 삭제 | 행별 | 확인 다이얼로그 후 삭제. 선택 중인 서식이면 영역 B 상태도 초기화. | +| 버튼명 | 위치 | 동작 | +| --------- | -------------- | ----------------------------------------------- | +| 서식 등록 | 섹션 우측 상단 | 결재서식 등록 다이얼로그 오픈 | +| 수정 | 행별 | 결재서식 수정 다이얼로그 (기존 값 초기화) | +| 삭제 | 행별 | 확인 후 삭제. 선택 중인 서식이면 영역 B 초기화. | ### 4-4. 다이얼로그(개요) -| 다이얼로그명 | 트리거 | 입력 항목(예상) | 비고 | -| ------------------ | ---------------- | ------------------------------ | ------------------------ | -| 결재서식 등록 | [서식 등록] 클릭 | 결재서식명, 문서구분(드롭다운) | 등록 성공 시 영역 A 갱신 | -| 결재서식 수정 | 행별 [수정] | 결재서식명, 문서구분 | 기존 값으로 초기화 | -| 결재서식 삭제 확인 | 행별 [삭제] | (확인/취소) | | +| 다이얼로그명 | 트리거 | 입력 항목 | 비고 | +| ------------------ | ----------- | ----------------------------------------------- | ----------------------------------------- | +| 결재서식 등록 | [서식 등록] | `elaprTmpltNm`, `docSeComCd` | `elaprTmpltExpln` 선택, ID는 저장 후 응답 | +| 결재서식 수정 | 행별 [수정] | `elaprTmpltNm`, `docSeComCd`, `elaprTmpltExpln` | ID는 변경 불가 | +| 결재서식 삭제 확인 | 행별 [삭제] | (확인/취소) | | --- ## 5. 영역 B: 결재선 관리 -영역 A에서 선택된 결재서식을 기준으로 결재 단계(접수·검토·최종승인)와 결재자, 순서를 관리한다. 영역 B는 상단 **결재자 입력 폼(B-1)** 과 하단 **결재선 그리드(B-2)** 로 구성된다. +영역 A에서 선택된 `elaprTmpltId` 기준으로 결재선을 관리한다. **등록·수정은 B-2 그리드 인라인 폼**으로 수행하며, 검토인력 지정만 **[선택]** 보조 다이얼로그를 사용한다. -### 5-1. 헤더 / 상태 표시 +### 5-1. 헤더 / 툴바 -| 항목 | 내용 | -| ----------- | ------------------------------------------------------------------------------------------------------------ | -| 섹션 타이틀 | 결재선 관리 | -| 우측 라벨 | `선택된 서식: {전자결재서식아이디}` — 영역 A에서 선택한 서식 ID를 강조 색으로 표시. 미선택 시 영역 B 비활성. | +| 항목 | 내용 | +| ----------- | --------------------------------------------------------------------------------------------------------------------------- | +| 섹션 타이틀 | 결재선 관리 | +| 우측 라벨 | `선택된 서식: {elaprTmpltId}` — 미선택 시 영역 B 비활성 | +| 우측 버튼 | **[행 추가]** — 편집 중인 행이 없을 때만 활성 · **[결재선 저장]** — 편집 중인 행이 없을 때만 활성(순서·미저장 행 일괄 반영) | -### 5-2. 영역 B-1: 결재자 입력 폼 - -#### 필드 정의 - -| 표시명 | 입력 유형 | 필수 | 기본값 | 데이터 소스 | API 파라미터 매핑 | 비고 | -| ---------------------- | ----------------- | ---- | ----------- | -------------------- | ----------------- | --------------------------------------------------------------------- | -| 결재선아이디(자동발급) | 텍스트(읽기 전용) | — | `APLN-XXXX` | 서버 자동 발급 (TBD) | TBD | 입력 불가. 저장 시 서버 발급. 표시값은 미발급 자리표시(또는 임시 ID). | -| 결재구분 | 드롭다운 | 예 | `접수` | 코드 (TBD) | TBD | 옵션: **접수 / 검토 / 최종승인** (그리드 행 기준) | -| 결재자명 | 텍스트(읽기 전용) | 예 | `""` | [검토인력 선택] 결과 | TBD | 자유 입력 불가. 다이얼로그 선택 결과로 채움. | -| 결재순서번호 | 숫자 입력 | 예 | `1` | — | TBD | 1 이상의 정수. 동일 서식 내 중복 검증 필요. | - -플레이스홀더 / 표시 텍스트: - -- 결재자명 입력란 플레이스홀더: `검토인력을 선택하세요` -- 결재선아이디 표시 예시: `APLN-0007` (다음 발급 예정 번호 노출 또는 빈 칸 — 표시 정책 TBD) - -#### 버튼 - -| 버튼명 | 동작 | -| ------------- | ------------------------------------------------------------------------------------------------------------- | -| 검토인력 선택 | **검토인력 선택 다이얼로그** 오픈. 선택 시 `결재자명`과 내부 `결재자 ID`를 폼 상태에 반영. | -| 결재자 추가 | 입력 폼 값을 영역 B-2 그리드에 **임시 행**으로 추가하고 폼을 초기화한다. (서버 호출 없음, 저장 시 일괄 반영.) | -| 결재선 저장 | 영역 B-2의 결재선 목록을 일괄 저장 API로 전송한다. 성공 시 그리드 재조회 + 토스트 표시. | -| 초기화 | 입력 폼을 기본값으로 리셋(결재구분=접수, 결재자명=빈값, 결재순서번호=1). | - -> 정책 메모: **[결재자 추가]** 와 **[결재선 저장]** 의 분리에 대해서는 두 가지 운영이 가능하다. 본 SPEC 초안은 그리드에 임시 행을 누적해 두고 [결재선 저장] 시점에 서버에 일괄 반영하는 모델을 가정한다. 실제 구현 방식(추가 즉시 저장 vs 일괄 저장)은 백엔드 API 확정 후 재정의(TBD). - -#### 다이얼로그 - -| 다이얼로그명 | 트리거 | 표시 내용 / 입력 항목 | 결과 | -| ---------------- | --------------- | ---------------------------------------------------------- | ----------------------------------- | -| 검토인력 선택 | [검토인력 선택] | 사용자 검색 필터(이름/소속 등 TBD), 사용자 목록(단일 선택) | 선택된 사용자 정보를 입력 폼에 반영 | -| 결재자 수정 | B-2 행별 [수정] | 결재구분, 결재자명(선택), 결재순서번호 | 확인 시 해당 행 갱신 | -| 결재자 삭제 확인 | B-2 행별 [삭제] | (확인/취소) | 확인 시 해당 행 삭제 | - -### 5-3. 영역 B-2: 결재선 그리드 +### 5-2. 영역 B-2: 결재선 그리드 (인라인 편집) #### 그리드 컬럼 -| 컬럼명 | Field | 타입 | 비고 | -| ------------ | ------------ | ------ | ------------------------------------- | -| 결재선아이디 | `atrzLineId` | string | 예) `APLN-0001` (서버 자동 발급, TBD) | -| 결재순서번호 | `atrzSeqNo` | number | 1부터 시작. 단계 순서. | -| 결재구분 | `atrzSeNm` | string | `접수` / `검토` / `최종승인` | -| 결재자명 | `atrzUserNm` | string | 검토인력 선택 시 채워짐 | -| 등록일시 | `regDt` | string | `YYYY-MM-DD HH:mm:ss` | -| 관리 | — | — | 행별 **[수정] · [삭제]** 버튼 | +| 컬럼명 | Field | 입력 UI (편집 모드) | 비고 | +| ------------- | ------------- | ----------------------------------------------------------------------------------------- | ---------------------------------------------- | +| (드래그 핸들) | — | — | `useSortable` 연동 | +| 결재선아이디 | `aplnId` | 읽기 전용 | 신규 행: `—`, 저장 후 서버 `aplnId` 표시 | +| 결재순서번호 | `atrzSeqNo` | 읽기 전용 (그리드 행 순서 1부터 표시) | 저장 시 행 순서 기준 자동 채번 | +| 결재구분 | `atrzSeComCd` | Select (`ATRZSECD` 공통코드) | 표시명 `atrzSeComNm` (조회 행) | +| 결재자 | `aprvrUuid` | 읽기 전용 이름 + **[선택]** 버튼 | 응답에 성명 없음 — 선택 시 로컬 `aprvrNm` 유지 | +| 등록일시 | `regDt` | 읽기 전용 | 신규 행은 `—` | +| 관리 | — | 편집 중인 행: **[저장] [취소]** · 조회 행: **[수정] [삭제]** (다른 행 편집 중이면 비활성) | -- 표시 정렬: 기본 `결재순서번호` 오름차순. -- 임시 행(서버 미저장)과 저장된 행의 시각적 구분 필요(예: ID 컬럼 표기 또는 행 스타일). 세부 UI는 TBD. -- 페이지네이션: TBD. +#### 인라인 편집 정책 + +| 항목 | 내용 | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| **단일 행 편집** | **행 추가 또는 한 행 수정 중에는 다른 행을 편집할 수 없다.** `editingRowId` 로 현재 편집 행 1건만 허용. | +| 편집 잠금 시 비활성 | 다른 행: 셀·[수정]·[삭제] 비활성 · `[행 추가]` 비활성 · 행 드래그 비활성 · (선택) `[결재선 저장]` 비활성 | +| 편집 해제 | 행별 **[저장]** 성공 또는 **[취소]** 시 `editingRowId` 초기화 후 전체 조회 모드 복귀 | +| 조회 행 | 편집 중이 아닐 때 읽기 전용. **[수정]** 클릭 시 해당 행만 편집 모드 진입 | +| 신규 행 | `[행 추가]` 시 맨 아래 편집 행 1건 생성 (`editingRowId` = 임시 키) | +| 필수값 | `atrzSeComCd`, `aprvrUuid` | +| 순서 중복 | 저장 시 행 순서로 자동 채번하므로 중복 없음 | +| 결재구분 코드 | `ATRZSECD` — 예: `ATRZ01` 검토자, `ATRZ02` 승인자, `ATRZ03` 최종승인자 | +| **순서 제약** | `ATRZ01`은 최상단 블록, `ATRZ03`은 최하단 블록. 드래그 종료·저장 시 `validateAtrzOrder`로 검증, 위반 시 적용/저장 불가 (결재구분 변경 시에는 미검증) | +| 저장 단위 | 행별 **[저장]** (단건 POST/PUT) 또는 툴바 **[결재선 저장]** (일괄) | + +#### 행 드래그 정렬 (`@dnd-kit` + `useSortable`) + +| 항목 | 내용 | +| ---------- | ------------------------------------------------------------------------------------------------------- | +| 목적 | 결재 단계 순서를 그리드에서 직접 변경 | +| 라이브러리 | `@dnd-kit/core`, `@dnd-kit/sortable` — 테이블 행 POC 후 확정 | +| 저장 시점 | 드래그는 클라이언트 상태만 변경 → **[결재선 저장]** 또는 행별 **[저장]** 시 `atrzSeqNo` PUT | +| 편집 중 | `editingRowKey != null` 이면 드래그 **불가** | +| 순서 제약 | 드래그 종료 시 `validateAtrzOrder` — 실패하면 순서 변경 미적용 + 안내. 결재구분 미선택 행은 드래그 불가 | + +### 5-3. 보조 다이얼로그 (검토인력만) + +| 다이얼로그명 | 트리거 | 결과 | +| ------------- | ----------------------- | ---------------------------------- | +| 검토인력 선택 | 그리드 결재자 셀 [선택] | `aprvrUuid` 및 UI용 `aprvrNm` 반영 | + +> 결재자 **등록·수정 다이얼로그는 사용하지 않음** (v0.2 변경). --- ## 6. API/DTO 명세 -> 본 화면은 현재 UI 위주 초안으로, API/DTO는 백엔드 명세 확정 후 채운다. - ### 6-1. 공통 -| 항목 | 내용 | -| ----------- | ------------------------------------------- | -| Base prefix | TBD (예: `/server` 또는 결재 서비스 prefix) | -| 인증 | JWT 토큰 기반 (Authorization 헤더) | -| 클라이언트 | TBD (예: `@/entities/approval-line`) | +| 항목 | 내용 | +| ----------- | ------------------------------------------------------------- | +| 서비스 | `as-elapr-service` | +| Base prefix | `/as-elapr-service/api/v1` (프록시 경유) | +| 인증 | JWT (`Authorization` 헤더) | +| 페이징 | Spring `Pageable`: `page`, `size`, `sort` → `PageResponse` | +| 엔티티 | `@/entities/elapr` (`elaprTmpltService`, `elaprAplnService`) | -### 6-2. 전자결재서식 (영역 A) +### 6-2. 전자결재서식 (영역 A) — `elapr-tmplt` -| 기능 | Method | URL | Request DTO | Response DTO | API(클라이언트 함수명) | -| ------------------ | ------ | --- | ----------- | ------------ | ---------------------- | -| 결재서식 목록 조회 | TBD | TBD | TBD | TBD | TBD | -| 결재서식 등록 | TBD | TBD | TBD | TBD | TBD | -| 결재서식 수정 | TBD | TBD | TBD | TBD | TBD | -| 결재서식 삭제 | TBD | TBD | TBD | TBD | TBD | +| 기능 | Method | URL | Request / Query | Response | 클라이언트 | +| --------- | ------ | ----------------------------- | ------------------------------------------ | ------------------ | ---------------------------- | +| 목록 조회 | GET | `/elapr-tmplt` | Query: `ElaprTmpltSearchParams` + Pageable | `Page` | `elaprTmpltService.findPage` | +| 단건 조회 | GET | `/elapr-tmplt/{elaprTmpltId}` | — | `ElaprTmplt` | `elaprTmpltService.findById` | +| 등록 | POST | `/elapr-tmplt` | `ElaprTmpltSaveRequest` | `ElaprTmplt` (201) | `elaprTmpltService.create` | +| 수정 | PUT | `/elapr-tmplt/{elaprTmpltId}` | `ElaprTmpltUpdateRequest` | `ElaprTmplt` | `elaprTmpltService.update` | +| 삭제 | DELETE | `/elapr-tmplt/{elaprTmpltId}` | — | 204 | `elaprTmpltService.remove` | -### 6-3. 결재선 (영역 B) +**ElaprTmplt (응답)** -| 기능 | Method | URL | Request DTO | Response DTO | API(클라이언트 함수명) | -| ------------------------ | ------ | --- | ------------------------------------ | ------------ | ---------------------- | -| 결재선 목록 조회(서식별) | TBD | TBD | `{ elctAtrzTmpltId }` (TBD) | TBD | TBD | -| 결재선 추가/일괄 저장 | TBD | TBD | `{ elctAtrzTmpltId, lines[] }` (TBD) | TBD | TBD | -| 결재선 수정 | TBD | TBD | TBD | TBD | TBD | -| 결재선 삭제 | TBD | TBD | TBD | TBD | TBD | +| Field | 타입 | 설명 | +| ----------------- | ------- | ------------------ | +| `elaprTmpltId` | string | 전자결재서식아이디 | +| `elaprTmpltNm` | string | 결재서식명 | +| `elaprTmpltExpln` | string? | 서식 설명 | +| `docSeComCd` | string? | 문서구분 코드 | +| `docSeComNm` | string? | 문서구분명 | +| `regDt` | string? | 등록일시 | -### 6-4. 검토인력 (다이얼로그) +**ElaprTmpltSaveRequest**: `elaprTmpltNm`\_, `elaprTmpltExpln?`, `docSeComCd?` -| 기능 | Method | URL | Request DTO | Response DTO | API(클라이언트 함수명) | -| ------------------ | ------ | --- | --------------- | ------------ | ---------------------- | -| 검토인력 목록 조회 | TBD | TBD | TBD (검색 필터) | TBD | TBD | +**ElaprTmpltUpdateRequest**: `elaprTmpltNm`\*, `elaprTmpltExpln?`, `docSeComCd?` + +**ElaprTmpltSearchParams**: `elaprTmpltNm?`, `docSeComCd?`, `regDtStart?`, `regDtEnd?` + `page`, `size`, `sort` + +### 6-3. 결재선 (영역 B) — `elapr-apln` + +| 기능 | Method | URL | Request / Query | Response | 클라이언트 | +| --------- | ------ | ---------------------- | ------------------------------- | ----------------- | --------------------------- | +| 목록 조회 | GET | `/elapr-apln` | Query: `elaprTmpltId`, Pageable | `Page` | `elaprAplnService.findPage` | +| 단건 조회 | GET | `/elapr-apln/{aplnId}` | — | `ElaprApln` | `elaprAplnService.findById` | +| 등록 | POST | `/elapr-apln` | `ElaprAplnSaveRequest` | `ElaprApln` (201) | `elaprAplnService.create` | +| 수정 | PUT | `/elapr-apln/{aplnId}` | `ElaprAplnUpdateRequest` | `ElaprApln` | `elaprAplnService.update` | +| 삭제 | DELETE | `/elapr-apln/{aplnId}` | — | 204 | `elaprAplnService.remove` | + +> **일괄 저장 API 없음** — UI의 [결재선 저장]은 변경된 행에 대해 POST/PUT을 순차(또는 병렬) 호출하는 클라이언트 오케스트레이션으로 구현. + +**ElaprApln (응답)** + +| Field | 타입 | 설명 | +| -------------- | ------- | ------------------ | +| `aplnId` | string | 결재선아이디 | +| `elaprTmpltId` | string | 전자결재서식아이디 | +| `aprvrUuid` | string? | 결재자 UUID | +| `atrzSeComCd` | string? | 결재구분 코드 | +| `atrzSeComNm` | string? | 결재구분명 | +| `atrzSeqNo` | number? | 결재순서번호 | +| `regDt` | string? | 등록일시 | + +**ElaprAplnSaveRequest**: `elaprTmpltId`\_, `aprvrUuid?`, `atrzSeComCd?`, `atrzSeqNo?` + +**ElaprAplnUpdateRequest**: `elaprTmpltId`\*, `aprvrUuid?`, `atrzSeComCd?`, `atrzSeqNo?` + +### 6-4. 검토인력 (보조) + +| 기능 | Method | URL | 비고 | +| ------------------ | ------ | --- | --------------------------- | +| 검토인력 목록 조회 | TBD | TBD | 사용자 서비스 또는 별도 API | + +### 6-5. Backend ↔ 화면 필드 매핑 (구 SPEC 대비) + +| 구 SPEC 필드 | 백엔드 필드 | +| ----------------- | ---------------------------------------------------- | +| `elctAtrzTmpltId` | `elaprTmpltId` | +| `atrzTmpltNm` | `elaprTmpltNm` | +| `docSeNm` | `docSeComNm` | +| `atrzLineId` | `aplnId` | +| `atrzSeNm` | `atrzSeComNm` | +| `atrzUserNm` | (없음) `aprvrUuid` + 사용자 조회/선택 시 로컬 표시명 | --- @@ -307,52 +362,46 @@ END ### 7-1. 기술 스택·원칙 -- **공통 컴포넌트**: `@common/components` (버튼, 셀렉트, 인풋, 카드 등 기존 패턴 재사용). -- **테이블**: `@tanstack/react-table`, 프로젝트 표준 `useDataTable` 패턴이 있다면 동일하게 적용. -- **데이터**: React Query 기반 조회/뮤테이션. API 확정 후 `queryKey` 와 무효화 정책 정리. -- **상태**: 영역 A의 **선택된 서식 ID**, 영역 B-1의 **결재자 입력 폼 값**, 영역 B-2의 **임시 결재선 배열**은 페이지 로컬 상태로 관리. 다이얼로그 상태(검토인력 선택 등)는 컴포넌트 로컬 상태 또는 가벼운 상태 관리 훅으로 분리. -- **모의 데이터**: 백엔드 명세 확정 전까지 `@faker-js/faker` 로 그리드 목데이터 구성(스크린샷 컬럼/값과 동일 형태). +- **공통 컴포넌트**: `@common/components` +- **테이블**: `@tanstack/react-table` + 인라인 편집 셀 (`Select`, `Input`, `Button`) +- **드래그**: `@dnd-kit` — B-2 행 정렬 (POC 필수) +- **데이터**: React Query — `@/entities/elapr` hooks +- **상태**: 선택된 `elaprTmpltId`, `editingRowId` (단일 행 편집 잠금), B-2 행 배열(dirty·신규·`aprvrNm`·수정 전 스냅샷) +- **모의 데이터**: API 연동 전 `@faker-js/faker` ### 7-2. 모듈·파일 배치(제안) -| 구분 | 경로 | -| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | -| 라우트(App Router) | `apps/as/app/(with-base-layout)/approval-line-management/page.tsx` | -| 페이지 컴포넌트 | `apps/as/src/pages/approval-line-management/index.ts`
`.../ui/approval-line-management-page.tsx` | -| 위젯(영역 단위) | `apps/as/src/widgets/approval-template-list/`
`apps/as/src/widgets/approval-line-editor/` | -| 피처(상호작용) | `apps/as/src/features/approver-picker/` (검토인력 선택 다이얼로그) | -| 엔티티(도메인) | `apps/as/src/entities/approval-template/`
`apps/as/src/entities/approval-line/` | -| 그리드 컬럼 정의 | `entities/approval-template/model/approval-template-table-columns.tsx`
`entities/approval-line/model/approval-line-table-columns.tsx` | -| API 클라이언트 | `entities/approval-template/api/*`, `entities/approval-line/api/*` (TBD) | +| 구분 | 경로 | +| ------ | -------------------------------------------------------------------------------------- | +| 라우트 | `apps/as/app/(with-base-layout)/approval-line-management/page.tsx` | +| 페이지 | `apps/as/src/pages/approval-line-management/` (Public API: page만 export) | +| UI | `.../ui/elapr-tmplt-list.tsx`, `elapr-tmplt-form-dialog.tsx`, `elapr-apln-grid.tsx` 등 | +| model | `.../model/columns.tsx`, `apln-row-types.ts`, `mock-reviewers.ts` | +| lib | `.../lib/use-apln-rows.ts`, `validators.ts`, `use-dnd-sensors.ts` | +| 엔티티 | `apps/as/src/entities/elapr/` | ### 7-3. 영역별 UI 매핑 -| 영역 | 주요 UI | 비고 | -| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | -| A | 섹션 헤더 + Button(`서식 등록`) + DataTable (행 선택, 행별 Action 버튼) | 선택 행 강조, 행 클릭으로 영역 B 갱신 | -| B 헤더 | 상태 라벨 `선택된 서식: {ID}` | 미선택 시 영역 B-1·B-2 비활성 | -| B-1 | 읽기 전용 Input(아이디), Select(결재구분), Input+Button(결재자/검토인력 선택), NumberInput(순서번호), Action 버튼 3종(`결재자 추가`/`결재선 저장`/`초기화`) | 폼 유효성 검증 후 그리드에 임시 행 추가 | -| B-2 | DataTable (행별 [수정][삭제]), 임시 행 시각적 구분 | 기본 정렬: 결재순서번호 ASC | -| 공통 | Dialog(검토인력 선택, 서식 등록/수정, 결재자 수정, 삭제 확인) | 디자인 시스템의 `Dialog` 컴포넌트 사용 | +| 영역 | 주요 UI | +| ------ | -------------------------------------------------------------------------------------------- | +| A | DataTable + 행 선택 + 서식 등록/수정 다이얼로그 | +| B 헤더 | 상태 라벨 + `행 추가`, `결재선 저장` | +| B-2 | 인라인 편집 DataTable + `editingRowId` 단일 행 잠금 + 드래그 핸들 + 행별 수정/저장/삭제/취소 | +| 공통 | 서식 Dialog, 검토인력 선택 Dialog만 | ### 7-4. 데이터 흐름(요약) -- **진입 시**: 결재서식 목록 조회(TBD) → 영역 A 그리드 렌더. 영역 B는 안내/비활성 상태. -- **서식 선택 시**: 선택된 `elctAtrzTmpltId` 를 페이지 상태로 보관 → 결재선 목록 조회(TBD) → 영역 B-2 그리드 갱신. 영역 B-1 폼 초기화. -- **결재자 추가**: 입력 폼 값을 영역 B-2의 **클라이언트 임시 배열**에 push. 결재선아이디는 미발급 상태로 표시(또는 임시 키). 폼 리셋. -- **결재선 저장**: 임시 배열 + 기존 그리드 행을 합쳐 일괄 저장 API(TBD) 호출 → 성공 시 그리드 재조회 + 토스트. -- **수정/삭제**: 각각의 단건 API(TBD) 호출 후 그리드 재조회. -- **검토인력 선택 다이얼로그**: 사용자 목록 조회(TBD) → 단일 선택 → 폼의 결재자명/결재자 ID 갱신. -- **서식 등록/수정/삭제**: 다이얼로그 또는 확인 후 단건 API(TBD) 호출 → 영역 A 그리드 재조회. 삭제 대상이 현재 선택된 서식이면 영역 B 초기화. +- **진입**: `useElaprTmpltPage` → 영역 A. B 비활성. +- **서식 선택**: `useElaprAplnPage({ elaprTmpltId })` → B-2. +- **행 추가**: `editingRowId` 없을 때만 가능 → 신규 행 1건 편집 잠금 → 저장/취소 후 해제. +- **인라인 수정**: [수정]으로 `editingRowId` 설정 → 해당 행만 편집 → `useUpdateElaprApln` 후 해제. +- **드래그**: `editingRowId == null` 일 때만 가능 → 로컬 `atrzSeqNo` 재부여 → 일괄 PUT. +- **삭제**: `useDeleteElaprApln` 또는 신규 행 로컬 제거. -### 7-5. 미확정 / 검증 필요 항목 (TBD) +### 7-5. 미확정 / 검증 필요 -- 모든 백엔드 API 경로·DTO·필드명. -- `결재선아이디(자동발급)` 입력 박스의 표시 정책: (a) 다음 발급 예정 번호 미리 보여주기, (b) 저장 전에는 빈 칸 또는 자리표시자만, (c) 임시 ID. -- `[결재자 추가]` 의 동작: 그리드에 임시 누적(현재 가정) vs 즉시 단건 저장. -- 결재구분 코드 값(`접수`/`검토`/`최종승인`)의 코드 매핑. -- 문서구분 코드 값(`설치인가`/`기본계획` 등)의 코드 매핑. -- 영역 A·B 그리드의 페이지네이션·정렬·검색 필터 유무. -- 검토인력 선택 다이얼로그의 검색 필드(부서/직급/이름 등) 및 다중 선택 허용 여부. -- 결재선 저장 시 순서번호 중복·누락(예: 1, 2, 4) 허용 정책. -- 권한(역할별 등록/수정/삭제 가능 여부). +- 검토인력 API 경로·DTO. +- `@dnd-kit` + react-table 행 인라인 Input 동시 적용 POC. +- 결재자 성명 표시: `aprvrUuid` → 사용자명 조회 API. +- 영역 A·B 페이지네이션·검색 UI 노출 여부. +- 권한(역할별 CRUD). diff --git a/apps/as/docs/checklist-management/SPEC.md b/apps/as/docs/checklist-management/SPEC.md new file mode 100644 index 000000000..927b6c53a --- /dev/null +++ b/apps/as/docs/checklist-management/SPEC.md @@ -0,0 +1,487 @@ +# 화면설계서: 체크리스트관리 + +## 1. 기본 정보 + +| 항목 | 내용 | +| ---------- | -------------------------------------------- | +| 버전 | 0.1 | +| 프로세스명 | UI설계 | +| 화면명 | 체크리스트관리 | +| 화면 유형 | [v] 조회 [v] 입력 [v] 수정 [v] 삭제 [ ] 출력 | +| 메뉴 경로 | 디지털행정지원 > 시스템관리 > 체크리스트관리 | + +--- + +## 2. 화면 목적 + +업무별·문서별 **점검표**(체크리스트)를 등록·관리하고, **점검항목** 마스터를 유지한다. 상단 좌측에서 점검표를 선택하면 하단 **체크리스트 항목** 영역에 해당 점검표의 검토 구성(계층형 검토내용)을 편집한다. 상단 우측 **점검항목 관리**는 점검표에 연결할 항목 풀을 등록·수정·삭제한다. + +--- + +## 3. 화면 구성 + +### 3-1. 전체 레이아웃 + +``` +┌───────────────────────────────────────────────────────────────────────────────────────────────┐ +│ 페이지 타이틀: 체크리스트관리 브레드크럼: 디지털행정지원 > … > │ +├──────────────────────────────────────────────┬────────────────────────────────────────────────┤ +│ [영역 A] 점검표 관리 │ [영역 B] 점검항목 관리 │ +│ [점검표 추가] │ [점검항목 추가] │ +│ ┌────────────────────────────────────────┐ │ ┌──────────────────────────────────────────┐ │ +│ │ 점검표일련번호 | 점검표명 | 문서구분 │ │ │ 점검항목코드 | 점검항목명 | 사용여부 │ │ +│ │ 업무구분 | 사용여부 | 등록일시 | 관리 │ │ │ 등록일시 | 관리 │ │ +│ ├────────────────────────────────────────┤ │ ├──────────────────────────────────────────┤ │ +│ │ 1 (선택 행 강조) [관리] [삭제] │ │ │ ITEM_001 요건검토 … [관리][삭제] │ │ +│ │ │ │ │ ITEM_002 법령검토 … [관리][삭제] │ │ +│ └────────────────────────────────────────┘ │ └──────────────────────────────────────────┘ │ +├──────────────────────────────────────────────┴────────────────────────────────────────────────┤ +│ [영역 C] 체크리스트 항목 [미리보기] [추가] [저장] │ +│ ┌─────────────────────────────────────────────────────────────────────────────────────────┐ │ +│ │ 점검항목 ▼ | 주요 검토내용 | 사용 여부 | 구분 | 관리 │ │ +│ │ L1 [요건검토 ▼] | (입력) | ○예 ○아니오 | 1단계 | [하위추가]│ │ +│ │ L2 [요건검토 ▼] | (입력) | ○예 ○아니오 | 2단계 | [삭제] │ │ +│ └─────────────────────────────────────────────────────────────────────────────────────────┘ │ +└───────────────────────────────────────────────────────────────────────────────────────────────┘ +``` + +### 3-2. 화면 데이터 흐름 + +``` +BEGIN + + // 최초 진입 + CALL GET /as-checklist-service/api/v1/cklst + DISPLAY 점검표 그리드 (영역 A) + CALL GET /as-checklist-service/api/v1/cklst-artcl + DISPLAY 점검항목 그리드 (영역 B) + DISPLAY 빈 상태 안내 (영역 C) // 점검표 미선택 시 C 비활성 + + // 영역 A: 점검표 행 선택 + ON 영역 A 행 CLICK DO + SET 선택된 점검표 = 행.cklstSn + CALL GET /as-checklist-service/api/v1/cklst-rvw?cklstSn={cklstSn} + // TBD: 점검표 단위 전체 검토 트리 조회 API — 현재는 항목별 by-artcl 조합 또는 클라이언트 병합 + DISPLAY 체크리스트 항목 그리드 (영역 C) + END ON + + // 영역 A: 점검표 추가 + ON [점검표 추가] 버튼 CLICK DO + OPEN 점검표 추가 다이얼로그 + ON [저장] DO + VALIDATE cklstNm, taskSeComCd + CALL POST /as-checklist-service/api/v1/cklst + CALL GET /as-checklist-service/api/v1/cklst + CLOSE 다이얼로그 + END ON + ON [취소] / [닫기] DO + CLOSE 다이얼로그 + END ON + END ON + + // 영역 A: 점검표 수정·삭제 + ON 영역 A 행 [관리] CLICK DO + OPEN 점검표 수정 다이얼로그 (기존 값 초기화) + ON [저장] DO + CALL PUT /as-checklist-service/api/v1/cklst/{cklstSn} + CALL GET /as-checklist-service/api/v1/cklst + END ON + END ON + + ON 영역 A 행 [삭제] CLICK DO + CONFIRM "선택한 점검표를 삭제하시겠습니까?" + CALL DELETE /as-checklist-service/api/v1/cklst/{cklstSn} + IF 삭제 대상 == 선택된 점검표 THEN RESET 영역 C + CALL GET /as-checklist-service/api/v1/cklst + END ON + + // 영역 B: 점검항목 추가 + ON [점검항목 추가] 버튼 CLICK DO + OPEN 점검항목 추가 다이얼로그 + ON [저장] DO + VALIDATE cklstArtclNm + // TBD: cklstSn 필수 — UI에 소속 점검표 선택 없음. 백엔드 정책 확인 + CALL POST /as-checklist-service/api/v1/cklst-artcl + CALL GET /as-checklist-service/api/v1/cklst-artcl + CLOSE 다이얼로그 + END ON + END ON + + // 영역 B: 점검항목 수정·삭제 + ON 영역 B 행 [관리] CLICK DO + OPEN 점검항목 수정 다이얼로그 + ON [저장] DO + CALL PUT /as-checklist-service/api/v1/cklst-artcl/{cklstArtclSn}/{cklstSn} + END ON + END ON + + ON 영역 B 행 [삭제] CLICK DO + CONFIRM "선택한 점검항목을 삭제하시겠습니까?" + CALL DELETE /as-checklist-service/api/v1/cklst-artcl/{cklstArtclSn}/{cklstSn} + CALL GET /as-checklist-service/api/v1/cklst-artcl + END ON + + // 영역 C: 검토 행 추가 + ON [추가] 버튼 CLICK DO + VALIDATE 선택된 점검표 존재 + ADD 영역 C 그리드 맨 아래 편집 가능 행 (1단계, rvwCnLvNo=1) + END ON + + // 영역 C: 하위 검토 추가 + ON 행 [하위추가] CLICK DO + ADD 부모행 하위 편집 행 (2단계, upCklstRvwSn=부모.cklstRvwSn, rvwCnLvNo=2) + END ON + + // 영역 C: 일괄 저장 + ON [저장] 버튼 CLICK DO + VALIDATE 선택된 점검표 존재 + FOR EACH 신규·변경된 검토 행 DO + IF 신규 THEN + CALL POST /as-checklist-service/api/v1/cklst-rvw + ELSE + CALL PUT /as-checklist-service/api/v1/cklst-rvw/{cklstRvwSn}/{cklstArtclSn}/{cklstSn} + END IF + END FOR + CALL GET /as-checklist-service/api/v1/cklst-rvw?cklstSn={cklstSn} + TOAST "체크리스트 항목이 저장되었습니다." + END ON + + // 영역 C: 행 삭제 + ON 행 [삭제] CLICK DO + CONFIRM "선택한 항목을 삭제하시겠습니까?" + IF 서버 저장된 행 THEN + CALL DELETE /as-checklist-service/api/v1/cklst-rvw/{cklstRvwSn}/{cklstArtclSn}/{cklstSn} + END IF + REMOVE 그리드 행 (미저장 행은 API 호출 없이 제거) + END ON + + // 영역 C: 미리보기 + ON [미리보기] 버튼 CLICK DO + OPEN 체크리스트 미리보기 다이얼로그 (읽기 전용 트리) + // TBD: 미리보기 UI·API + END ON + +END +``` + +--- + +## 4. 영역 A: 점검표 관리 + +선택된 점검표가 영역 C의 편집 대상이 된다. 행 클릭으로 선택 상태를 표시한다. + +### 4-1. 헤더 / 툴바 + +| 항목 | 내용 | +| ----------- | ------------------------------------------ | +| 섹션 타이틀 | 점검표 관리 | +| 우측 버튼 | **[점검표 추가]** — 점검표 등록 다이얼로그 | + +### 4-2. 그리드 컬럼 + +| 컬럼명 | Field | 타입 | 비고 | +| ---------------- | ------------- | ------ | ---------------------------------------------------- | +| 점검표일련번호 | `cklstSn` | number | PK | +| 점검표명 | `cklstNm` | string | | +| 문서구분공통코드 | `docSeComCd` | string | 코드명 표시 — `ASDOCSECD` 등 공통코드 (TBD 상위코드) | +| 업무구분공통코드 | `taskSeComCd` | string | RE/SF/SP 등 — 공통코드 (TBD 상위코드) | +| 사용여부 | `useYn` | string | `Y`→예, `N`→아니오 | +| 등록일시 | `regDt` | string | `yyyy-MM-dd HH:mm:ss` | +| 관리 | — | — | 행별 **[관리] · [삭제]** | + +- 행 클릭 시: `cklstSn` 선택 → 영역 C 헤더·그리드 갱신. +- 페이지네이션: Spring `Pageable` (`page`, `size`, `sort`). + +### 4-3. 버튼 + +| 버튼명 | 위치 | 동작 | +| ----------- | -------------- | ------------------------------------------------- | +| 점검표 추가 | 섹션 우측 상단 | 점검표 추가 다이얼로그 오픈 | +| 관리 | 행별 | 점검표 수정 다이얼로그 (기존 값 초기화) | +| 삭제 | 행별 | 확인 후 삭제. 선택 중인 점검표이면 영역 C 초기화. | + +### 4-4. 다이얼로그: 점검표 추가 / 수정 + +| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 | +| -------- | ---------------- | ---- | ------ | ----------------- | ------------------------------------------------ | +| 점검표명 | Text | Y | — | `cklstNm` | placeholder: "예: 설치인가 체크리스트" | +| 문서구분 | Select(공통코드) | N | — | `docSeComCd` | 예: FNRS, INSTL, PWRK, DESIGN | +| 업무구분 | Select(공통코드) | Y | — | `taskSeComCd` | 예: RE(재이용수), SF(하수처리시설), SP(하수관로) | +| 사용여부 | Select | N | Y | `useYn` | Y/N, UI 표시: 예/아니오 | + +| 버튼명 | 동작 | +| ------ | -------------------------------------- | +| 저장 | POST(등록) 또는 PUT(수정) 후 목록 갱신 | +| 취소 | 다이얼로그 닫기, 입력값 폐기 | +| 닫기 | 다이얼로그 닫기 | + +--- + +## 5. 영역 B: 점검항목 관리 + +점검표 하단(영역 C)에서 선택할 **점검항목** 마스터를 관리한다. + +### 5-1. 헤더 / 툴바 + +| 항목 | 내용 | +| ----------- | ---------------------------------------------- | +| 섹션 타이틀 | 점검항목 관리 | +| 우측 버튼 | **[점검항목 추가]** — 점검항목 등록 다이얼로그 | + +### 5-2. 그리드 컬럼 + +| 컬럼명 | Field | 타입 | 비고 | +| ------------ | -------------- | ------ | ------------------------------------- | +| 점검항목코드 | `cklstArtclSn` | number | UI mock `ITEM_001` 형식 표시 여부 TBD | +| 점검항목명 | `cklstArtclNm` | string | | +| 사용여부 | `useYn` | string | `Y`→예, `N`→아니오 | +| 등록일시 | `regDt` | string | `yyyy-MM-dd HH:mm:ss` | +| 관리 | — | — | 행별 **[관리] · [삭제]** | + +> **TBD**: 백엔드 `CklstArtcl`은 복합 PK(`cklstArtclSn`, `cklstSn`)이며 등록 시 `cklstSn` 필수. UI 다이얼로그에는 `cklstSn` 입력이 없음 — 소속 점검표 지정 방식·전역 마스터 여부 백엔드 정책 확인. + +### 5-3. 다이얼로그: 점검항목 추가 / 수정 + +| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 | +| ---------- | --------- | ---- | ------ | ----------------- | --------------------------- | +| 점검항목명 | Text | Y | — | `cklstArtclNm` | placeholder: "예: 법령검토" | +| 사용여부 | Select | N | Y | `useYn` | Y/N | + +| 버튼명 | 동작 | +| ------ | -------------------------------------- | +| 저장 | POST(등록) 또는 PUT(수정) 후 목록 갱신 | +| 취소 | 다이얼로그 닫기 | +| 닫기 | 다이얼로그 닫기 | + +--- + +## 6. 영역 C: 체크리스트 항목 (선택된 점검표) + +영역 A에서 선택된 `cklstSn` 기준으로 **점검표검토**(계층형 검토내용)를 구성한다. + +### 6-1. 헤더 / 툴바 + +| 항목 | 내용 | +| ----------- | -------------------------------------------------------------------- | +| 섹션 타이틀 | 체크리스트 항목 (선택된 점검표: {cklstNm}) | +| 우측 버튼 | **[미리보기]** · **[추가]** · **[저장]** — 점검표 미선택 시 C 비활성 | + +### 6-2. 그리드 컬럼 + +| 컬럼명 | Field | 입력 UI | 비고 | +| ------------- | -------------- | ------------------------------------- | ------------------------------------ | +| 점검항목 | `cklstArtclSn` | Select (영역 B 항목 목록) | L1/L2 접두 표시, `cklstArtclNm` 라벨 | +| 주요 검토내용 | `rvwCn` | Text / Textarea | 최대 4000자 | +| 사용 여부 | `useYn` | Radio (예/아니오) | `Y` / `N` | +| 구분 | `rvwCnLvNo` | 읽기 전용 | 1→1단계, 2→2단계, 3→3단계 | +| 관리 | — | **[하위추가]** (1·2단계, 저장 후) · **[삭제]** | 3단계 행은 [하위추가] 미표시 | + +#### 계층 정책 + +| 항목 | 내용 | +| -------------- | -------------------------------------------------------------------- | +| 최대 깊이 | 3단계 (`rvwCnLvNo`: 1, 2, 3) | +| 상위-하위 연결 | `upCklstRvwSn` — 2단계 행은 부모 1단계 `cklstRvwSn` 참조 | +| 정렬 | `sortSeq` — 동일 레벨 내 순서 (TBD: UI 드래그 정렬 여부) | +| 체크 사용 | `bchckUseYn` — 미리보기·실사용 화면 체크박스 노출 여부 (TBD UI 반영) | + +### 6-3. 버튼 + +| 버튼명 | 동작 | +| -------- | ---------------------------------------------------- | +| 추가 | 1단계 검토 행 1건 추가 (편집 가능) | +| 저장 | 변경·신규 검토 행 일괄 POST/PUT (일괄 저장 API 없음) | +| 미리보기 | 선택 점검표 체크리스트 읽기 전용 미리보기 (TBD) | +| 하위추가 | 선택 1·2단계 행 아래 하위 단계(2·3단계) 자식 행 추가 | +| 삭제 | 확인 후 DELETE 또는 미저장 행 로컬 제거 | + +--- + +## 7. API/DTO 명세 + +### 7-1. 공통 + +| 항목 | 내용 | +| ------------- | ------------------------------------------------------------- | +| 서비스 | `as-checklist-service` | +| Base prefix | `/as-checklist-service/api/v1` (게이트웨이 프록시 경유) | +| 인증 | JWT (`Authorization` 헤더), `X-Site-Id`, `token-id` | +| 페이징 | Spring `Pageable`: `page`, `size`, `sort` → `PageResponse` | +| 엔티티 | `@/entities/cklst` (`cklstMngService`, `cklstArtclService`, `cklstRvwService`) | + +### 7-2. 점검표 (영역 A) — `cklst` + +| 기능 | Method | URL | Request / Query | Response | 클라이언트 | +| --------- | ------ | ------------------ | --------------------------------- | ------------------ | -------------------------- | +| 목록 조회 | GET | `/cklst` | `CklstMngSearchParams` + Pageable | `Page` | `cklstMngService.findPage` | +| 단건 조회 | GET | `/cklst/{cklstSn}` | — | `CklstMng` | `cklstMngService.findById` | +| 등록 | POST | `/cklst` | `CklstMngSaveRequest` | `number` (cklstSn) | `cklstMngService.create` | +| 수정 | PUT | `/cklst/{cklstSn}` | `CklstMngSaveRequest` | `number` (cklstSn) | `cklstMngService.update` | +| 삭제 | DELETE | `/cklst/{cklstSn}` | — | `number` (cklstSn) | `cklstMngService.remove` | + +**CklstMng (응답)** + +| Field | 타입 | 설명 | +| ------------- | ------- | ---------------- | +| `cklstSn` | number | 점검표일련번호 | +| `cklstNm` | string | 점검표명 | +| `taskSeComCd` | string | 업무구분공통코드 | +| `useYn` | string? | 사용여부 | +| `docSeComCd` | string? | 문서구분공통코드 | +| `rgtr` | string? | 등록자 UUID | +| `rgtrNm` | string? | 등록자명 | +| `mdfr` | string? | 수정자 UUID | +| `regDt` | string? | 등록일시 | +| `mdfcnDt` | string? | 수정일시 | + +**CklstMngSaveRequest**: `cklstNm`\*, `taskSeComCd`\*, `useYn?`, `docSeComCd?` + +**CklstMngSearchParams**: `cklstNm?`, `taskSeComCd?`, `useYn?`, `docSeComCd?` + `page`, `size`, `sort` + +**React Query hooks**: `useCklstMngPage`, `useCklstMng`, `useCreateCklstMng`, `useUpdateCklstMng`, `useDeleteCklstMng` + +### 7-3. 점검표항목 (영역 B) — `cklst-artcl` + +| 기능 | Method | URL | Request / Query | Response | 클라이언트 | +| ------------------ | ------ | --------------------------------------- | ----------------------------------- | ------------------ | --------------------------------- | +| 목록 조회 (페이징) | GET | `/cklst-artcl` | `CklstArtclSearchParams` + Pageable | `Page` | `cklstArtclService.findPage` | +| 점검표별 전체 목록 | GET | `/cklst-artcl/by-cklst/{cklstSn}` | — | `CklstArtcl[]` | `cklstArtclService.findByCklstSn` | +| 단건 조회 | GET | `/cklst-artcl/{cklstArtclSn}/{cklstSn}` | — | `CklstArtcl` | `cklstArtclService.findById` | +| 등록 | POST | `/cklst-artcl` | `CklstArtclSaveRequest` | `CklstArtclId` | `cklstArtclService.create` | +| 수정 | PUT | `/cklst-artcl/{cklstArtclSn}/{cklstSn}` | `CklstArtclUpdateRequest` | `CklstArtclId` | `cklstArtclService.update` | +| 삭제 | DELETE | `/cklst-artcl/{cklstArtclSn}/{cklstSn}` | — | `CklstArtclId` | `cklstArtclService.remove` | + +**CklstArtcl (응답)** + +| Field | 타입 | 설명 | +| ----------------- | ------- | ------------------ | +| `cklstArtclSn` | number | 점검표항목일련번호 | +| `cklstSn` | number | 점검표일련번호 | +| `cklstArtclNm` | string? | 점검표항목명 | +| `useYn` | string? | 사용여부 | +| `rgtr` / `rgtrNm` | string? | 등록자 | +| `regDt` | string? | 등록일시 | + +**CklstArtclSaveRequest**: `cklstSn`\*, `cklstArtclNm?`, `useYn?` + +**CklstArtclUpdateRequest**: `cklstArtclNm?`, `useYn?` + +**CklstArtclId**: `{ cklstArtclSn: number; cklstSn: number }` + +**React Query hooks**: `useCklstArtclPage`, `useCklstArtclList`, `useCklstArtcl`, `useCreateCklstArtcl`, `useUpdateCklstArtcl`, `useDeleteCklstArtcl` + +### 7-4. 점검표검토 (영역 C) — `cklst-rvw` + +| 기능 | Method | URL | Request / Query | Response | 클라이언트 | +| --------------------- | ------ | -------------------------------------------------- | ------------------------------ | ---------------- | ------------------------------- | +| 목록 조회 (페이징) | GET | `/cklst-rvw` | `CklstRvwSearchParams` + Pageable | `Page` | `cklstRvwService.findPage` | +| 항목별 검토 전체 목록 | GET | `/cklst-rvw/by-artcl/{cklstSn}/{cklstArtclSn}` | — | `CklstRvw[]` | `cklstRvwService.findByArtcl` | +| 단건 조회 | GET | `/cklst-rvw/{cklstRvwSn}/{cklstArtclSn}/{cklstSn}` | — | `CklstRvw` | `cklstRvwService.findById` | +| 등록 | POST | `/cklst-rvw` | `CklstRvwSaveRequest` | `CklstRvwId` | `cklstRvwService.create` | +| 수정 | PUT | `/cklst-rvw/{cklstRvwSn}/{cklstArtclSn}/{cklstSn}` | `CklstRvwUpdateRequest` | `CklstRvwId` | `cklstRvwService.update` | +| 삭제 | DELETE | `/cklst-rvw/{cklstRvwSn}/{cklstArtclSn}/{cklstSn}` | — | `CklstRvwId` | `cklstRvwService.remove` | + +**CklstRvw (응답)** + +| Field | 타입 | 설명 | +| -------------- | ------- | ---------------------- | +| `cklstRvwSn` | number | 점검표검토일련번호 | +| `cklstArtclSn` | number | 점검표항목일련번호 | +| `cklstSn` | number | 점검표일련번호 | +| `upCklstRvwSn` | number? | 상위점검표검토일련번호 | +| `rvwCnLvNo` | number? | 검토내용레벨번호 | +| `rvwCn` | string? | 검토내용 | +| `bchckUseYn` | string? | 체크사용여부 | +| `sortSeq` | number? | 정렬순서 | +| `useYn` | string? | 사용여부 | +| `regDt` | string? | 등록일시 | + +**CklstRvwSaveRequest**: `cklstArtclSn`\*, `cklstSn`\*, `upCklstRvwSn?`, `rvwCnLvNo?`, `rvwCn?`, `bchckUseYn?`, `sortSeq?`, `useYn?` + +**CklstRvwUpdateRequest**: `upCklstRvwSn?`, `rvwCnLvNo?`, `rvwCn?`, `bchckUseYn?`, `sortSeq?`, `useYn?` + +**CklstRvwSearchParams**: `cklstSn?`, `cklstArtclSn?`, `useYn?` + `page`, `size`, `sort` + +**CklstRvwId**: `{ cklstRvwSn: number; cklstArtclSn: number; cklstSn: number }` + +**React Query hooks**: `useCklstRvwPage`, `useCklstRvwByArtcl`, `useCklstRvw`, `useCreateCklstRvw`, `useUpdateCklstRvw`, `useDeleteCklstRvw` + +### 7-5. 공통코드 (보조) + +| UI 필드 | 코드 그룹 (TBD) | 예시 값 | +| -------- | --------------- | -------------------------------------------- | +| 문서구분 | `ASDOCSECD` 등 | FNRS, INSTL, PWRK, DESIGN | +| 업무구분 | TBD | RE(재이용수), SF(하수처리시설), SP(하수관로) | +| 사용여부 | 고정 | Y, N | + +공통코드 조회: `@/entities/code` — `codeService`, `CodeSelect` + +--- + +## 8. UI 구현 가이드 + +### 8-1. 기술 스택·원칙 + +- **공통 컴포넌트**: `@common/components` (`Dialog`, `Button`, `Input`, `Select`, `RadioGroup` 등) +- **테이블**: `@tanstack/react-table` + `useDataTable` +- **데이터**: React Query — `@/entities/cklst` hooks (`useCklstRvwPage` 등 영역 C 포함) +- **공통코드**: `@/entities/code` — `CodeSelect`, `findCodeLabel` +- **상태**: 선택된 `cklstSn`, 영역 C 검토 행 배열(신규·dirty·계층 트리) +- **모의 데이터**: API 연동 전 `@faker-js/faker` + +### 8-2. 모듈·파일 배치(제안) + +| 구분 | 경로 | +| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 라우트 | `apps/as/app/(with-base-layout)/checklist-management/page.tsx` | +| 페이지 | `apps/as/src/pages/checklist-management/` (Public API: page만 export) | +| UI | `.../ui/cklst-mng-list.tsx`, `cklst-mng-form-dialog.tsx`, `cklst-artcl-list.tsx`, `cklst-artcl-form-dialog.tsx`, `cklst-rvw-grid.tsx`, `cklst-preview-dialog.tsx` | +| model | `.../model/cklst-mng-columns.tsx`, `cklst-artcl-columns.tsx`, `cklst-rvw-row-types.ts` | +| lib | `.../lib/use-cklst-rvw-rows.ts`, `validators.ts`, `build-rvw-tree.ts` | +| 엔티티 | `apps/as/src/entities/cklst/` (`cklstMng`, `cklstArtcl`, `cklstRvw` 서비스·hooks) | +| 페이지 | `apps/as/src/pages/checklist-management/` — `/checklist-management` | +| 문서 | `apps/as/docs/checklist-management/SPEC.md` | + +### 8-3. 영역별 UI 매핑 + +| 영역 | 주요 UI | +| ---- | ---------------------------------------------------------------------------- | +| A | DataTable + 행 선택 + 점검표 추가/수정 Dialog | +| B | DataTable + 점검항목 추가/수정 Dialog | +| C | 계층형 편집 그리드(1·2단계) + [추가][저장][미리보기] + 행별 [하위추가][삭제] | +| 공통 | `CodeSelect` (문서구분·업무구분), 사용여부 Y/N Select/Radio | + +### 8-4. 다이얼로그·라우팅 + +| 다이얼로그명 | 트리거 | API 연동 | +| ------------------- | --------------- | ------------------------- | +| 점검표 추가 | [점검표 추가] | `useCreateCklstMng` | +| 점검표 수정 | 행 [관리] | `useUpdateCklstMng` | +| 점검표 삭제 확인 | 행 [삭제] | `useDeleteCklstMng` | +| 점검항목 추가 | [점검항목 추가] | `useCreateCklstArtcl` | +| 점검항목 수정 | 행 [관리] | `useUpdateCklstArtcl` | +| 체크리스트 미리보기 | [미리보기] | TBD — 읽기 전용 트리 렌더 | + +라우트: `/checklist-management` (메뉴 URL은 CMS 등록값과 일치시킴) + +### 8-5. 데이터 흐름(요약) + +- **진입**: `useCklstMngPage` → 영역 A, `useCklstArtclPage` → 영역 B. C 비활성. +- **점검표 선택**: `cklstSn` 설정 → 영역 C 활성 → `useCklstRvwPage({ cklstSn })` → 트리 그리드 표시. +- **점검표 CRUD**: Dialog → `useCreateCklstMng` / `useUpdateCklstMng` / `useDeleteCklstMng`. +- **점검항목 CRUD**: Dialog → `useCreateCklstArtcl` / `useUpdateCklstArtcl` / `useDeleteCklstArtcl`. +- **검토 저장**: [저장] → 신규 POST / 기존 PUT 순차 호출 (일괄 API 없음). +- **영역 C 점검항목 Select**: `useCklstArtclList(cklstSn)` 또는 영역 B 전체 목록과 교차 — **TBD**. + +### 8-6. 미확정 / 검증 필요 + +- [ ] 영역 B 점검항목 등록 시 `cklstSn` 필수 vs UI 무소속 마스터 — 백엔드·기획 확인 +- [ ] `점검항목코드`(ITEM_001) 표시 형식 vs `cklstArtclSn` 숫자 PK +- [ ] 영역 C 전체 검토 트리 조회: `cklstSn` 단위 일괄 API 부재 — `by-artcl` 다건 호출 또는 백엔드 확장 +- [x] `@/entities/cklst` — `cklstRvwService`·hooks 구현 +- [x] `pages/checklist-management` — 영역 A·B·C UI 구현 (`/checklist-management`) +- [ ] 문서구분·업무구분 공통코드 상위코드 ID (`ASDOCSECD` 등) +- [ ] [미리보기] 다이얼로그 UI·데이터 소스 +- [ ] `bchckUseYn` UI 반영 여부 +- [ ] 영역 A·B 페이지네이션·검색 UI 노출 여부 +- [ ] 권한(역할별 CRUD) diff --git a/apps/as/docs/code-management/SPEC.md b/apps/as/docs/code-management/SPEC.md new file mode 100644 index 000000000..e745ab071 --- /dev/null +++ b/apps/as/docs/code-management/SPEC.md @@ -0,0 +1,385 @@ +# 화면설계서: 코드관리 + +## 1. 기본 정보 + +| 항목 | 내용 | +| ---------- | -------------------------------------------- | +| 버전 | 0.1 | +| 프로세스명 | UI설계 | +| 화면명 | 코드관리 | +| 화면 유형 | [v] 조회 [v] 입력 [v] 수정 [v] 삭제 [ ] 출력 | +| 메뉴 경로 | 디지털행정지원 > 사이트관리 > 코드관리 | + +--- + +## 2. 화면 목적 + +설치인가 등 AS 업무에서 참조하는 **공종구분코드**(공종코드)와 **회계구분코드**(회계코드)를 한 화면에서 조회·등록·수정·삭제한다. 좌측 **공종코드 관리**와 우측 **회계코드 관리**는 독립된 목록·CRUD 영역이며, 각 섹션은 자체 API(`cnstp-se`, `acntg-se`)와 연동한다. + +--- + +## 3. 화면 구성 + +### 3-1. 전체 레이아웃 + +``` +┌───────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ 페이지 타이틀: 코드관리 브레드크럼: 디지털행정지원 > 사이트관리 > 코드관리 │ +├──────────────────────────────────────────────────┬────────────────────────────────────────────────┤ +│ [영역 A] 공종코드 관리 │ [영역 B] 회계코드 관리 │ +│ [등록] │ [등록] │ +│ ┌────────────────────────────────────────────┐ │ ┌──────────────────────────────────────────┐ │ +│ │ 코드 | 코드명 | 설명 | 사용여부 | 관리 │ │ │ 코드 | 코드명 | 설명 | 사용여부 | 관리 │ │ +│ ├────────────────────────────────────────────┤ │ ├──────────────────────────────────────────┤ │ +│ │ WC001 공공하수처리시설 … 사용 [수정][삭제]│ │ │ AC101 시설비 … 사용 [수정][삭제] │ │ +│ │ WC002 하수관로정비 … 사용 [수정][삭제]│ │ │ AC102 용역비 … 사용 [수정][삭제] │ │ +│ │ WC003 재이용시설 … 사용 [수정][삭제]│ │ │ AC103 운영비 … 미사용 [수정][삭제] │ │ +│ └────────────────────────────────────────────┘ │ └──────────────────────────────────────────┘ │ +│ « 1 2 3 … » │ « 1 2 3 … » │ +└──────────────────────────────────────────────────┴────────────────────────────────────────────────┘ +``` + +### 3-2. 화면 데이터 흐름 + +``` +BEGIN + + // 최초 진입 — 좌·우 독립 조회 + CALL cnstpSeMngService.findAll({ page, size }) + DISPLAY 공종코드 그리드 (영역 A) + + CALL acntgSeMngService.findAll({ page, size }) + DISPLAY 회계코드 그리드 (영역 B) + + // 영역 A: 공종코드 등록 + ON 영역 A [등록] 버튼 CLICK DO + OPEN 공종코드 등록 다이얼로그 + ON [저장] DO + VALIDATE cnstpSeCd + CALL cnstpSeMngService.save(CnstpSeMngSaveRequestDto) + CALL cnstpSeMngService.findAll + CLOSE 다이얼로그 + END ON + ON [취소] / [닫기] DO + CLOSE 다이얼로그 + END ON + END ON + + // 영역 A: 공종코드 수정 + ON 영역 A 행 [수정] CLICK DO + CALL cnstpSeMngService.findById(cnstpSeCd) + OPEN 공종코드 수정 다이얼로그 (기존 값 초기화) + ON [저장] DO + CALL cnstpSeMngService.update(cnstpSeCd, CnstpSeMngSaveRequestDto) + CALL cnstpSeMngService.findAll + CLOSE 다이얼로그 + END ON + END ON + + // 영역 A: 공종코드 삭제 + ON 영역 A 행 [삭제] CLICK DO + CONFIRM "선택한 공종코드를 삭제하시겠습니까?" + CALL cnstpSeMngService.delete(cnstpSeCd) + CALL cnstpSeMngService.findAll + END ON + + // 영역 B: 회계코드 등록 + ON 영역 B [등록] 버튼 CLICK DO + OPEN 회계코드 등록 다이얼로그 + ON [저장] DO + VALIDATE acntgSeCd, acntgSeCdNm + CALL acntgSeMngService.save(AcntgSeMngSaveRequestDto) + CALL acntgSeMngService.findAll + CLOSE 다이얼로그 + END ON + ON [취소] / [닫기] DO + CLOSE 다이얼로그 + END ON + END ON + + // 영역 B: 회계코드 수정 + ON 영역 B 행 [수정] CLICK DO + CALL acntgSeMngService.findById(acntgSeCd) + OPEN 회계코드 수정 다이얼로그 (기존 값 초기화) + ON [저장] DO + CALL acntgSeMngService.update(acntgSeCd, AcntgSeMngSaveRequestDto) + CALL acntgSeMngService.findAll + CLOSE 다이얼로그 + END ON + END ON + + // 영역 B: 회계코드 삭제 + ON 영역 B 행 [삭제] CLICK DO + CONFIRM "선택한 회계코드를 삭제하시겠습니까?" + CALL acntgSeMngService.delete(acntgSeCd) + CALL acntgSeMngService.findAll + END ON + +END +``` + +--- + +## 4. 영역 A: 공종코드 관리 + +백엔드 **공종구분코드**(`CnstpSeMng`) CRUD. 계층 구조(`upCnstpCd`, `cdLvNo`)를 지원하나, 본 화면은 **평면 목록**으로 표시. + +### 4-1. 헤더 / 툴바 + +| 항목 | 내용 | +| ----------- | ------------------------------------- | +| 섹션 타이틀 | 공종코드 관리 | +| 우측 버튼 | **[등록]** — 공종코드 등록 다이얼로그 | + +### 4-2. 그리드 컬럼 + +| 컬럼명 | Field | 타입 | 비고 | +| -------- | ------------- | ------ | ------------------------ | +| 코드 | `cnstpSeCd` | string | PK, 최대 10자 | +| 코드명 | `cnstpSeCdNm` | string | 최대 100자 | +| 설명 | `expln` | string | 최대 1000자, 선택 | +| 사용여부 | `useYn` | string | `Y`→사용, `N`→미사용 | +| 관리 | — | — | 행별 **[수정] · [삭제]** | + +- 행 클릭 시 별도 상세 이동 없음. 수정은 **[수정]** 버튼으로 다이얼로그 진입. +- 페이지네이션: Spring `Pageable` (`page`, `size`, `sort`) — **UI 노출**. 기본 `size: 10`, `@common/components` 공통 페이지네이션 컴포넌트 사용. + +### 4-3. 버튼 + +| 버튼명 | 위치 | 동작 | +| ------ | -------------- | ---------------------------------------- | +| 등록 | 섹션 우측 상단 | 공종코드 등록 다이얼로그 오픈 | +| 수정 | 행별 | 상세 조회 후 수정 다이얼로그 (값 초기화) | +| 삭제 | 행별 | 확인 후 삭제, 목록 갱신 | + +### 4-4. 다이얼로그: 공종코드 등록 / 수정 + +다이얼로그 입력 필드는 **코드 · 코드명 · 설명 · 사용여부** 4개만 노출한다. + +| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 | +| -------- | --------- | ---- | ------ | ----------------- | ------------------------------- | +| 코드 | Input | Y | — | `cnstpSeCd` | 등록 시 입력, 수정 시 읽기 전용 | +| 코드명 | Input | N | — | `cnstpSeCdNm` | 최대 100자 | +| 설명 | Input | N | — | `expln` | 최대 1000자 | +| 사용여부 | Radio | N | Y | `useYn` | `Y`→사용, `N`→미사용 | + +> UI에 노출하지 않는 API 필드(`upCnstpCd`, `cdLvNo`): 등록 시 `cdLvNo: 1` 등 기본값 전송, 수정 시 `findById` 응답값 유지. + +| 버튼명 | 동작 | +| ------ | ------------------------------------------------ | +| 저장 | POST(등록) 또는 PUT(수정) 후 `findAll` 목록 갱신 | +| 취소 | 다이얼로그 닫기, 입력값 폐기 | +| 닫기 | 다이얼로그 닫기 | + +--- + +## 5. 영역 B: 회계코드 관리 + +백엔드 **회계구분관리**(`AcntgSeMng`) CRUD. 영역 A와 동일한 **평면 목록** UI 패턴을 따른다. + +### 5-1. 헤더 / 툴바 + +| 항목 | 내용 | +| ----------- | ------------------------------------- | +| 섹션 타이틀 | 회계코드 관리 | +| 우측 버튼 | **[등록]** — 회계코드 등록 다이얼로그 | + +### 5-2. 그리드 컬럼 + +| 컬럼명 | Field | 타입 | 비고 | +| -------- | ------------- | ------ | ------------------------ | +| 코드 | `acntgSeCd` | string | PK, 최대 10자 | +| 코드명 | `acntgSeCdNm` | string | 최대 100자 | +| 설명 | `expln` | string | 최대 1000자, 선택 | +| 사용여부 | `useYn` | string | `Y`→사용, `N`→미사용 | +| 관리 | — | — | 행별 **[수정] · [삭제]** | + +- 페이지네이션: 영역 A와 동일 — **UI 노출**, 기본 `size: 10`. + +### 5-3. 버튼 + +| 버튼명 | 위치 | 동작 | +| ------ | -------------- | ---------------------------------------- | +| 등록 | 섹션 우측 상단 | 회계코드 등록 다이얼로그 오픈 | +| 수정 | 행별 | 상세 조회 후 수정 다이얼로그 (값 초기화) | +| 삭제 | 행별 | 확인 후 삭제, 목록 갱신 | + +### 5-4. 다이얼로그: 회계코드 등록 / 수정 + +다이얼로그 입력 필드는 **코드 · 코드명 · 설명 · 사용여부** 4개만 노출한다. (영역 A와 동일 패턴) + +| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 | +| -------- | --------- | ---- | ------ | ----------------- | ------------------------------- | +| 코드 | Input | Y | — | `acntgSeCd` | 등록 시 입력, 수정 시 읽기 전용 | +| 코드명 | Input | Y | — | `acntgSeCdNm` | 최대 100자 | +| 설명 | Input | N | — | `expln` | 최대 1000자 | +| 사용여부 | Radio | N | Y | `useYn` | `Y`→사용, `N`→미사용 | + +> UI에 노출하지 않는 API 필드(`upAcntgSeCd`, `cdLvNo`): 등록 시 `cdLvNo: 1` 등 기본값 전송, 수정 시 `findById` 응답값 유지. + +| 버튼명 | 동작 | +| ------ | ------------------------------------------------ | +| 저장 | POST(등록) 또는 PUT(수정) 후 `findAll` 목록 갱신 | +| 취소 | 다이얼로그 닫기, 입력값 폐기 | +| 닫기 | 다이얼로그 닫기 | + +--- + +## 6. API/DTO 명세 + +### 6-1. 공통 + +| 항목 | 내용 | +| ----------- | ---------------------------------------------------------------------------------- | +| 서비스 | `as-service` | +| Base prefix | `/as-service/api/v1` (`ENDPOINT.AS_COMMON_SERVICE`) | +| 인증 | JWT (`Authorization` 헤더), `X-Site-Id`, `token-id` | +| 페이징 | Spring `Pageable`: `page`, `size`, `sort` → `PageResponse` | +| 엔티티 | `@/entities/cnstp` (`cnstpSeMngService`), `@/entities/acntg` (`acntgSeMngService`) | + +**부가 API (본 화면 목록 외 참조용)** + +| 리소스 | Method | URL | Response | 용도 | +| ------------ | ------ | --------------------- | --------------------- | --------------------------------- | +| 공종구분코드 | GET | `/cnstp-se/list/tree` | `CnstpSeMngTreeDto[]` | 계층 트리 (타 화면·상위코드 선택) | +| 회계구분관리 | GET | `/acntg-se/list/tree` | `AcntgSeMngTreeDto[]` | 계층 트리 (타 화면·상위코드 선택) | + +### 6-2. 공종구분코드 (영역 A) — `cnstp-se` + +| 기능 | Method | URL | Request / Query | Response | 클라이언트 | +| --------- | ------ | ----------------------- | -------------------------------- | ----------------------------- | ---------------------------- | +| 목록 조회 | GET | `/cnstp-se` | `CnstpSeMngSearchDto` + Pageable | `Page` | `cnstpSeMngService.findAll` | +| 트리 조회 | GET | `/cnstp-se/list/tree` | — | `CnstpSeMngTreeDto[]` | `cnstpSeMngService.findTree` | +| 상세 조회 | GET | `/cnstp-se/{cnstpSeCd}` | — | `CnstpSeMngResponseDto` | `cnstpSeMngService.findById` | +| 등록 | POST | `/cnstp-se` | `CnstpSeMngSaveRequestDto` | `string` (cnstpSeCd) | `cnstpSeMngService.save` | +| 수정 | PUT | `/cnstp-se/{cnstpSeCd}` | `CnstpSeMngSaveRequestDto` | `string` (cnstpSeCd) | `cnstpSeMngService.update` | +| 삭제 | DELETE | `/cnstp-se/{cnstpSeCd}` | — | `string` (cnstpSeCd) | `cnstpSeMngService.delete` | + +**CnstpSeMngResponseDto** + +| Field | 타입 | 설명 | +| ------------- | ------- | ------------------ | +| `cnstpSeCd` | string | 공종구분코드 | +| `cnstpSeCdNm` | string | 공종구분코드명 | +| `upCnstpCd` | string? | 상위공종코드 | +| `cdLvNo` | number | 코드레벨번호 | +| `useYn` | string | 사용여부 | +| `expln` | string? | 설명 (최대 1000자) | +| `rgtr` | string? | 등록자 UUID | +| `rgtrNm` | string? | 등록자명 | +| `mdfr` | string? | 수정자 UUID | +| `regDt` | string? | 등록일시 | +| `mdfcnDt` | string? | 수정일시 | + +**CnstpSeMngSaveRequestDto**: `cnstpSeCd`, `cnstpSeCdNm?`, `upCnstpCd?`, `cdLvNo?`, `useYn?`, `expln?` + +**CnstpSeMngSearchDto**: `cnstpSeCd?`, `cnstpSeCdNm?`, `useYn?` + `page`, `size`, `sort` + +**React Query hooks**: `useCnstpSeMngPage`, `useCnstpSeMngTree`, `useCnstpSeMng`, `useSaveCnstpSeMng`, `useUpdateCnstpSeMng`, `useDeleteCnstpSeMng` + +### 6-3. 회계구분관리 (영역 B) — `acntg-se` + +| 기능 | Method | URL | Request / Query | Response | 클라이언트 | +| --------- | ------ | ----------------------- | -------------------------------- | ----------------------------- | ---------------------------- | +| 목록 조회 | GET | `/acntg-se` | `AcntgSeMngSearchDto` + Pageable | `Page` | `acntgSeMngService.findAll` | +| 트리 조회 | GET | `/acntg-se/list/tree` | — | `AcntgSeMngTreeDto[]` | `acntgSeMngService.findTree` | +| 상세 조회 | GET | `/acntg-se/{acntgSeCd}` | — | `AcntgSeMngResponseDto` | `acntgSeMngService.findById` | +| 등록 | POST | `/acntg-se` | `AcntgSeMngSaveRequestDto` | `string` (acntgSeCd) | `acntgSeMngService.save` | +| 수정 | PUT | `/acntg-se/{acntgSeCd}` | `AcntgSeMngSaveRequestDto` | `string` (acntgSeCd) | `acntgSeMngService.update` | +| 삭제 | DELETE | `/acntg-se/{acntgSeCd}` | — | `string` (acntgSeCd) | `acntgSeMngService.delete` | + +**AcntgSeMngResponseDto** + +| Field | 타입 | 설명 | +| ------------- | ------- | ------------------ | +| `acntgSeCd` | string | 회계구분코드 | +| `acntgSeCdNm` | string | 회계구분코드명 | +| `upAcntgSeCd` | string? | 상위회계구분코드 | +| `cdLvNo` | number | 코드레벨번호 | +| `useYn` | string | 사용여부 | +| `expln` | string? | 설명 (최대 1000자) | +| `rgtr` | string? | 등록자 UUID | +| `rgtrNm` | string? | 등록자명 | +| `mdfr` | string? | 수정자 UUID | +| `regDt` | string? | 등록일시 | +| `mdfcnDt` | string? | 수정일시 | + +**AcntgSeMngSaveRequestDto**: `acntgSeCd`, `acntgSeCdNm`, `upAcntgSeCd?`, `cdLvNo`, `useYn?`, `expln?` + +**AcntgSeMngSearchDto**: `acntgSeCd?`, `acntgSeCdNm?`, `useYn?` + `page`, `size`, `sort` + +**React Query hooks**: `useAcntgSeMngPage`, `useAcntgSeMngTree`, `useAcntgSeMng`, `useSaveAcntgSeMng`, `useUpdateAcntgSeMng`, `useDeleteAcntgSeMng` + +--- + +## 7. UI 구현 가이드 + +### 7-1. 기술 스택·원칙 + +- Next.js App Router, React Query (`@tanstack/react-query`) +- `@common/components` — `Card`, `Button`, `Dialog`, `DataTable` / `useDataTable` +- `@tanstack/react-table` — 컬럼 정의는 `model/*-table-columns.tsx` 분리 +- 기존 entity 레이어 재사용: `@/entities/cnstp`, `@/entities/acntg` (service·queries·interface 구현 완료) +- 좌·우 2열 레이아웃: `grid grid-cols-1 lg:grid-cols-2 gap-4` (모바일 1열 스택) +- 삭제 전 `confirm` / Toast 피드백 — 프로젝트 공통 패턴 준수 +- 목록 하단 **페이지네이션 UI 노출** — 영역 A·B 각각 독립 page state +- **검색 필터 UI 미제공** — `findAll` 호출 시 `page`, `size`만 전달 +- **등록/수정 다이얼로그** — 코드·코드명·설명은 `Input`, 사용여부는 `Radio` (4필드만 노출) +- 상위 코드 삭제 시 **하위 코드 연쇄 삭제 UI·로직 미구현** — 단건 삭제만 수행 + +### 7-2. 모듈·파일 배치 + +| 구분 | 경로 (제안) | +| ----------------- | -------------------------------------------------------------------- | +| 라우트 | `apps/as/src/app/(authenticated)/site/code-management/page.tsx` | +| 페이지 컴포넌트 | `apps/as/src/pages/code-management/ui/code-management-page.tsx` | +| 공종 그리드 컬럼 | `apps/as/src/pages/code-management/model/cnstp-se-table-columns.tsx` | +| 회계 그리드 컬럼 | `apps/as/src/pages/code-management/model/acntg-se-table-columns.tsx` | +| 공종 등록/수정 폼 | `apps/as/src/pages/code-management/ui/cnstp-se-form-dialog.tsx` | +| 회계 등록/수정 폼 | `apps/as/src/pages/code-management/ui/acntg-se-form-dialog.tsx` | +| API·타입 (기존) | `@/entities/cnstp`, `@/entities/acntg` | + +### 7-3. 영역별 UI 매핑 + +| 영역 | UI 컴포넌트 | 데이터 소스 | 비고 | +| ------------ | ------------------------------------- | ------------------------------------------- | ------------------------------ | +| A | `CnstpSeSection` + Table + Pagination | `useCnstpSeMngPage({ page, size })` | 독립 page state·mutation | +| B | `AcntgSeSection` + Table + Pagination | `useAcntgSeMngPage({ page, size })` | A와 상호 의존 없음 | +| A 다이얼로그 | `CnstpSeFormDialog` | `useCnstpSeMng`, `useSave/UpdateCnstpSeMng` | Input 4필드 + Radio 사용여부 | +| B 다이얼로그 | `AcntgSeFormDialog` | `useAcntgSeMng`, `useSave/UpdateAcntgSeMng` | Input 4필드 + Radio 사용여부 | + +### 7-4. 다이얼로그·라우팅 + +| 다이얼로그 | 트리거 | 라우팅 | +| ------------- | ---------------- | -------------------- | +| 공종코드 등록 | 영역 A [등록] | URL 변경 없음 (모달) | +| 공종코드 수정 | 영역 A 행 [수정] | URL 변경 없음 (모달) | +| 회계코드 등록 | 영역 B [등록] | URL 변경 없음 (모달) | +| 회계코드 수정 | 영역 B 행 [수정] | URL 변경 없음 (모달) | + +### 7-5. 데이터 흐름 (구현 관점) + +``` +BEGIN + + // 페이지 마운트 — 검색 조건 없이 page·size만 전달 + const cnstpQuery = useCnstpSeMngPage({ page: 0, size: 20 }) + const acntgQuery = useAcntgSeMngPage({ page: 0, size: 20 }) + + DISPLAY CodeManagementPage + ├─ CnstpSeSection(data=cnstpQuery.data, pagination) + └─ AcntgSeSection(data=acntgQuery.data, pagination) + + // 페이지 변경 + ON pagination page/size CHANGE DO + refetch cnstpQuery | acntgQuery with new page, size + END ON + + // mutation 성공 시 + ON save/update/delete SUCCESS DO + invalidateQueries cnstp-se | acntg-se baseKey + TOAST "저장되었습니다." | "삭제되었습니다." + END ON + +END +``` diff --git a/apps/as/docs/essential-attachment-file-management/SPEC.md b/apps/as/docs/essential-attachment-file-management/SPEC.md new file mode 100644 index 000000000..72e7c06b7 --- /dev/null +++ b/apps/as/docs/essential-attachment-file-management/SPEC.md @@ -0,0 +1,284 @@ +# 화면설계서: 필수첨부파일관리 + +## 1. 기본 정보 + +| 항목 | 내용 | +| ---------- | ---------------------------------------------- | +| 버전 | 0.1 | +| 프로세스명 | UI설계 | +| 화면명 | 필수첨부파일관리 | +| 화면 유형 | [v] 조회 [v] 입력 [v] 수정 [v] 삭제 [ ] 출력 | +| 메뉴 경로 | 디지털행정지원 > 사이트관리 > 필수첨부파일관리 | + +--- + +## 2. 화면 목적 + +문서(업무) 구분별로 **필수 첨부파일** 항목을 등록·조회·수정·삭제한다. 상단 검색 영역에서 **문서구분**으로 목록을 필터링하고, **등록** 또는 행별 **수정**·**삭제**로 마스터 데이터를 유지한다. 등록·수정 시 **필수첨부파일아이디**는 클라이언트에서 입력(또는 생성)하여 요청 본문에 포함한다. + +--- + +## 3. 화면 구성 + +### 3-1. 전체 레이아웃 + +``` +┌──────────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ 페이지 타이틀: 필수첨부파일관리 브레드크럼: 디지털행정지원 > … > │ +├──────────────────────────────────────────────────────────────────────────────────────────────────────┤ +│ [영역 A] 검색 / 액션 │ +│ ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ │ +│ │ 문서구분 [전체 ▼] [조회] [등록] │ │ +│ └────────────────────────────────────────────────────────────────────────────────────────────────┘ │ +├──────────────────────────────────────────────────────────────────────────────────────────────────────┤ +│ [영역 B] 필수첨부파일 목록 │ +│ ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ │ +│ │ 필수첨부파일아이디 | 문서구분 | 필수첨부파일명 | 필수첨부여부 | 비고 | 등록일시 | 수정일시 | 관리 │ +│ ├────────────────────────────────────────────────────────────────────────────────────────────────┤ │ +│ │ EAF-0001 | 설치인가 | 사업계획서 | 예 | 서명본 첨부 | 2026-04-22 10:10:00 | — | [수정][삭제] │ │ +│ │ EAF-0002 | 기본계획 | 변경사유서 | 예 | — | 2026-04-22 11:00:00 | — | [수정][삭제] │ │ +│ └────────────────────────────────────────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────────────────────────────────────┘ + + (모달) [다이얼로그 C] 필수첨부파일 등록 / 수정 + ┌──────────────────────────────────────────────────────┐ + │ 필수첨부파일 등록 [닫기] │ + ├──────────────────────────────────────────────────────┤ + │ 필수첨부파일아이디(자동발급) [EAF-0004] │ + │ 문서구분 [설치인가 ▼] │ + │ 필수첨부파일명 [예: 사업계획서] │ + │ 필수첨부여부 [ ] 예 [ ] 아니오 │ + │ 비고 [비고를 입력하세요] │ + ├──────────────────────────────────────────────────────┤ + │ [취소] [저장] │ + └──────────────────────────────────────────────────────┘ +``` + +### 3-2. 화면 데이터 흐름 + +``` +BEGIN + + // 최초 진입 + CALL 필수첨부파일 목록 조회 (findPage) + DISPLAY 필수첨부파일 그리드 (영역 B) + + // 영역 A: 검색 + ON [조회] 버튼 CLICK DO + CALL 필수첨부파일 목록 조회 (문서구분 = 선택값, findPage) + DISPLAY 필수첨부파일 그리드 (영역 B) + END ON + + // 영역 A: 등록 + ON [등록] 버튼 CLICK DO + OPEN 필수첨부파일 등록 다이얼로그 (다이얼로그 C) + ON [저장] DO + CONFIRM "등록하시겠습니까?" + VALIDATE 문서구분, 필수첨부파일명, 필수첨부여부 + CALL 필수첨부파일 등록 (create) + CALL 필수첨부파일 목록 조회 + CLOSE 다이얼로그 + TOAST "저장되었습니다." + END ON + ON [취소] / [닫기] DO + CLOSE 다이얼로그 + END ON + END ON + + // 영역 B: 수정 + ON 행 [수정] CLICK DO + OPEN 필수첨부파일 수정 다이얼로그 (기존 값 초기화) + ON [저장] DO + VALIDATE 문서구분, 필수첨부파일명, 필수첨부여부 + CALL 필수첨부파일 수정 (update) + CALL 필수첨부파일 목록 조회 + CLOSE 다이얼로그 + END ON + END ON + + // 영역 B: 삭제 + ON 행 [삭제] CLICK DO + CONFIRM "삭제하시겠습니까?" + CALL 필수첨부파일 삭제 (remove) + CALL 필수첨부파일 목록 조회 + END ON + +END +``` + +--- + +## 4. 영역 A: 검색 / 액션 + +문서구분 조건으로 목록을 조회하고, 신규 등록 다이얼로그를 연다. + +### 4-1. 검색 필드 + +| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 | +| -------- | ---------- | ---- | ------ | ----------------- | ------------------------------ | +| 문서구분 | CodeSelect | N | 전체 | `docSeComCd?` | "전체" 선택 시 파라미터 미전송 | + +### 4-2. 버튼 + +| 버튼명 | 위치 | 동작 | +| ------ | -------------- | ----------------------------------------------- | +| 조회 | 검색 필드 우측 | 현재 검색 조건으로 영역 B 목록 갱신 | +| 등록 | 영역 우측 끝 | 필수첨부파일 등록 다이얼로그(다이얼로그 C) 오픈 | + +--- + +## 5. 영역 B: 필수첨부파일 목록 + +필수 첨부파일 마스터를 테이블로 표시한다. 페이지네이션 UI 적용. + +### 5-1. 헤더 + +| 항목 | 내용 | +| ----------- | ----------------- | +| 섹션 타이틀 | 필수첨부파일 목록 | + +### 5-2. 그리드 컬럼 + +| 컬럼명 | Field | 타입 | 비고 | +| ------------------ | ------------------- | ------ | ------------------------------------- | +| 필수첨부파일아이디 | `esntlAtchFileId` | string | 예: `EAF-0001`, 클라이언트 생성/입력 | +| 문서구분 | `docSeComCd` | string | 코드명 표시 — 공통코드 (TBD 상위코드) | +| 필수첨부파일명 | `esntlAtchFileNm` | string | | +| 필수첨부여부 | `esntlAtchYn` | string | `Y`→예, `N`→아니오 | +| 비고 | `esntlAtchFileRmrk` | string | 없으면 `—` 표시 | +| 등록일시 | `regDt` | string | `yyyy-MM-dd HH:mm:ss` | +| 수정일시 | `mdfcnDt` | string | 미수정 시 빈 값 또는 `—` | +| 관리 | — | — | 행별 **[수정] · [삭제]** | + +- 행 클릭 시 별도 상세 화면 없음. 편집은 **[수정]** 버튼으로 다이얼로그 C를 연다. + +### 5-3. 버튼 + +| 버튼명 | 위치 | 동작 | +| ------ | ---- | -------------------------------------- | +| 수정 | 행별 | 필수첨부파일 수정 다이얼로그 (기존 값) | +| 삭제 | 행별 | 확인 후 삭제 API 호출 → 목록 갱신 | + +--- + +## 6. 다이얼로그 C: 필수첨부파일 등록 / 수정 + +등록·수정 공용 폼 다이얼로그. 등록 시 제목 **「필수첨부파일 등록」**, 수정 시 **「필수첨부파일 수정」**. + +### 6-1. 폼 필드 + +| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 | +| ------------------ | ---------- | ---- | ------ | ------------------- | -------------------------------- | +| 필수첨부파일아이디 | (Readonly) | Y | — | `esntlAtchFileId` | 등록 시 미노출 | +| 문서구분 | CodeSelect | Y | — | `docSeComCd` | 예: 설치인가, 기본계획 | +| 필수첨부파일명 | Text | Y | — | `esntlAtchFileNm` | placeholder: "예: 사업계획서" | +| 필수첨부여부 | RadioGroup | Y | Y | `esntlAtchYn` | Y/N, UI 표시: 예/아니오 | +| 비고 | Textarea | N | — | `esntlAtchFileRmrk` | placeholder: "비고를 입력하세요" | + +### 6-2. 버튼 + +| 버튼명 | 동작 | +| ------ | -------------------------------------- | +| 저장 | POST(등록) 또는 PUT(수정) 후 목록 갱신 | +| 취소 | 다이얼로그 닫기, 입력값 폐기 | +| 닫기 | 다이얼로그 닫기 (헤더 우측) | + +--- + +## 7. API/DTO 명세 + +### 7-1. 공통 + +| 항목 | 내용 | +| ----------- | ----------------------------------------------------- | +| 서비스 | AS Common Service | +| Base prefix | `/as-service/api/v1/esntl-atch-file` | +| 인증 | JWT (`Authorization`), `X-Site-Id` 등 | +| 페이징 | Spring `Pageable` 사용 (`page`, `size`, `sort`) | +| 엔티티 | `@/entities/esntl` (`esntl-service.ts`, `queries.ts`) | + +### 7-2. 필수첨부파일 API + +| 기능 | Method | URL | Request DTO | Response DTO | API (클라이언트 함수명) | +| --------- | ------ | -------------------- | ----------------------------- | ----------------------------------- | ----------------------- | +| 트리 조회 | GET | `/list/tree` | Query: `docSeComCd`(필수) | `EsntlAtchFileMngTreeResponseDto[]` | `findTree` | +| 목록 조회 | GET | `/` | Query: Search + Pageable | `PageResponse` | `findPage` | +| 단건 조회 | GET | `/{esntlAtchFileId}` | Path: `esntlAtchFileId` | `EsntlAtchFileMng` | `findById` | +| 등록 | POST | `/` | `EsntlAtchFileMngSaveRequest` | `string` (생성된 ID) | `create` | +| 수정 | PUT | `/{esntlAtchFileId}` | `EsntlAtchFileMngSaveRequest` | `string` (수정된 ID) | `update` | +| 삭제 | DELETE | `/{esntlAtchFileId}` | Path: `esntlAtchFileId` | `string` (삭제된 ID) | `remove` | + +**응답 DTO** + +| Field | 타입 | 설명 | +| ------------------- | ------- | -------------------------------- | +| `esntlAtchFileId` | string | 필수첨부파일아이디 | +| `docSeComCd` | string | 문서구분공통코드 | +| `esntlAtchFileNm` | string | 필수첨부파일명 | +| `esntlAtchYn` | string | 필수첨부여부 (Y/N) | +| `esntlAtchFileRmrk` | string? | 필수첨부파일비고 | +| `rgtr` | string? | 등록자 UUID | +| `rgtrNm` | string? | 등록자명 | +| `mdfr` | string? | 수정자 UUID | +| `regDt` | string? | 등록일시 (`yyyy-MM-dd HH:mm:ss`) | +| `mdfcnDt` | string? | 수정일시 | + +**저장 Request** + +| Field | 필수 | 설명 | +| ------------------- | ---- | ------------------ | +| `esntlAtchFileId` | Y | 필수첨부파일아이디 | +| `docSeComCd` | N | 문서구분 | +| `esntlAtchFileNm` | N | 필수첨부파일명 | +| `esntlAtchYn` | N | 필수첨부여부 | +| `esntlAtchFileRmrk` | N | 비고 | + +**검색 Query** + +| Field | 필수 | 설명 | +| ----------------- | ---- | ----------------------------- | +| `docSeComCd` | N | 문서구분 (전체 시 미전송) | +| `esntlAtchFileNm` | N | 필수첨부파일명 부분/일치 검색 | +| `esntlAtchYn` | N | 필수첨부여부 (`Y`/`N`) | +| `page` | N | 페이지 번호 (0-base) | +| `size` | N | 페이지 크기 (기본값: 100) | +| `sort` | N | 정렬 조건 (예: `regDt,desc`) | + +### 7-3. 공통코드 + +| UI 필드 | 코드 그룹 | 예시 값 | +| ------------ | --------- | ------------------ | +| 문서구분 | ASDOCSECD | 설치인가, 기본계획 | +| 필수첨부여부 | 고정 | Y(예), N(아니오) | + +--- + +## 8. UI 구현 가이드 + +### 8-1. 기술 스택·원칙 + +- **공통 컴포넌트**: `@common/components` (`Dialog`, `Button`, `Input`, `Select`, `Textarea`, `Label` 등) +- **테이블**: `@tanstack/react-table` + `useDataTable` +- **폼**: `react-hook-form` +- **데이터**: React Query — `@/entities/esntl/api/queries.ts` 훅 사용 +- **공통코드**: `@/entities/code` — `CodeSelect`, `findCodeLabel` +- **확인**: `@frontend-opensource/use-react-hooks` — `useConfirm` (등록·삭제 확인) + +### 8-2. 모듈·파일 배치 + +| 구분 | 경로 | +| ------ | ---------------------------------------------------------------------------------------------------- | +| 라우트 | `apps/as/app/(with-base-layout)/esntl-atch-file-mng/page.tsx` | +| 페이지 | `apps/as/src/pages/esntl-atch-file-mng/` (Public API: page만 export) | +| UI | `.../ui/esntl-atch-file-mng-page.tsx`, `esntl-atch-file-list.tsx`, `esntl-atch-file-form-dialog.tsx` | +| model | `.../model/esntl-atch-file-columns.tsx`, `esntl-atch-file-search-params.ts` | +| 엔티티 | `apps/as/src/entities/esntl/` (`esntl-service.ts`, `queries.ts`, `esntl-interface.ts`) | +| 문서 | `apps/as/docs/essential-attachment-file-management/SPEC.md` | + +### 8-3. 데이터 흐름(요약) + +- **진입**: 검색 params 기본값(`docSeComCd` = 전체) → 목록 query → 영역 B DataTable. +- **조회**: [조회] 클릭 시 search state 반영 → 목록 query refetch. +- **등록**: [등록] → Dialog `mode=create` → [저장] → POST → 목록 invalidate. +- **수정**: [수정] → Dialog `mode=edit` + row 초기값 → [저장] → PUT → 목록 invalidate. +- **삭제**: confirm → DELETE → 목록 invalidate. 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/docs/review-manpower-management/SPEC.md b/apps/as/docs/review-manpower-management/SPEC.md new file mode 100644 index 000000000..be0211783 --- /dev/null +++ b/apps/as/docs/review-manpower-management/SPEC.md @@ -0,0 +1,370 @@ +# 화면설계서: 검토인력관리 + +## 1. 기본 정보 + +| 항목 | 내용 | +| ---------- | -------------------------------------------- | +| 버전 | 0.2 | +| 프로세스명 | UI설계 | +| 화면명 | 검토인력관리 | +| 화면 유형 | [v] 조회 [v] 입력 [ ] 수정 [v] 삭제 [ ] 출력 | +| 메뉴 경로 | 디지털행정지원 > 사이트관리 > 검토인력관리 | + +--- + +## 2. 화면 목적 + +조직도에서 **한 명의 사용자**를 선택한 뒤, 결재 단계(접수·검토·승인·최종승인)별 **인원 배치** 영역에 즉시 등록한다. 등록·목록 갱신은 별도 저장 버튼 없이 **[선택 인원 배치]** 클릭 시 `rvw-mnpw` API로 바로 반영한다. 좌측 조직도는 CMS 조직·사용자 트리 API를, 우측 배치 목록은 검토인력관리 API를 사용한다. + +--- + +## 3. 화면 구성 + +### 3-1. 전체 레이아웃 + +``` +┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ 페이지 타이틀: 검토인력관리 브레드크럼: 디지털행정지원 > 사이트관리 > 검토인력관리 │ +├─────────────────────────────────┬──────────────────────────────────────────────────────────────────┤ +│ [영역 A] 조직도 │ [영역 B] 인원 배치 (결재구분별 4블록, 세로 스택) │ +│ 제목: 조직도 │ │ +│ [A-1] 검색: │ ┌─ [B-1] 접수 ───────────── count ─ [선택 인원 배치] ─┐ │ +│ [성명·부서 검색] [조회][초기화]│ │ (배치 목록 또는 빈 상태 문구) │ │ +│ 선택 안내: │ └──────────────────────────────────────────────────────┘ │ +│ "선택된 인원: {이름} │ ┌─ [B-2] 검토 ───────────── count ─ [선택 인원 배치] ──┐ │ +│ ({부서} / {직급})" │ └──────────────────────────────────────────────────────┘ │ +│ [A-2] 조직 트리 + 사용자 행 │ ┌─ [B-3] 승인 ───────────── count ─ [선택 인원 배치] ──┐ │ +│ ┌────────────────────┐ │ └──────────────────────────────────────────────────────┘ │ +│ │ 조직 트리 + 사용자 │ │ ┌─ [B-4] 최종승인 ───────── count ─ [선택 인원 배치] ──┐ │ +│ │ (단일 선택 강조) │ │ └──────────────────────────────────────────────────────┘ │ +│ └────────────────────┘ │ │ +└─────────────────────────────────┴──────────────────────────────────────────────────────────────────┘ +``` + +### 3-2. 화면 데이터 흐름 + +``` +BEGIN + + // 최초 진입 + CALL userOgnzService.findOgnzUserTree() + DISPLAY 조직도 트리 (영역 A) + + CALL rvwMnpwMngService.findPage({ size: 충분히 큰 값 }) // 또는 atrzSeComCd별 분할 조회 + GROUP BY atrzSeComCd + DISPLAY 인원 배치 4블록 (영역 B-1 ~ B-4) + 건수 뱃지 + + // 영역 A-1: 조직도 검색 (클라이언트 필터 — 서버 API 없음) + ON [조회] 버튼 CLICK / 검색 폼 SUBMIT DO + SET displayTree = filterOgnzUserTree(원본트리, keyword) + SET expandedOgnzSnIds = collectExpandedOgnzSnIds(displayTree) // 매칭 노드 자동 펼침 + DISPLAY 필터된 트리 (영역 A-2) + END ON + + ON [초기화] 버튼 CLICK DO + RESET keyword = "" + RESET displayTree = 원본트리 + RESET expandedOgnzSnIds = 빈 Set (또는 기본 접힘) + DISPLAY 전체 트리 (영역 A-2) + END ON + + // 영역 A-2: 사용자 단일 선택 + ON 조직도 사용자 행 CLICK DO + VALIDATE 동일 행 재클릭이 아닌 경우만 변경 + SET selectedPerson = { userUuid, userNm, ognzSn, ognzNm, jbgdSeCd, ... } + DISPLAY "선택된 인원: {userNm} ({ognzNm} / {직급명})" + END ON + + // 영역 B-k: 선택 인원 즉시 배치 + ON [B-k] [선택 인원 배치] 버튼 CLICK DO + VALIDATE selectedPerson 존재 + SET targetAtrzSeComCd = 블록 k 에 매핑된 결재구분 코드 (§4-2 표) + CALL rvwMnpwMngService.save({ + atrzSeComCd: targetAtrzSeComCd, + ognzSn: selectedPerson.ognzSn, + userUuid: selectedPerson.userUuid + }) + ON 성공 DO + CALL rvwMnpwMngService.findPage (또는 해당 블록 목록만 갱신) + DISPLAY [B-k] 배치 목록·건수 뱃지 갱신 + TOAST "배치되었습니다." + END ON + ON 실패(이미 존재) DO + TOAST (백엔드 메시지 연동) + END ON + END ON + + // 영역 B-k: 배치 인원 제거 (UI 상 삭제 UX — 스크린샷 미표기, API는 제공됨) + ON 배치 인원 [삭제] / [제거] CLICK DO + CONFIRM "해당 인원을 배치에서 제거하시겠습니까?" + CALL rvwMnpwMngService.delete(atrzSeComCd, ognzSn, userUuid) + CALL rvwMnpwMngService.findPage + DISPLAY [B-k] 목록·뱃지 갱신 + END ON + +END +``` + +--- + +## 4. 영역 A — 조직도 + +### 4-1. 표시·상호작용 + +| 요소 | 내용 | +| ---------------- | --------------------------------------------------------------------- | +| 섹션 제목 | 조직도 | +| 검색 폼 (A-1) | 성명·부서 키워드 입력 + **[조회]** + **[초기화]** — §4-3 | +| 선택 안내 문구 | `선택된 인원: {userNm} ({ognzNm} / {직급표시})` — 미선택 시 안내 문구 | +| 트리 (A-2) | 본부 → 팀 계층 + 팀 소속 사용자 목록 (`displayTree` 기준 표시) | +| 선택 규칙 | **사용자 1명만** 선택 가능 (라디오형 단일 선택) | +| 선택 시각 피드백 | 선택된 사용자 행 강조(배경·테두리) | +| 로딩/오류/빈값 | Skeleton / 오류 문구 / "등록된 조직/사용자가 없습니다." | +| 검색 결과 없음 | 필터 후 매칭 노드·사용자 0건. "조회된 인원이 없습니다." 문구 노출 | + +### 4-2. 조직도 데이터 매핑 + +| UI 표시 | 트리 DTO 필드 (`OgnzUserTreeResponseDto`) | 비고 | +| --------------- | ----------------------------------------- | ----------------------------------------- | +| 조직명 | `ognzNm` | 접기/펼치기 노드 | +| 사용자명 | `users[].userNm` | 클릭 시 선택 | +| 부서(안내 문구) | `ognzNm` (소속 팀 노드) | 선택 시 `selectedPerson.ognzNm` | +| 직급(안내 문구) | `users[].jbgdSeCd` 또는 `jbpsSeCd` | 공통코드 라벨 변환 | +| 저장용 조직 SN | `ognzSn` | 사용자가 속한 **리프/팀 노드**의 `ognzSn` | +| 저장용 사용자 | `users[].userUnqId` | API body `userUuid`로 전달 (§6-3) | + +### 4-3. 조직도 검색 (영역 A-1) + +결재선 관리 `AprvrPickerDialog`와 동일한 **클라이언트 사이드 필터** 패턴을 사용한다. 별도 검색 API는 없으며, `useOgnzUserTree`로 받은 **원본 트리**를 `filterOgnzUserTree`로 가공해 `displayTree`에 반영한다. + +#### 필드 정의 + +| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 | +| -------------- | --------- | ---- | ------ | ----------------- | ----------------------------------------- | +| 성명·부서 검색 | Text | N | (빈값) | — (클라이언트만) | `placeholder="성명·부서 검색"` | +| 조회 | Button | — | — | — | `type="submit"` — 폼 제출 시 필터 적용 | +| 초기화 | Button | — | — | — | `type="button"` — 키워드·트리·펼침 초기화 | + +#### 필터 규칙 (`filterOgnzUserTree`) + +| 대상 | 매칭 조건 | +| ---------------- | ----------------------------------------------------- | +| 조직명 | `ognzNm`에 키워드 포함 (대소문자 무시) | +| 사용자명 | `userNm`에 키워드 포함 (대소문자 무시) | +| 조직 매칭 시 | 해당 노드 **소속 사용자 전원** 유지 | +| 사용자만 매칭 시 | 매칭 사용자만 남기고 상위 조직 노드는 유지(경로 보존) | +| 하위 조직 | 재귀 필터 — 자식·손자 노드 매칭 시 상위도 표시 | +| 키워드 빈값 | 원본 트리 전체 반환 | + +#### 조회 후 트리 펼침 (`collectExpandedOgnzSnIds`) + +- **[조회]** 성공 후 필터 결과 트리의 **모든 노드** `ognzSn`을 `expandedOgnzSnIds`에 넣어 자동 펼침 +- 검색 결과에서 사용자·하위 조직을 바로 확인할 수 있도록 함 + +#### 초기화 동작 + +| 상태 | 초기화 후 값 | +| ------------------- | --------------------------------------- | +| `keyword` | `""` | +| `displayTree` | `useOgnzUserTree` 원본 `data` | +| `expandedOgnzSnIds` | `new Set()` (전체 접힘) | +| `selectedPerson` | **유지** — 검색 초기화와 선택 해제 분리 | + +#### 버튼 + +| 버튼명 | 동작 | +| ------ | --------------------------------------------------------- | +| 조회 | `filterOgnzUserTree(data, keyword)` → `displayTree` 갱신 | +| 초기화 | 키워드·표시 트리·펼침 상태 리셋 (§3-2 `ON [초기화]` 블록) | + +--- + +## 5. 영역 B — 인원 배치 + +### 5-1. 블록 공통 + +| 요소 | 내용 | +| ------------------ | ------------------------------------------------- | +| 블록 제목 | 접수 / 검토 / 승인 / 최종승인 | +| 건수 뱃지 | 해당 `atrzSeComCd`에 배치된 인원 수 | +| 액션 버튼 | **[선택 인원 배치]** — 클릭 시 즉시 `save` (§3-2) | +| 빈 상태 | "배치된 인원이 없습니다." | +| 배치 목록(있을 때) | 인원명·부서·직급 등 | + +### 5-2. 결재구분 ↔ 블록 매핑 + +| 블록 | UI 제목 | `atrzSeComCd` | 비고 | +| ---- | -------- | ------------- | -------------------------------------------------------------- | +| B-1 | 접수 | `ATRZ00` | 결재선 관리 `ATRZ_RECEIVER_CD`와 동일 | +| B-2 | 검토 | `ATRZ01` | 결재선 `ATRZ_REVIEWER_1_CD`. `ATRZ02` 별도 블록 여부 — **TBD** | +| B-3 | 승인 | `ATRZ03` | 결재선 `ATRZ_APPROVER_CD` | +| B-4 | 최종승인 | `ATRZ99` | 결재선 `ATRZ_FINAL_CD` | + +공통코드 마스터 `ATRZSE` / 화면 셀렉트용 `ATRZSECD`와 정합성은 배포 환경 코드표로 최종 확인. + +### 5-3. 배치 목록 항목 (표시) + +| 표시명 | Field (응답 DTO) | 비고 | +| -------- | ---------------- | ---------------------------- | +| 사용자명 | `userUuidNm` | `@CodeName` 미적용 시 TBD | +| 결재구분 | `atrzSeComCdNm` | 블록 제목과 중복 — 생략 가능 | +| 조직 SN | `ognzSn` | 삭제 API path에 사용 | +| 사용자 | `userUuid` | 삭제 API path에 사용 | + +### 5-4. 버튼 + +| 버튼명 | 위치 | 동작 | +| -------------- | ----------- | --------------------------------- | +| 선택 인원 배치 | 각 B-k 헤더 | `save` 후 목록·뱃지 갱신 (§3-2) | +| 제거 | 목록 항목 | `delete` 후 목록·뱃지 갱신 (§3-2) | + +### 5-5. 검증·제약 + +| 규칙 | 내용 | +| ------------- | ------------------------------------------------------------------- | +| 선택 필수 | `[선택 인원 배치]` 시 `selectedPerson` 없으면 버튼 비활성 또는 안내 | +| 단일 선택 | 조직도에서 사용자 1명만 `selectedPerson` 유지 | +| 중복 등록 | 동일 PK `(atrzSeComCd, ognzSn, userUuid)` 재등록 시 서버 오류 | +| 다건 배치 | 동일 블록에 서로 다른 사용자·조직은 **복수 행** 가능 (PK가 다름) | +| 결재구분 변경 | 본 화면 주 흐름은 `save`만 | + +--- + +## 6. API/DTO 명세 + +### 6-1. 공통 + +| 항목 | 내용 | +| ------------- | ------------------------------------------------------------- | +| 인증 | JWT (`Authorization` 헤더) | +| 페이징 | Spring `Pageable`: `page`, `size`, `sort` → `PageResponse` | +| 프론트 엔티티 | `@/entities/user-organization`, `@/entities/rvw-mnpw` | + +### 6-2. 조직·사용자 트리 (영역 A) — `user-ognz` + +| 항목 | 내용 | +| ----------- | ------------------------------- | +| 서비스 | `cms-service` | +| Base prefix | `/cms-service/api/v1/user-ognz` | + +| 기능 | Method | URL | Request DTO | Response DTO | API | +| ---------------- | ------ | ----------- | ----------- | --------------------------- | ---------------------------------- | +| 조직·사용자 트리 | GET | `/treeuser` | — | `OgnzUserTreeResponseDto[]` | `userOgnzService.findOgnzUserTree` | + +**`OgnzUserTreeResponseDto`**: `ognzSn`, `upOgnzSn`, `ognzNm`, `users[]`, `children[]` + +**`OgnzUserTreeUserDto`**: `userUnqId`, `userNm`, `jbpsSeCd`, `jbgdSeCd` + +**React Query**: `useOgnzUserTree` + +### 6-3. 검토인력관리 (영역 B) — `rvw-mnpw` + +| 항목 | 내용 | +| ----------- | ------------------------------------- | +| 서비스 | `as-service` | +| Base prefix | `/as-service/api/v1/rvw-mnpw` | +| ENDPOINT | `ENDPOINT.AS_COMMON_SERVICE.RVW_MNPW` | + +| 기능 | Method | URL | Request DTO / Query | Response DTO | API | +| --------------- | ------ | ------------------------------------ | -------------------------------- | ----------------------------- | ---------------------------- | +| 목록 조회 | GET | `/` | `RvwMnpwMngSearchDto` + Pageable | `Page` | `rvwMnpwMngService.findPage` | +| 상세 조회 | GET | `/{atrzSeComCd}/{ognzSn}/{userUuid}` | Path | `RvwMnpwMngResponseDto` | `rvwMnpwMngService.findById` | +| 등록(즉시 배치) | POST | `/` | `RvwMnpwMngSaveRequestDto` | `RvwMnpwMngResponseDto` (201) | `rvwMnpwMngService.save` | +| 결재구분 변경 | PUT | `/{atrzSeComCd}/{ognzSn}/{userUuid}` | `RvwMnpwMngUpdateRequestDto` | `RvwMnpwMngResponseDto` | `rvwMnpwMngService.update` | +| 삭제 | DELETE | `/{atrzSeComCd}/{ognzSn}/{userUuid}` | Path | `string` | `rvwMnpwMngService.delete` | + +**`RvwMnpwMngSearchDto`**: `atrzSeComCd?`, `ognzSn?`, `userUuid?` + `page`, `size`, `sort` + +**`RvwMnpwMngSaveRequestDto`**: `atrzSeComCd*`, `ognzSn*`, `userUuid*` + +**`RvwMnpwMngUpdateRequestDto`**: `newAtrzSeComCd*` — 본 화면 기본 흐름에서는 미사용 + +**`RvwMnpwMngResponseDto`**: `atrzSeComCd`, `atrzSeComCdNm?`, `ognzSn`, `userUuid`, `userUuidNm?`, `rgtr?`, `regDt?` + +### 6-4. 조직도 ↔ 검토인력 필드 매핑 (저장 시) + +| 조직도 (`OgnzUserTreeUserDto` + 노드) | `RvwMnpwMngSaveRequestDto` | 비고 | +| ------------------------------------- | -------------------------- | --------------------- | +| (블록 k의 `atrzSeComCd`) | `atrzSeComCd` | §5-2 표 | +| 소속 팀 `ognzSn` | `ognzSn` | | +| `userUnqId` | `userUuid` | 문자열(UUID)로 직렬화 | + +--- + +## 7. UI 구현 가이드 + +### 7-1. 기술 스택·원칙 + +- **공통 컴포넌트**: `@common/components` (`Button`, `Badge`, `Card` 또는 섹션 레이아웃) +- **데이터**: React Query — `useOgnzUserTree`, `useRvwMnpwMngPage`, `useSaveRvwMnpwMng`, `useDeleteRvwMnpwMng` +- **상태**: `selectedPerson`(단일), `keyword`, `displayTree`, `expandedOgnzSnIds`, 블록별 배치 목록(서버 데이터 파생) +- **조직도 검색**: `filterOgnzUserTree`, `collectExpandedOgnzSnIds` (`widgets/ognz-user-tree/lib` 또는 페이지 `lib`) +- **저장 UX**: 별도 화면 저장 없음 — **[선택 인원 배치]** = `save` mutation 즉시 호출 +- **조직도 UI**: 결재선 관리 `ognz-user-tree.tsx`를 **widgets**로 승격해 공통화 + +### 7-2. 모듈·파일 배치(제안) + +| 구분 | 경로 | +| -------- | ---------------------------------------------------------------------------------- | +| 라우트 | `apps/as/app/(with-base-layout)/rvw-mnpw-mng/page.tsx` | +| 페이지 | `apps/as/src/pages/rvw-mnpw-mng/` | +| UI | `.../ui/rvw-mnpw-mng-page.tsx`, `rvw-mnpw-assignment-panel.tsx` 등 | +| model | `.../model/selected-ognz-user.ts`, `rvw-mnpw-block-config.ts` (`atrzSeComCd` 상수) | +| lib | `.../lib/group-rvw-mnpw-by-atrz-se.ts` (목록 → B-1~B-4 분배) | +| **위젯** | `apps/as/src/widgets/ognz-user-tree/` — 아래 이전 대상 | +| 엔티티 | `entities/user-organization/`, `entities/rvw-mnpw/` (구현 완료) | + +#### widgets/ognz-user-tree 이전 (결재선 → 공통) + +| 현재 (결재선 관리) | 이전 후 (widgets) | +| ------------------------------------------------------ | ------------------------------------------------------------------------------------ | +| `pages/approval-line-management/ui/ognz-user-tree.tsx` | `widgets/ognz-user-tree/ui/ognz-user-tree.tsx` | +| `pages/.../ui/ognz-user-tree-node.tsx` | `widgets/ognz-user-tree/ui/ognz-user-tree-node.tsx` | +| `pages/.../model/reviewer-option.ts` | `widgets/ognz-user-tree/model/selected-ognz-user.ts` (이름·필드 확장: `ognzSn` 필수) | +| `pages/.../lib/filter-ognz-user-tree.ts` | `widgets/ognz-user-tree/lib/filter-ognz-user-tree.ts` (**필수** — 검색 공통화) | +| `widgets/ognz-user-tree/index.ts` | `OgnzUserTree`, `OgnzUserTreeNode`, 타입 export | + +결재선 `AprvrPickerDialog`는 `@/widgets/ognz-user-tree` import로 교체. 검토인력관리 페이지는 **인라인 조직도**(다이얼로그 없음)로 동일 위젯 사용. + +**`SelectedOgnzUser`** — `ReviewerOption` 확장: + +```typescript +export interface SelectedOgnzUser { + userUuid: string; // OgnzUserTreeUserDto.userUnqId + userNm: string; + ognzSn: number; + ognzNm: string; + jbgdSeCd?: string; + jbpsSeCd?: string; +} +``` + +### 7-3. 영역별 UI 매핑 + +| 영역 | 주요 UI | +| ---- | -------------------------------------------------------------------- | +| A-1 | 검색 폼 (`Input` + 조회/초기화) — `AprvrPickerDialog` 90~103행 패턴 | +| A-2 | `OgnzUserTree(nodes={displayTree})` + 선택 안내 + `useOgnzUserTree` | +| B-k | 제목·Badge(count)·`선택 인원 배치`·배치 목록(또는 empty) | +| 공통 | 페이지 헤더·브레드크럼·2열 레이아웃 (`grid` / `flex`, 모바일은 스택) | + +### 7-4. 다이얼로그·라우팅 + +| 항목 | 내용 | +| ---------- | ----------------------------------- | +| 다이얼로그 | **없음** (조직도 인라인, 즉시 저장) | +| 라우팅 | 메뉴 `검토인력관리` 단일 페이지 | + +### 7-5. 데이터 흐름(구현 요약) + +- **진입**: `useOgnzUserTree` → `displayTree = data` + `useRvwMnpwMngPage({ page: 0, size: N })` → 4패널. +- **검색**: `keyword` 로컬 상태 → **[조회]** 시 `filterOgnzUserTree` + `collectExpandedOgnzSnIds` → `OgnzUserTree`에 `displayTree` 전달. +- **초기화**: `keyword`·`displayTree`·`expandedOgnzSnIds` 리셋 (`selectedPerson` 유지). +- **선택**: 트리 `onSelect` → `selectedPerson` (반드시 `ognzSn` 포함). +- **배치**: `useSaveRvwMnpwMng().mutate({ atrzSeComCd, ognzSn, userUuid })` → `onSuccess`에서 `rvw-mnpw` query invalidate. +- **삭제**: 목록 항목에서 `useDeleteRvwMnpwMng` — UI 확정 후 연결. + +### 7-6. 미확정 / 검증 필요 + +- [ ] `ATRZ02`(검토2) 별도 블록 필요 여부 +- [ ] 권한(역할별 CRUD) diff --git a/apps/as/middleware.ts b/apps/as/middleware.ts index d9239dc40..94a1d0381 100644 --- a/apps/as/middleware.ts +++ b/apps/as/middleware.ts @@ -1,8 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; -import { PORT, PROXY_HOST, SERVER_API_URL, SITE_ID } from "@/shared/config"; +import { AUTH_USER_ID, CLAIM_NAME, PORT, PROXY_HOST, SERVER_API_URL, SITE_ID } from "@/shared/config"; -const serverApiUrl = process.env.SERVER_API_URL ?? "http://localhost:3000"; +const serverApiUrl = process.env.SERVER_API_URL; // CSP 헤더 설정 함수 (img-src만) function addSecurityHeaders(response: NextResponse): NextResponse { @@ -34,6 +34,28 @@ export function middleware(req: NextRequest) { return addSecurityHeaders(response); } + + // tus-js-client PATCH 요청: Location 헤더로 /server 없이 반환된 경로 직접 처리 + // (예: /attach-service/api/v1/tus/upload/{id}) + if (pathname.startsWith("/attach-service")) { + const requestHeaders = new Headers(req.headers); + const token = req.cookies.get(CLAIM_NAME)?.value; + const userId = req.cookies.get(AUTH_USER_ID)?.value; + const newUrl = `${serverApiUrl}${pathname}`; + + if (token) requestHeaders.set(CLAIM_NAME, token); + if (userId) requestHeaders.set(AUTH_USER_ID, userId); + + logMessages.push(` [TUS Proxy] Rewritten URL: ${newUrl}`); + logMessages.push("\n--------------------------------\n"); + console.log(logMessages.join("\n")); + + const response = NextResponse.rewrite(new URL(newUrl), { + headers: requestHeaders, + }); + return addSecurityHeaders(response); + } + // if (pathname === "/") { // const response = NextResponse.redirect(new URL("/dashboard", req.url)); // return addSecurityHeaders(response); @@ -47,5 +69,5 @@ export function middleware(req: NextRequest) { } export const config = { - matcher: ["/server/:path*", "/"], + matcher: ["/server/:path*", "/attach-service/:path*", "/"], }; diff --git a/apps/as/next-env.d.ts b/apps/as/next-env.d.ts deleted file mode 100644 index 3cd7048ed..000000000 --- a/apps/as/next-env.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// -/// -/// - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/as/next.config.ts b/apps/as/next.config.ts index 8864de0e7..d3afd4d70 100644 --- a/apps/as/next.config.ts +++ b/apps/as/next.config.ts @@ -1,6 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + output: "standalone", reactStrictMode: true, transpilePackages: ["@common/assets"], }; diff --git a/apps/as/package.json b/apps/as/package.json index efafdcc50..cebdf28ee 100644 --- a/apps/as/package.json +++ b/apps/as/package.json @@ -3,25 +3,36 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev -p 3003", "build": "next build", - "start": "next start", + "start": "next start -p 3003", "lint": "next lint", "lint:fsd": "steiger ." }, "dependencies": { "@common/assets": "workspace:*", + "@common/auth": "workspace:*", "@common/components": "workspace:*", "@common/network": "workspace:*", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@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", "axios": "^1.8.1", + "class-variance-authority": "^0.7.1", "dayjs": "^1.11.13", "js-cookie": "^3.0.5", - "next": "15.3.8", + "lucide-react": "^0.474.0", + "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 54fb55c52..ebe24b41c 100644 --- a/apps/as/src/app/layouts/base-layout.tsx +++ b/apps/as/src/app/layouts/base-layout.tsx @@ -5,7 +5,9 @@ import { usePathname, useRouter } from "next/navigation"; 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"; @@ -18,16 +20,20 @@ export function BaseLayoutWrapper({ children }: BaseLayoutWrapperProps) { const pathname = usePathname(); const queryClient = useQueryClient(); + const setCurrentMenuInfo = useMenuStore((state) => state.setCurrentMenuInfo); + const handleMenuClick = useCallback( (item: MenuItem) => { + setCurrentMenuInfo(item); router.push(item.urlPath); }, - [router], + [router, setCurrentMenuInfo], ); return ( diff --git a/apps/as/src/app/providers/index.tsx b/apps/as/src/app/providers/index.tsx index 476609d59..2a0ac6e69 100644 --- a/apps/as/src/app/providers/index.tsx +++ b/apps/as/src/app/providers/index.tsx @@ -1,11 +1,15 @@ "use client"; +import { ConfirmProvider } from "@frontend-opensource/use-react-hooks"; import { QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { queryClient } from "@/shared/lib"; import { StoreProvider } from "@/shared/providers"; +import { AlertDialog } from "@/shared/ui/alert-dialog"; +import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; import { AuthWrapper } from "@/entities/auth"; +import { MenuWrapper } from "@/entities/menu"; interface ProvidersProps { children: React.ReactNode; @@ -15,9 +19,17 @@ export function Providers({ children }: ProvidersProps) { return ( - {children} + + + {children} + + + + - +
+ +
); } diff --git a/apps/as/src/entities/acntg/api/acntg-service.ts b/apps/as/src/entities/acntg/api/acntg-service.ts new file mode 100644 index 000000000..fd620009e --- /dev/null +++ b/apps/as/src/entities/acntg/api/acntg-service.ts @@ -0,0 +1,58 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { PageResponse } from "@/shared/model"; + +import { + ACNTG_SE_SAVE_MODE, + type AcntgSeMngPutRequestDto, + type AcntgSeMngResponseDto, + type AcntgSeMngSaveRequestDto, + type AcntgSeMngSearchDto, + type AcntgSeMngService, + type AcntgSeMngTreeDto, +} from "../model/acntg-interface"; + +const BASE = ENDPOINT.AS_SERVICE.ACNTG_SE; + +async function findAll(params: AcntgSeMngSearchDto): Promise> { + const response = await client.get>(BASE, { params }); + return response.data; +} + +async function findTree(): Promise { + const response = await client.get(`${BASE}/list/tree`); + return response.data; +} + +async function findById(acntgSeCd: string): Promise { + const response = await client.get(`${BASE}/${acntgSeCd}`); + return response.data; +} + +async function saveMulti(body: AcntgSeMngSaveRequestDto[]): Promise { + const response = await client.post(BASE, body); + return response.data; +} + +async function update(acntgSeCd: string, body: AcntgSeMngPutRequestDto): Promise { + const response = await client.put(`${BASE}/${acntgSeCd}`, { + ...body, + acntgSeCd, + mode: ACNTG_SE_SAVE_MODE.UPDATE, + }); + return response.data; +} + +async function deleteAcntgSeCd(acntgSeCd: string): Promise { + const response = await client.delete(`${BASE}/${acntgSeCd}`); + return response.data; +} + +export const acntgSeMngService = { + findAll, + findTree, + findById, + saveMulti, + update, + delete: deleteAcntgSeCd, +} satisfies AcntgSeMngService; diff --git a/apps/as/src/entities/acntg/api/queries.ts b/apps/as/src/entities/acntg/api/queries.ts new file mode 100644 index 000000000..db00d2317 --- /dev/null +++ b/apps/as/src/entities/acntg/api/queries.ts @@ -0,0 +1,73 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { ENDPOINT } from "@/shared/config"; + +import type { + AcntgSeMngPutRequestDto, + AcntgSeMngSaveRequestDto, + AcntgSeMngSearchDto, +} from "../model/acntg-interface"; + +import { acntgSeMngService } from "./acntg-service"; + +const acntgSeMngBaseKey = [ENDPOINT.AS_SERVICE.ACNTG_SE] as const; + +export function useAcntgSeMngPage(params: AcntgSeMngSearchDto) { + return useQuery({ + queryKey: [...acntgSeMngBaseKey, "findAll", params], + queryFn: () => acntgSeMngService.findAll(params), + }); +} + +export function useAcntgSeMngTree() { + return useQuery({ + queryKey: [...acntgSeMngBaseKey, "findTree"], + queryFn: () => acntgSeMngService.findTree(), + staleTime: Infinity, + }); +} + +export function useAcntgSeMng(acntgSeCd: string | null | undefined) { + return useQuery({ + queryKey: [...acntgSeMngBaseKey, "findById", acntgSeCd], + queryFn: () => acntgSeMngService.findById(acntgSeCd!), + enabled: Boolean(acntgSeCd), + }); +} + +export function useSaveMultiAcntgSeMng() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (body: AcntgSeMngSaveRequestDto[]) => acntgSeMngService.saveMulti(body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: acntgSeMngBaseKey }); + }, + }); +} + +export function useUpdateAcntgSeMng() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ acntgSeCd, body }: { acntgSeCd: string; body: AcntgSeMngPutRequestDto }) => + acntgSeMngService.update(acntgSeCd, body), + onSuccess: (_data, { acntgSeCd }) => { + queryClient.invalidateQueries({ queryKey: acntgSeMngBaseKey }); + queryClient.invalidateQueries({ + queryKey: [...acntgSeMngBaseKey, "findById", acntgSeCd], + }); + }, + }); +} + +export function useDeleteAcntgSeMng() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (acntgSeCd: string) => acntgSeMngService.delete(acntgSeCd), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: acntgSeMngBaseKey }); + }, + }); +} diff --git a/apps/as/src/entities/acntg/index.ts b/apps/as/src/entities/acntg/index.ts new file mode 100644 index 000000000..bd5083917 --- /dev/null +++ b/apps/as/src/entities/acntg/index.ts @@ -0,0 +1,19 @@ +export { acntgSeMngService } from "./api/acntg-service"; +export { + useAcntgSeMng, + useAcntgSeMngPage, + useAcntgSeMngTree, + useDeleteAcntgSeMng, + useSaveMultiAcntgSeMng, + useUpdateAcntgSeMng, +} from "./api/queries"; +export type { + AcntgSeMngResponseDto, + AcntgSeMngSaveRequestDto, + AcntgSeMngSearchDto, + AcntgSeMngService, + AcntgSeMngTreeDto, + AcntgSeSaveMode, + BizCstTreeDto, +} from "./model/acntg-interface"; +export { ACNTG_SE_SAVE_MODE } from "./model/acntg-interface"; diff --git a/apps/as/src/entities/acntg/model/acntg-interface.ts b/apps/as/src/entities/acntg/model/acntg-interface.ts new file mode 100644 index 000000000..ea64fc218 --- /dev/null +++ b/apps/as/src/entities/acntg/model/acntg-interface.ts @@ -0,0 +1,88 @@ +import type { PageableParams, PageResponse } from "@/shared/model"; + +export const ACNTG_SE_SAVE_MODE = { + CREATE: "C", + UPDATE: "U", + DELETE: "D", +} as const; + +export type AcntgSeSaveMode = (typeof ACNTG_SE_SAVE_MODE)[keyof typeof ACNTG_SE_SAVE_MODE]; + +/** 백엔드 AcntgSeMngResponseDto */ +export interface AcntgSeMngResponseDto { + acntgSeCd: string; + acntgSeCdNm: string; + upAcntgSeCd?: string; + cdLvNo: number; + useYn: string; + expln?: string; + sortSeq?: number; + rgtr?: string; + rgtrNm?: string; + mdfr?: string; + regDt?: string; + mdfcnDt?: string; +} + +/** 백엔드 AcntgSeMngSearchDto + Pageable */ +export interface AcntgSeMngSearchDto extends PageableParams { + acntgSeCd?: string; + acntgSeCdNm?: string; + useYn?: string; +} + +/** 백엔드 AcntgSeMngSaveRequestDto */ +export interface AcntgSeMngSaveRequestDto { + acntgSeCd: string; + mode: AcntgSeSaveMode; + acntgSeCdNm: string; + upAcntgSeCd?: string; + cdLvNo: number; + useYn?: string; + expln?: string; + sortSeq?: number; +} + +/** PUT /{acntgSeCd} 요청 (mode는 서비스에서 U로 설정) */ +export interface AcntgSeMngPutRequestDto { + acntgSeCd: string; + acntgSeCdNm: string; + upAcntgSeCd?: string; + cdLvNo: number; + useYn?: string; + expln?: string; +} + +/** 백엔드 AcntgSeMngTreeDto */ +export interface AcntgSeMngTreeDto { + indAcntgSeCdNm: string; + acntgSeCd: string; + acntgSeCdNm: string; + upAcntgSeCd?: string; + useYn: string; + treeLevel: number; + treePath: string; +} + +/** 백엔드 BizCstTreeDto (InstlSncnMngDetResponseDto 등에서 참조) */ +export interface BizCstTreeDto { + indAcntgSeCdNm: string; + acntgSeCd: string; + acntgSeCdNm: string; + useYn: string; + treeLevel: number; + treePath: string; + bscPlanAmt?: number; + bgtRfltAmt?: number; + tstmDesignAmt?: number; +} + +/** Controller public 메서드명·시그니처와 동일 */ +export interface AcntgSeMngService { + findAll(params: AcntgSeMngSearchDto): Promise>; + findTree(): Promise; + findById(acntgSeCd: string): Promise; + saveMulti(body: AcntgSeMngSaveRequestDto[]): Promise; + update(acntgSeCd: string, body: AcntgSeMngPutRequestDto): Promise; + delete(acntgSeCd: string): Promise; +} diff --git a/apps/as/src/entities/adstdstr/api/adstdstr-service.ts b/apps/as/src/entities/adstdstr/api/adstdstr-service.ts new file mode 100644 index 000000000..b4bb11fae --- /dev/null +++ b/apps/as/src/entities/adstdstr/api/adstdstr-service.ts @@ -0,0 +1,26 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; + +import type { + CmCdAdstDstrComboResponseDto, + CmCdAdstDstrService, +} from "../model/adstdstr-interface"; + +const BASE = ENDPOINT.AS_SERVICE.ADSTDSTR; + +async function findSidoCombo(): Promise { + const response = await client.get(`${BASE}/sido`); + return response.data; +} + +async function findGugunCombo(sido: string): Promise { + const response = await client.get(`${BASE}/gugun`, { + params: { sido }, + }); + return response.data; +} + +export const cmCdAdstDstrService = { + findSidoCombo, + findGugunCombo, +} satisfies CmCdAdstDstrService; diff --git a/apps/as/src/entities/adstdstr/api/queries.ts b/apps/as/src/entities/adstdstr/api/queries.ts new file mode 100644 index 000000000..353e25cfe --- /dev/null +++ b/apps/as/src/entities/adstdstr/api/queries.ts @@ -0,0 +1,22 @@ +import { useQuery } from "@tanstack/react-query"; + +import { ENDPOINT } from "@/shared/config"; + +import { cmCdAdstDstrService } from "./adstdstr-service"; + +const cmCdAdstDstrBaseKey = [ENDPOINT.AS_SERVICE.ADSTDSTR] as const; + +export function useFindSidoCombo() { + return useQuery({ + queryKey: [...cmCdAdstDstrBaseKey, "findSidoCombo"], + queryFn: () => cmCdAdstDstrService.findSidoCombo(), + }); +} + +export function useFindGugunCombo(sido: string) { + return useQuery({ + queryKey: [...cmCdAdstDstrBaseKey, "findGugunCombo", sido], + queryFn: () => cmCdAdstDstrService.findGugunCombo(sido), + enabled: Boolean(sido), + }); +} diff --git a/apps/as/src/entities/adstdstr/index.ts b/apps/as/src/entities/adstdstr/index.ts new file mode 100644 index 000000000..da1dbd132 --- /dev/null +++ b/apps/as/src/entities/adstdstr/index.ts @@ -0,0 +1,6 @@ +export { cmCdAdstDstrService } from "./api/adstdstr-service"; +export { useFindGugunCombo, useFindSidoCombo } from "./api/queries"; +export type { + CmCdAdstDstrComboResponseDto, + CmCdAdstDstrService, +} from "./model/adstdstr-interface"; diff --git a/apps/as/src/entities/adstdstr/model/adstdstr-interface.ts b/apps/as/src/entities/adstdstr/model/adstdstr-interface.ts new file mode 100644 index 000000000..72cb673dc --- /dev/null +++ b/apps/as/src/entities/adstdstr/model/adstdstr-interface.ts @@ -0,0 +1,11 @@ +/** 백엔드 CmCdAdstDstrComboResponseDto */ +export interface CmCdAdstDstrComboResponseDto { + adstDstrCd: string; + adstDstrNm: string; +} + +/** Controller public 메서드명·시그니처와 동일 */ +export interface CmCdAdstDstrService { + findSidoCombo(): Promise; + findGugunCombo(sido: string): Promise; +} diff --git a/apps/as/src/entities/attach/api/attach-service.ts b/apps/as/src/entities/attach/api/attach-service.ts new file mode 100644 index 000000000..0af0a916d --- /dev/null +++ b/apps/as/src/entities/attach/api/attach-service.ts @@ -0,0 +1,22 @@ +import { AxiosResponse } from "axios"; + +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; + +import type { AttachService } from "../model/attach-interface"; + +async function deleteAttachmentByAtflId(atflId: string): Promise { + if (!atflId) return; + await client.delete(`${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}/${atflId}`); +} + +async function downloadAttachmentByAtflCd(atflCd: string): Promise { + return await client.get(`${ENDPOINT.ATTACH_SERVICE.DOWNLOAD}/${atflCd}`, { + responseType: "blob", + }); +} + +export const attachService = { + deleteAttachmentByAtflId, + downloadAttachmentByAtflCd, +} satisfies AttachService; diff --git a/apps/as/src/entities/attach/api/queries.ts b/apps/as/src/entities/attach/api/queries.ts new file mode 100644 index 000000000..ed9efefbf --- /dev/null +++ b/apps/as/src/entities/attach/api/queries.ts @@ -0,0 +1,39 @@ +import { useMutation } from "@tanstack/react-query"; + +import { ENDPOINT } from "@/shared/config"; + +import { parseFilenameFromResponse } from "../lib/parse-filename-from-response"; + +import { attachService } from "./attach-service"; + +const attachBaseKey = [ENDPOINT.ATTACH_SERVICE.ATTACHMENTS] as const; +const attachDownloadBaseKey = [ENDPOINT.ATTACH_SERVICE.DOWNLOAD] as const; + +export function useDeleteAttachment() { + return useMutation({ + mutationKey: [...attachBaseKey, "delete"], + mutationFn: (atflId: string) => attachService.deleteAttachmentByAtflId(atflId), + }); +} + +export function useDownloadAttachmentByAtflCd() { + return useMutation({ + mutationKey: [...attachDownloadBaseKey, "download"], + mutationFn: async (atflCd: string) => { + const response = await attachService.downloadAttachmentByAtflCd(atflCd); + const filename = parseFilenameFromResponse(response); + + const blob = new Blob([response.data]); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + + window.URL.revokeObjectURL(url); + document.body.removeChild(link); + }, + }); +} diff --git a/apps/as/src/entities/attach/index.ts b/apps/as/src/entities/attach/index.ts new file mode 100644 index 000000000..37258e6e5 --- /dev/null +++ b/apps/as/src/entities/attach/index.ts @@ -0,0 +1,3 @@ +export { attachService } from "./api/attach-service"; +export { useDeleteAttachment, useDownloadAttachmentByAtflCd } from "./api/queries"; +export type { Attach, AttachService } from "./model/attach-interface"; diff --git a/apps/as/src/entities/attach/lib/parse-filename-from-response.ts b/apps/as/src/entities/attach/lib/parse-filename-from-response.ts new file mode 100644 index 000000000..cd8a5aa3d --- /dev/null +++ b/apps/as/src/entities/attach/lib/parse-filename-from-response.ts @@ -0,0 +1,11 @@ +import type { AxiosResponse } from "axios"; + +export function parseFilenameFromResponse(response: AxiosResponse): string { + const contentDisposition = response.headers["content-disposition"]; + if (!contentDisposition) return "download"; + + const filenameMatch = contentDisposition.match(/filename\*=UTF-8''([^;]+)/); + if (!filenameMatch?.[1]) return "download"; + + return decodeURIComponent(filenameMatch[1]); +} diff --git a/apps/as/src/entities/attach/model/attach-interface.ts b/apps/as/src/entities/attach/model/attach-interface.ts new file mode 100644 index 000000000..d6b0a08d2 --- /dev/null +++ b/apps/as/src/entities/attach/model/attach-interface.ts @@ -0,0 +1,22 @@ +import { AxiosResponse } from "axios"; + +/** 백엔드 AtflMngResponseDto */ +export interface Attach { + atflCd: string; + atflSn: number; + atflId: string; + physFileNm: string; + orgnlFileNm: string; + atflFlieSz: number; + atflType: string; + dwnldNocs: number; + delYn: string; + lnkgDmnId: string; + lnkgDmnNm: string; + regDt: string; +} + +export interface AttachService { + deleteAttachmentByAtflId: (atflId: string) => Promise; + downloadAttachmentByAtflCd: (atflCd: string) => Promise; +} diff --git a/apps/as/src/entities/auth/api/auth-service.ts b/apps/as/src/entities/auth/api/auth-service.ts new file mode 100644 index 000000000..5615f55f8 --- /dev/null +++ b/apps/as/src/entities/auth/api/auth-service.ts @@ -0,0 +1,88 @@ +import { ACCESS_TOKEN, AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "@/shared/config"; +import { type AuthPayload, type AuthService, getRemainingMs } from "@common/auth"; + +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", + "X-Device-Id": getOrCreateDeviceId(), + }, + body: JSON.stringify(payload), + }); + + const data = await response.json(); + + const accessToken = data[ACCESS_TOKEN]; + const tokenId = data[AUTH_USER_ID]; + + if (!accessToken || !tokenId) { + throw new Error("인증 토큰을 받지 못했습니다."); + } + + setToken(CLAIM_NAME, accessToken); + setToken(AUTH_USER_ID, tokenId); + + const remainingMs = getRemainingMs(accessToken); + if (remainingMs > 0) { + setupRefreshTimer(silentRefresh, remainingMs); + } +} + +async function silentRefresh(): Promise { + console.log(`[silentRefresh] Called at ${new Date().toISOString()}`); + try { + const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`, { + method: "PUT", + headers: { + "X-Device-Id": getOrCreateDeviceId(), + }, + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data?.message ?? "토큰 갱신에 실패했습니다."); + } + + const accessToken = data[ACCESS_TOKEN]; + const tokenId = data[AUTH_USER_ID]; + + if (!accessToken || !tokenId) { + throw new Error("갱신된 인증 토큰을 받지 못했습니다."); + } + + setToken(CLAIM_NAME, accessToken); + setToken(AUTH_USER_ID, tokenId); + + const remainingMs = getRemainingMs(accessToken); + if (remainingMs > 0) { + setupRefreshTimer(silentRefresh, remainingMs); + } + } catch (error) { + console.error(`[silentRefresh] Failed at ${new Date().toISOString()}`, error); + tokenManager.clearAuthToken(); + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("auth:logout")); + } + } +} + +async function logout(): Promise { + tokenManager.clearAuthToken(); + clearDeviceId(); + + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("auth:logout")); + } +} + +export const authService: AuthService = { + login, + silentRefresh, + declineSessionExtend: clearRefreshTimer, + logout, +}; diff --git a/apps/as/src/entities/auth/api/login-service.ts b/apps/as/src/entities/auth/api/login-service.ts deleted file mode 100644 index 2dc784e97..000000000 --- a/apps/as/src/entities/auth/api/login-service.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { AxiosError } from "axios"; - -import { - ACCESS_TOKEN, - AUTH_USER_ID, - CLAIM_NAME, - ENDPOINT, - JWT_EXPIRATION_TIME, -} from "@/shared/config"; - -import { tokenManager } from "../lib/token-manager"; -import type { ILogin, LoginService } from "../model/login-interface"; - -export class LoginApiService implements LoginService { - private static instance: LoginApiService; - - private constructor() {} - - public static getInstance(): LoginApiService { - if (!LoginApiService.instance) { - LoginApiService.instance = new LoginApiService(); - } - return LoginApiService.instance; - } - - public async login(payload: ILogin): Promise { - try { - const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, { - method: "POST", - body: JSON.stringify({ - userId: payload.userId, - password: payload.password, - }), - }); - - const data = await response.json(); - console.log("data ", data); - const accessToken = data[ACCESS_TOKEN]; - const tokenId = data[AUTH_USER_ID]; - - if (!accessToken || !tokenId) { - if (data.message == "Invalid Credentials") { - throw new Error("아이디와 비밀번호가 일치하지 않습니다."); - } else if (data.message == "User not found") { - throw new Error("존재하지 않는 아이디입니다."); - } else if (data.message == "User is not active") { - throw new Error("활성화되지 않은 계정입니다."); - } else if (data.message == "User is not verified") { - throw new Error("이메일 인증이 필요합니다."); - } else { - throw new Error(data.message); - } - } - - tokenManager.setToken(CLAIM_NAME, accessToken); - tokenManager.setToken(AUTH_USER_ID, tokenId); - tokenManager.setupRefreshTimer(() => this.silentRefresh(), JWT_EXPIRATION_TIME); - } catch (error) { - if (error instanceof AxiosError) { - throw new Error(`Login 실패 ${error.response?.data?.message}`); - } - throw new Error(`Login 실패 ${error}`); - } - } - - public async silentRefresh(): Promise { - try { - const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`, { - method: "PUT", - }); - - const data = await response.json(); - const accessToken = data[ACCESS_TOKEN]; - const tokenId = data[AUTH_USER_ID]; - - if (!accessToken || !tokenId) { - throw new Error("Missing required authentication tokens"); - } - - tokenManager.setToken(CLAIM_NAME, accessToken); - tokenManager.setToken(AUTH_USER_ID, tokenId); - } catch (error) { - tokenManager.clearAuthToken(); - if (error instanceof AxiosError) { - throw new Error(`Silent refresh failed: ${error.response?.data?.message}`); - } - throw new Error(`Silent refresh failed: ${error}`); - } - } -} - -export const LoginApi = LoginApiService.getInstance(); diff --git a/apps/as/src/entities/auth/index.ts b/apps/as/src/entities/auth/index.ts index 97e142225..7f0d0081a 100644 --- a/apps/as/src/entities/auth/index.ts +++ b/apps/as/src/entities/auth/index.ts @@ -1,2 +1,3 @@ -export { LoginApi } from "./api/login-service"; +export { authService } from "./api/auth-service"; export { AuthWrapper } from "./lib/auth-wrapper"; +export { tokenManager } from "./lib/token-manager"; diff --git a/apps/as/src/entities/auth/lib/auth-wrapper.tsx b/apps/as/src/entities/auth/lib/auth-wrapper.tsx index e92957163..ef454dfb9 100644 --- a/apps/as/src/entities/auth/lib/auth-wrapper.tsx +++ b/apps/as/src/entities/auth/lib/auth-wrapper.tsx @@ -1,42 +1,71 @@ "use client"; import { useEffect } from "react"; +import { useRouter } from "next/navigation"; + +import { isAxiosError } from "axios"; import { client } from "@/shared/api"; -import { AUTH_LOGIN_REDIRECT_URL, AUTH_USER_ID, ENDPOINT } from "@/shared/config"; -import { getCookie, useUserStore } from "@/shared/lib"; +import { AUTH_LOGIN_REDIRECT_URL, AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "@/shared/config"; +import { useUserStore } from "@/shared/lib"; import type { IUserInfo } from "@/shared/store"; +import { getRemainingMs } from "@common/auth"; import { isAtLoginRedirectUrl } from "@common/network"; +import { authService } from "../api/auth-service"; + +import { getToken, setupRefreshTimer } from "./token-manager"; + const AuthWrapper = ({ children }: { children: React.ReactNode }) => { - const { userInfo, setUserInfo } = useUserStore((state) => state); - - async function authCheck() { - if (isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) { - return; - } - - const userId = getCookie(AUTH_USER_ID); - if (!userId) { - throw new Error("Missing required authentication tokens"); - } - - const response = await client.get(`${ENDPOINT.USER_SERVICE.USERS}/info/me`); - if (!userInfo) setUserInfo(response.data); - } + const router = useRouter(); + const userInfo = useUserStore((state) => state.userInfo); + const setUserInfo = useUserStore((state) => state.setUserInfo); useEffect(() => { - (async () => { - try { - await authCheck(); - } catch (error) { - console.error(error); - setUserInfo(null); - if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) { - window.location.assign(AUTH_LOGIN_REDIRECT_URL); - } + const token = getToken(CLAIM_NAME); + if (token) { + const remainingMs = getRemainingMs(token); + if (remainingMs > 0) { + console.log(`[AuthWrapper] Refresh timer set: ${remainingMs}ms (${remainingMs / 1000}s)`); + setupRefreshTimer(authService.silentRefresh, remainingMs); } - })(); + } + + if (isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) return; + + async function authCheck() { + const userId = getToken(AUTH_USER_ID); + if (!userId) { + router.replace(AUTH_LOGIN_REDIRECT_URL); + return; + } + + if (userInfo) return; + + const response = await client.get(`${ENDPOINT.USER_SERVICE.USERS}/info/me`); + setUserInfo(response.data); + } + + authCheck().catch((error) => { + if (isAxiosError(error) && error.response?.status === 401) return; + console.error(error); + setUserInfo(null); + if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) { + router.replace(AUTH_LOGIN_REDIRECT_URL); + } + }); + }, []); + + useEffect(() => { + function handleAuthLogout() { + setUserInfo(null); + if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) { + router.replace(AUTH_LOGIN_REDIRECT_URL); + } + } + + window.addEventListener("auth:logout", handleAuthLogout); + return () => window.removeEventListener("auth:logout", handleAuthLogout); }, []); return <>{children}; 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 c5eeffc3a..78d24bd5e 100644 --- a/apps/as/src/entities/auth/lib/token-manager.ts +++ b/apps/as/src/entities/auth/lib/token-manager.ts @@ -1,82 +1,15 @@ -import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config"; -import { getCookie, removeCookie,setCookie } from "@/shared/lib"; +import { authConfig } from "@/shared/config/auth"; +import { createTokenManager } from "@common/auth"; -export class TokenManager { - private static instance: TokenManager; - private refreshTimer: NodeJS.Timeout | null = null; +export const tokenManager = createTokenManager(authConfig); - private constructor() {} - - public static getInstance(): TokenManager { - if (!TokenManager.instance) { - TokenManager.instance = new TokenManager(); - } - return TokenManager.instance; - } - - public getToken(key: string): string | undefined { - return getCookie(key); - } - - public setToken(key: string, value: string): void { - const domain = this.getCookieDomain(); - - setCookie(key, value, { - secure: window.location.protocol === "https:", - sameSite: "lax", - domain: domain, - path: "/", // 모든 경로에서 접근 가능 - }); - } - - - private getCookieDomain(): string | undefined { - const hostname = window.location.hostname; - - // 개발 환경에서는 localhost 도메인 사용 - if (hostname === "localhost" || hostname === "127.0.0.1") { - return undefined; // localhost에서는 도메인 설정하지 않음 - } - - // 운영 환경에서는 서브도메인을 포함한 최상위 도메인 사용 - if (hostname.includes("hasuon.or.kr")) { - return ".hasuon.or.kr"; - } - - // 기타 환경에서는 현재 호스트명 사용 - return hostname; - } - - public removeToken(key: string): void { - const domain = this.getCookieDomain(); - removeCookie(key, { domain, path: "/" }); - } - - public setupRefreshTimer( - callback: () => Promise, - expirationTime: number, - ): void { - if (this.refreshTimer) { - clearTimeout(this.refreshTimer); - } - - this.refreshTimer = setTimeout(() => { - callback().catch((error) => { - console.error("Silent refresh failed:", error); - this.clearAuthToken(); - }); - }, expirationTime - 60000); - } - - public clearAuthToken(): void { - if (this.refreshTimer) { - clearTimeout(this.refreshTimer); - this.refreshTimer = null; - } - - this.removeToken(CLAIM_NAME); - this.removeToken(AUTH_USER_ID); - } -} - -export const tokenManager = TokenManager.getInstance(); +export const { + getToken, + setToken, + removeToken, + setupRefreshTimer, + clearRefreshTimer, + getAccessToken, + getRefreshToken, + getUserId, +} = tokenManager; diff --git a/apps/as/src/entities/auth/model/login-interface.ts b/apps/as/src/entities/auth/model/login-interface.ts deleted file mode 100644 index 5aaf53044..000000000 --- a/apps/as/src/entities/auth/model/login-interface.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface ILogin { - userId: string; - password: string; -} - -export interface LoginService { - login(payload: ILogin): Promise; - silentRefresh(): Promise; -} diff --git a/apps/as/src/entities/bns-if/api/bns-if-service.ts b/apps/as/src/entities/bns-if/api/bns-if-service.ts new file mode 100644 index 000000000..320a01efc --- /dev/null +++ b/apps/as/src/entities/bns-if/api/bns-if-service.ts @@ -0,0 +1,47 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; + +import { buildBnsSearchDto } from "../lib/build-bns-search-dto"; +import type { + BnsDetailResponseWrapperDto, + BnsIfService, + BnsListResponseWrapperDto, + BnsSearchDto, + EmCodeListResponseDto, +} from "../model/bns-if-interface"; + +const BASE = ENDPOINT.AS_SERVICE.BNS_IF; + +async function getBnsList(params: BnsSearchDto): Promise { + const response = await client.get(`${BASE}/bns`, { + params: buildBnsSearchDto(params), + }); + return response.data; +} + +async function getBnsDetail( + gbn: string, + yr: string, + seqNo: string, +): Promise { + const response = await client.get(`${BASE}/bns/detail`, { + params: { gbn, yr, seqNo }, + }); + return response.data; +} + +async function getEmCodeList( + dpmCd?: string, + lclgvrnmntCd?: string, +): Promise { + const response = await client.get(`${BASE}/emCodeList`, { + params: { dpmCd, lclgvrnmntCd }, + }); + return response.data; +} + +export const bnsIfService = { + getBnsList, + getBnsDetail, + getEmCodeList, +} satisfies BnsIfService; diff --git a/apps/as/src/entities/bns-if/api/queries.ts b/apps/as/src/entities/bns-if/api/queries.ts new file mode 100644 index 000000000..7ab0b46a3 --- /dev/null +++ b/apps/as/src/entities/bns-if/api/queries.ts @@ -0,0 +1,33 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query"; + +import { ENDPOINT } from "@/shared/config"; + +import type { BnsSearchDto } from "../model/bns-if-interface"; + +import { bnsIfService } from "./bns-if-service"; + +const bnsIfBaseKey = [ENDPOINT.AS_SERVICE.BNS_IF] as const; + +export function useBnsList(params: BnsSearchDto, options?: { enabled?: boolean }) { + return useQuery({ + queryKey: [...bnsIfBaseKey, "getBnsList", params], + queryFn: () => bnsIfService.getBnsList(params), + enabled: options?.enabled, + placeholderData: keepPreviousData, + }); +} + +export function useBnsDetail(gbn: string, yr: string, seqNo: string) { + return useQuery({ + queryKey: [...bnsIfBaseKey, "getBnsDetail", gbn, yr, seqNo], + queryFn: () => bnsIfService.getBnsDetail(gbn, yr, seqNo), + enabled: Boolean(gbn && yr && seqNo), + }); +} + +export function useEmCodeList(dpmCd?: string, lclgvrnmntCd?: string) { + return useQuery({ + queryKey: [...bnsIfBaseKey, "getEmCodeList", dpmCd, lclgvrnmntCd], + queryFn: () => bnsIfService.getEmCodeList(dpmCd, lclgvrnmntCd), + }); +} diff --git a/apps/as/src/entities/bns-if/index.ts b/apps/as/src/entities/bns-if/index.ts new file mode 100644 index 000000000..430f4a611 --- /dev/null +++ b/apps/as/src/entities/bns-if/index.ts @@ -0,0 +1,13 @@ +export { bnsIfService } from "./api/bns-if-service"; +export { useBnsDetail, useBnsList, useEmCodeList } from "./api/queries"; +export { buildBnsSearchDto } from "./lib/build-bns-search-dto"; +export type { + BnsDetailResponseDto, + BnsDetailResponseWrapperDto, + BnsIfService, + BnsListResponseDto, + BnsListResponseWrapperDto, + BnsSearchDto, + EmCodeItemDto, + EmCodeListResponseDto, +} from "./model/bns-if-interface"; diff --git a/apps/as/src/entities/bns-if/lib/build-bns-search-dto.ts b/apps/as/src/entities/bns-if/lib/build-bns-search-dto.ts new file mode 100644 index 000000000..8e95b27dd --- /dev/null +++ b/apps/as/src/entities/bns-if/lib/build-bns-search-dto.ts @@ -0,0 +1,20 @@ +import type { BnsSearchDto } from "../model/bns-if-interface"; + +export function buildBnsSearchDto(input: { + cPage: number; + searchStartYear?: string; + searchSido?: string; + searchGugun?: string; +}): BnsSearchDto { + const dto: BnsSearchDto = { cPage: input.cPage }; + + const year = input.searchStartYear?.trim(); + const sido = input.searchSido?.trim(); + const gugun = input.searchGugun?.trim(); + + if (year) dto.searchStartYear = year; + if (sido) dto.searchSido = sido; + if (gugun) dto.searchGugun = gugun; + + return dto; +} diff --git a/apps/as/src/entities/bns-if/model/bns-if-interface.ts b/apps/as/src/entities/bns-if/model/bns-if-interface.ts new file mode 100644 index 000000000..e13ab8500 --- /dev/null +++ b/apps/as/src/entities/bns-if/model/bns-if-interface.ts @@ -0,0 +1,125 @@ +/** 백엔드 BnsSearchDto */ +export interface BnsSearchDto { + searchStartYear?: string; + cPage: number; + searchSido?: string; + searchGugun?: string; +} + +/** 백엔드 BnsListResponseWrapperDto */ +export interface BnsListResponseWrapperDto { + list: BnsListResponseDto[]; + cnt: number; +} + +/** 백엔드 BnsListResponseDto */ +export interface BnsListResponseDto { + cPage: number; + gbn: number; + chk: number; + seqNo: string; + rgnNm: string; + sido: string; + gugun: string; + bzCd: string; + bzNm: string; + yr: string; + strtYr: string; + edYr: string; + totBzamt: string; +} + +/** 백엔드 BnsDetailResponseWrapperDto */ +export interface BnsDetailResponseWrapperDto { + saup: unknown; + detail: BnsDetailResponseDto; +} + +/** 백엔드 BnsDetailResponseDto */ +export interface BnsDetailResponseDto { + cPage: number; + seqNo: string; + confBzCd: string; + chngDgr: string; + yr: string; + sido: string; + gugun: string; + rvbsnNm: string; + lgbsnNm: string; + mdmbsnNm: string; + lclgvrnmntNm: string; + accKdNm: string; + bzKdNm: string; + untBzNm: string; + dtlBzNm: string; + mevBzNm: string; + ddtlBzNm: string; + strtYr: string; + edYr: string; + aidRt: string; + bzStatus: string; + bzCd: string; + bzNm: string; + bzLoc: string; + efflWqltStndrdNm: string; + goalYear: string; + bssProm: string; + uppPlan: string; + techInsp: string; + bssCont: string; + rmk: string; + inflBscFctNm: string; + inflBscFctCd: string; + trtMtd: string; + swrExtns: string; + swrChng: string; + swrRnvt: string; + totFamNum: string; + pumpCnt: string; + rvWbsn: string; + rvText: string; + imprvExpctFlw: string; + imprvExpctWqlt: string; + trtplntPlEfflWqlt: string; + wqltAvg: string; + wqltHgh: string; + wqltLow: string; + stdCost: string; + bzEtc: string; + bscFctCd: string; + bscFctNm: string; + fctCpc: string; + fctCpcAfr: string; + trtDstrArea: string; + trtDstrAreaAfr: string; + swrTrtPopl: string; + swrTrtPoplAfr: string; + adstPopl: string; +} + +/** 백엔드 EmCodeItemDto */ +export interface EmCodeItemDto { + code: string; + data: string; + upCode: string; +} + +/** 백엔드 EmCodeListResponseDto */ +export interface EmCodeListResponseDto { + rvbsnList: EmCodeItemDto[]; + lgbsnList: EmCodeItemDto[]; + mdmbsnList: EmCodeItemDto[]; + accList: EmCodeItemDto[]; + bzList: EmCodeItemDto[]; + untList: EmCodeItemDto[]; + dtlList: EmCodeItemDto[]; + mevList: EmCodeItemDto[]; + ddtlList: EmCodeItemDto[]; +} + +/** Controller public 메서드명·시그니처와 동일 */ +export interface BnsIfService { + getBnsList(params: BnsSearchDto): Promise; + getBnsDetail(gbn: string, yr: string, seqNo: string): Promise; + getEmCodeList(dpmCd?: string, lclgvrnmntCd?: string): Promise; +} diff --git a/apps/as/src/entities/cklst/api/cklst-artcl-service.ts b/apps/as/src/entities/cklst/api/cklst-artcl-service.ts new file mode 100644 index 000000000..9561a659c --- /dev/null +++ b/apps/as/src/entities/cklst/api/cklst-artcl-service.ts @@ -0,0 +1,57 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { PageResponse } from "@/shared/model"; + +import type { + CklstArtcl, + CklstArtclId, + CklstArtclSaveRequest, + CklstArtclSearchParams, + CklstArtclService, + CklstArtclUpdateRequest, +} from "../model/cklst-artcl-interface"; + +const BASE = ENDPOINT.AS_SERVICE.CKLST_ARTCL; + +async function findPage(params: CklstArtclSearchParams): Promise> { + const response = await client.get>(BASE, { params }); + return response.data; +} + +async function findByCklstSn(cklstSn: number): Promise { + const response = await client.get(`${BASE}/by-cklst/${cklstSn}`); + return response.data; +} + +async function findById(cklstArtclSn: number, cklstSn: number): Promise { + const response = await client.get(`${BASE}/${cklstArtclSn}/${cklstSn}`); + return response.data; +} + +async function create(body: CklstArtclSaveRequest): Promise { + const response = await client.post(BASE, body); + return response.data; +} + +async function update( + cklstArtclSn: number, + cklstSn: number, + body: CklstArtclUpdateRequest, +): Promise { + const response = await client.put(`${BASE}/${cklstArtclSn}/${cklstSn}`, body); + return response.data; +} + +async function remove(cklstArtclSn: number, cklstSn: number): Promise { + const response = await client.delete(`${BASE}/${cklstArtclSn}/${cklstSn}`); + return response.data; +} + +export const cklstArtclService = { + findPage, + findByCklstSn, + findById, + create, + update, + remove, +} satisfies CklstArtclService; diff --git a/apps/as/src/entities/cklst/api/cklst-mng-service.ts b/apps/as/src/entities/cklst/api/cklst-mng-service.ts new file mode 100644 index 000000000..8ad2e0556 --- /dev/null +++ b/apps/as/src/entities/cklst/api/cklst-mng-service.ts @@ -0,0 +1,57 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { PageResponse } from "@/shared/model"; + +import type { + CklstMng, + CklstMngSaveRequest, + CklstMngSearchParams, + CklstMngService, + CklstTreeNode, + CklstTreeSearchParams, +} from "../model/cklst-mng-interface"; + +const BASE = ENDPOINT.AS_SERVICE.CKLST; + +async function findPage(params: CklstMngSearchParams): Promise> { + const response = await client.get>(BASE, { + params, + }); + return response.data; +} + +async function findById(cklstSn: number): Promise { + const response = await client.get(`${BASE}/${cklstSn}`); + return response.data; +} + +async function findTree(params: CklstTreeSearchParams): Promise { + const response = await client.get(`${BASE}/list/tree`, { + params, + }); + return response.data; +} + +async function create(body: CklstMngSaveRequest): Promise { + const response = await client.post(BASE, body); + return response.data; +} + +async function update(cklstSn: number, body: CklstMngSaveRequest): Promise { + const response = await client.put(`${BASE}/${cklstSn}`, body); + return response.data; +} + +async function remove(cklstSn: number): Promise { + const response = await client.delete(`${BASE}/${cklstSn}`); + return response.data; +} + +export const cklstMngService = { + findPage, + findById, + findTree, + create, + update, + remove, +} satisfies CklstMngService; diff --git a/apps/as/src/entities/cklst/api/cklst-rvw-service.ts b/apps/as/src/entities/cklst/api/cklst-rvw-service.ts new file mode 100644 index 000000000..e184d45aa --- /dev/null +++ b/apps/as/src/entities/cklst/api/cklst-rvw-service.ts @@ -0,0 +1,71 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { PageResponse } from "@/shared/model"; + +import type { + CklstRvw, + CklstRvwId, + CklstRvwSaveRequest, + CklstRvwSearchParams, + CklstRvwService, + CklstRvwUpdateRequest, +} from "../model/cklst-rvw-interface"; + +const BASE = ENDPOINT.AS_SERVICE.CKLST_RVW; + +async function findPage(params: CklstRvwSearchParams): Promise> { + const response = await client.get>(BASE, { params }); + return response.data; +} + +async function findByArtcl(cklstSn: number, cklstArtclSn: number): Promise { + const response = await client.get(`${BASE}/by-artcl/${cklstSn}/${cklstArtclSn}`); + return response.data; +} + +async function findById( + cklstRvwSn: number, + cklstArtclSn: number, + cklstSn: number, +): Promise { + const response = await client.get(`${BASE}/${cklstRvwSn}/${cklstArtclSn}/${cklstSn}`); + return response.data; +} + +async function create(body: CklstRvwSaveRequest): Promise { + const response = await client.post(BASE, body); + return response.data; +} + +async function update( + cklstRvwSn: number, + cklstArtclSn: number, + cklstSn: number, + body: CklstRvwUpdateRequest, +): Promise { + const response = await client.put( + `${BASE}/${cklstRvwSn}/${cklstArtclSn}/${cklstSn}`, + body, + ); + return response.data; +} + +async function remove( + cklstRvwSn: number, + cklstArtclSn: number, + cklstSn: number, +): Promise { + const response = await client.delete( + `${BASE}/${cklstRvwSn}/${cklstArtclSn}/${cklstSn}`, + ); + return response.data; +} + +export const cklstRvwService = { + findPage, + findByArtcl, + findById, + create, + update, + remove, +} satisfies CklstRvwService; diff --git a/apps/as/src/entities/cklst/api/queries.ts b/apps/as/src/entities/cklst/api/queries.ts new file mode 100644 index 000000000..afa3e02e3 --- /dev/null +++ b/apps/as/src/entities/cklst/api/queries.ts @@ -0,0 +1,281 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { ENDPOINT } from "@/shared/config"; + +import type { + CklstArtclSaveRequest, + CklstArtclSearchParams, + CklstArtclUpdateRequest, +} from "../model/cklst-artcl-interface"; +import type { CklstMngSaveRequest, CklstMngSearchParams } from "../model/cklst-mng-interface"; +import type { + CklstRvwSaveRequest, + CklstRvwSearchParams, + CklstRvwUpdateRequest, +} from "../model/cklst-rvw-interface"; + +import { cklstArtclService } from "./cklst-artcl-service"; +import { cklstMngService } from "./cklst-mng-service"; +import { cklstRvwService } from "./cklst-rvw-service"; + +const cklstMngBaseKey = [ENDPOINT.AS_SERVICE.CKLST] as const; +const cklstArtclBaseKey = [ENDPOINT.AS_SERVICE.CKLST_ARTCL] as const; +const cklstRvwBaseKey = [ENDPOINT.AS_SERVICE.CKLST_RVW] as const; + +export function useCklstMngPage(params: CklstMngSearchParams) { + return useQuery({ + queryKey: [...cklstMngBaseKey, "page", params], + queryFn: () => cklstMngService.findPage(params), + }); +} + +export function useCklstMng(cklstSn: number | null | undefined) { + return useQuery({ + queryKey: [...cklstMngBaseKey, cklstSn], + queryFn: () => cklstMngService.findById(cklstSn!), + enabled: cklstSn != null, + }); +} + +export function useCreateCklstMng() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (body: CklstMngSaveRequest) => cklstMngService.create(body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: cklstMngBaseKey }); + }, + }); +} + +export function useUpdateCklstMng() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ cklstSn, body }: { cklstSn: number; body: CklstMngSaveRequest }) => + cklstMngService.update(cklstSn, body), + onSuccess: (_data, { cklstSn }) => { + queryClient.invalidateQueries({ queryKey: cklstMngBaseKey }); + queryClient.invalidateQueries({ + queryKey: [...cklstMngBaseKey, cklstSn], + }); + }, + }); +} + +export function useDeleteCklstMng() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (cklstSn: number) => cklstMngService.remove(cklstSn), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: cklstMngBaseKey }); + }, + }); +} + +export function useCklstArtclPage(params: CklstArtclSearchParams) { + const { cklstSn } = params; + + return useQuery({ + queryKey: [...cklstArtclBaseKey, "page", params], + queryFn: () => cklstArtclService.findPage(params), + enabled: cklstSn != null, + }); +} + +export function useCklstArtclList(cklstSn: number | null | undefined) { + return useQuery({ + queryKey: [...cklstArtclBaseKey, "by-cklst", cklstSn], + queryFn: () => cklstArtclService.findByCklstSn(cklstSn!), + enabled: cklstSn != null, + }); +} + +export function useCklstArtcl(params: { + cklstArtclSn: number | null | undefined; + cklstSn: number | null | undefined; +}) { + const { cklstArtclSn, cklstSn } = params; + + return useQuery({ + queryKey: [...cklstArtclBaseKey, cklstArtclSn, cklstSn], + queryFn: () => cklstArtclService.findById(cklstArtclSn!, cklstSn!), + enabled: cklstArtclSn != null && cklstSn != null, + }); +} + +export function useCreateCklstArtcl() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (body: CklstArtclSaveRequest) => cklstArtclService.create(body), + onSuccess: (_data, { cklstSn }) => { + queryClient.invalidateQueries({ queryKey: cklstArtclBaseKey }); + queryClient.invalidateQueries({ + queryKey: [...cklstArtclBaseKey, "page", { cklstSn }], + }); + queryClient.invalidateQueries({ + queryKey: [...cklstArtclBaseKey, "by-cklst", cklstSn], + }); + }, + }); +} + +export function useUpdateCklstArtcl() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + cklstArtclSn, + cklstSn, + body, + }: { + cklstArtclSn: number; + cklstSn: number; + body: CklstArtclUpdateRequest; + }) => cklstArtclService.update(cklstArtclSn, cklstSn, body), + onSuccess: (_data, { cklstArtclSn, cklstSn }) => { + queryClient.invalidateQueries({ queryKey: cklstArtclBaseKey }); + queryClient.invalidateQueries({ + queryKey: [...cklstArtclBaseKey, cklstArtclSn, cklstSn], + }); + queryClient.invalidateQueries({ + queryKey: [...cklstArtclBaseKey, "page", { cklstSn }], + }); + queryClient.invalidateQueries({ + queryKey: [...cklstArtclBaseKey, "by-cklst", cklstSn], + }); + }, + }); +} + +export function useDeleteCklstArtcl() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + cklstArtclSn, + cklstSn, + }: { + cklstArtclSn: number; + cklstSn: number; + }) => cklstArtclService.remove(cklstArtclSn, cklstSn), + onSuccess: (_data, { cklstSn }) => { + queryClient.invalidateQueries({ queryKey: cklstArtclBaseKey }); + queryClient.invalidateQueries({ + queryKey: [...cklstArtclBaseKey, "page", { cklstSn }], + }); + queryClient.invalidateQueries({ + queryKey: [...cklstArtclBaseKey, "by-cklst", cklstSn], + }); + }, + }); +} + +export function useCklstRvwPage(params: CklstRvwSearchParams) { + const { cklstSn } = params; + + return useQuery({ + queryKey: [...cklstRvwBaseKey, "page", params], + queryFn: () => cklstRvwService.findPage(params), + enabled: cklstSn != null, + }); +} + +export function useCklstRvwByArtcl( + cklstSn: number | null | undefined, + cklstArtclSn: number | null | undefined, +) { + return useQuery({ + queryKey: [...cklstRvwBaseKey, "by-artcl", cklstSn, cklstArtclSn], + queryFn: () => cklstRvwService.findByArtcl(cklstSn!, cklstArtclSn!), + enabled: cklstSn != null && cklstArtclSn != null, + }); +} + +export function useCklstRvw(params: { + cklstRvwSn: number | null | undefined; + cklstArtclSn: number | null | undefined; + cklstSn: number | null | undefined; +}) { + const { cklstRvwSn, cklstArtclSn, cklstSn } = params; + + return useQuery({ + queryKey: [...cklstRvwBaseKey, cklstRvwSn, cklstArtclSn, cklstSn], + queryFn: () => cklstRvwService.findById(cklstRvwSn!, cklstArtclSn!, cklstSn!), + enabled: cklstRvwSn != null && cklstArtclSn != null && cklstSn != null, + }); +} + +export function useCreateCklstRvw() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (body: CklstRvwSaveRequest) => cklstRvwService.create(body), + onSuccess: (_data, { cklstSn, cklstArtclSn }) => { + queryClient.invalidateQueries({ queryKey: cklstRvwBaseKey }); + queryClient.invalidateQueries({ + queryKey: [...cklstRvwBaseKey, "page", { cklstSn }], + }); + queryClient.invalidateQueries({ + queryKey: [...cklstRvwBaseKey, "by-artcl", cklstSn, cklstArtclSn], + }); + }, + }); +} + +export function useUpdateCklstRvw() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + cklstRvwSn, + cklstArtclSn, + cklstSn, + body, + }: { + cklstRvwSn: number; + cklstArtclSn: number; + cklstSn: number; + body: CklstRvwUpdateRequest; + }) => cklstRvwService.update(cklstRvwSn, cklstArtclSn, cklstSn, body), + onSuccess: (_data, { cklstRvwSn, cklstArtclSn, cklstSn }) => { + queryClient.invalidateQueries({ queryKey: cklstRvwBaseKey }); + queryClient.invalidateQueries({ + queryKey: [...cklstRvwBaseKey, cklstRvwSn, cklstArtclSn, cklstSn], + }); + queryClient.invalidateQueries({ + queryKey: [...cklstRvwBaseKey, "page", { cklstSn }], + }); + queryClient.invalidateQueries({ + queryKey: [...cklstRvwBaseKey, "by-artcl", cklstSn, cklstArtclSn], + }); + }, + }); +} + +export function useDeleteCklstRvw() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + cklstRvwSn, + cklstArtclSn, + cklstSn, + }: { + cklstRvwSn: number; + cklstArtclSn: number; + cklstSn: number; + }) => cklstRvwService.remove(cklstRvwSn, cklstArtclSn, cklstSn), + onSuccess: (_data, { cklstSn, cklstArtclSn }) => { + queryClient.invalidateQueries({ queryKey: cklstRvwBaseKey }); + queryClient.invalidateQueries({ + queryKey: [...cklstRvwBaseKey, "page", { cklstSn }], + }); + queryClient.invalidateQueries({ + queryKey: [...cklstRvwBaseKey, "by-artcl", cklstSn, cklstArtclSn], + }); + }, + }); +} diff --git a/apps/as/src/entities/cklst/index.ts b/apps/as/src/entities/cklst/index.ts new file mode 100644 index 000000000..12796cd18 --- /dev/null +++ b/apps/as/src/entities/cklst/index.ts @@ -0,0 +1,46 @@ +export { cklstArtclService } from "./api/cklst-artcl-service"; +export { cklstMngService } from "./api/cklst-mng-service"; +export { cklstRvwService } from "./api/cklst-rvw-service"; +export { + useCklstArtcl, + useCklstArtclList, + useCklstArtclPage, + useCklstMng, + useCklstMngPage, + useCklstRvw, + useCklstRvwByArtcl, + useCklstRvwPage, + useCreateCklstArtcl, + useCreateCklstMng, + useCreateCklstRvw, + useDeleteCklstArtcl, + useDeleteCklstMng, + useDeleteCklstRvw, + useUpdateCklstArtcl, + useUpdateCklstMng, + useUpdateCklstRvw, +} from "./api/queries"; +export type { + CklstArtcl, + CklstArtclId, + CklstArtclSaveRequest, + CklstArtclSearchParams, + CklstArtclService, + CklstArtclUpdateRequest, +} from "./model/cklst-artcl-interface"; +export type { + CklstMng, + CklstMngSaveRequest, + CklstMngSearchParams, + CklstMngService, + CklstTreeNode, + CklstTreeSearchParams, +} from "./model/cklst-mng-interface"; +export type { + CklstRvw, + CklstRvwId, + CklstRvwSaveRequest, + CklstRvwSearchParams, + CklstRvwService, + CklstRvwUpdateRequest, +} from "./model/cklst-rvw-interface"; diff --git a/apps/as/src/entities/cklst/model/cklst-artcl-interface.ts b/apps/as/src/entities/cklst/model/cklst-artcl-interface.ts new file mode 100644 index 000000000..9d6e0e055 --- /dev/null +++ b/apps/as/src/entities/cklst/model/cklst-artcl-interface.ts @@ -0,0 +1,48 @@ +import type { PageableParams, PageResponse } from "@/shared/model"; + +export interface CklstArtclId { + cklstArtclSn: number; + cklstSn: number; +} + +export interface CklstArtcl { + cklstArtclSn: number; + cklstSn: number; + cklstArtclNm?: string; + useYn?: "Y" | "N"; + rgtr?: string; + rgtrNm?: string; + mdfr?: string; + regDt?: string; + mdfcnDt?: string; +} + +export interface CklstArtclSearchParams extends PageableParams { + cklstSn?: number; + cklstArtclNm?: string; + useYn?: "Y" | "N"; +} + +export interface CklstArtclSaveRequest { + cklstSn: number; + cklstArtclNm?: string; + useYn?: "Y" | "N"; +} + +export interface CklstArtclUpdateRequest { + cklstArtclNm?: string; + useYn?: "Y" | "N"; +} + +export interface CklstArtclService { + findPage(params: CklstArtclSearchParams): Promise>; + findByCklstSn(cklstSn: number): Promise; + findById(cklstArtclSn: number, cklstSn: number): Promise; + create(body: CklstArtclSaveRequest): Promise; + update( + cklstArtclSn: number, + cklstSn: number, + body: CklstArtclUpdateRequest, + ): Promise; + remove(cklstArtclSn: number, cklstSn: number): Promise; +} diff --git a/apps/as/src/entities/cklst/model/cklst-mng-interface.ts b/apps/as/src/entities/cklst/model/cklst-mng-interface.ts new file mode 100644 index 000000000..f6c80e630 --- /dev/null +++ b/apps/as/src/entities/cklst/model/cklst-mng-interface.ts @@ -0,0 +1,57 @@ +import type { PageableParams, PageResponse } from "@/shared/model"; + +export interface CklstMng { + cklstSn: number; + cklstNm: string; + taskSeComCd: string; + useYn?: "Y" | "N"; + docSeComCd?: string; + rgtr?: string; + rgtrNm?: string; + mdfr?: string; + regDt?: string; + mdfcnDt?: string; +} + +export interface CklstTreeNode { + cklstSn: number; + cklstNm: string; + cklstArtclSn: number; + cklstArtclNm: string; + cklstRvwSn: number; + upCklstRvwSn: number | null; + treeLevel: number; + rvwCn: string; + bchckUseYn: "Y" | "N"; + sortSeq: number; + // optional: backend may include the taskSeComCd per node + taskSeComCd?: string; +} + +export interface CklstTreeSearchParams { + docSeComCd?: string; + taskSeComCd?: string; +} + +export interface CklstMngSearchParams extends PageableParams { + cklstNm?: string; + taskSeComCd?: string; + useYn?: "Y" | "N"; + docSeComCd?: string; +} + +export interface CklstMngSaveRequest { + cklstNm: string; + taskSeComCd: string; + useYn?: "Y" | "N"; + docSeComCd?: string; +} + +export interface CklstMngService { + findPage(params: CklstMngSearchParams): Promise>; + findById(cklstSn: number): Promise; + findTree(params: CklstTreeSearchParams): Promise; + create(body: CklstMngSaveRequest): Promise; + update(cklstSn: number, body: CklstMngSaveRequest): Promise; + remove(cklstSn: number): Promise; +} diff --git a/apps/as/src/entities/cklst/model/cklst-rvw-interface.ts b/apps/as/src/entities/cklst/model/cklst-rvw-interface.ts new file mode 100644 index 000000000..2a9ddc7d5 --- /dev/null +++ b/apps/as/src/entities/cklst/model/cklst-rvw-interface.ts @@ -0,0 +1,64 @@ +import type { PageableParams, PageResponse } from "@/shared/model"; + +export interface CklstRvwId { + cklstRvwSn: number; + cklstArtclSn: number; + cklstSn: number; +} + +export interface CklstRvw { + cklstRvwSn: number; + cklstArtclSn: number; + cklstSn: number; + upCklstRvwSn?: number; + rvwCnLvNo?: number; + rvwCn?: string; + bchckUseYn?: "Y" | "N"; + sortSeq?: number; + useYn?: "Y" | "N"; + rgtr?: string; + rgtrNm?: string; + mdfr?: string; + regDt?: string; + mdfcnDt?: string; +} + +export interface CklstRvwSearchParams extends PageableParams { + cklstSn?: number; + cklstArtclSn?: number; + useYn?: "Y" | "N"; +} + +export interface CklstRvwSaveRequest { + cklstArtclSn: number; + cklstSn: number; + upCklstRvwSn?: number; + rvwCnLvNo?: number; + rvwCn?: string; + bchckUseYn?: "Y" | "N"; + sortSeq?: number; + useYn?: "Y" | "N"; +} + +export interface CklstRvwUpdateRequest { + upCklstRvwSn?: number; + rvwCnLvNo?: number; + rvwCn?: string; + bchckUseYn?: "Y" | "N"; + sortSeq?: number; + useYn?: "Y" | "N"; +} + +export interface CklstRvwService { + findPage(params: CklstRvwSearchParams): Promise>; + findByArtcl(cklstSn: number, cklstArtclSn: number): Promise; + findById(cklstRvwSn: number, cklstArtclSn: number, cklstSn: number): Promise; + create(body: CklstRvwSaveRequest): Promise; + update( + cklstRvwSn: number, + cklstArtclSn: number, + cklstSn: number, + body: CklstRvwUpdateRequest, + ): Promise; + remove(cklstRvwSn: number, cklstArtclSn: number, cklstSn: number): Promise; +} diff --git a/apps/as/src/entities/cnstp/api/cnstp-service.ts b/apps/as/src/entities/cnstp/api/cnstp-service.ts new file mode 100644 index 000000000..292ac7069 --- /dev/null +++ b/apps/as/src/entities/cnstp/api/cnstp-service.ts @@ -0,0 +1,58 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { PageResponse } from "@/shared/model"; + +import { + CNSTP_SE_SAVE_MODE, + type CnstpSeMngPutRequestDto, + type CnstpSeMngResponseDto, + type CnstpSeMngSaveRequestDto, + type CnstpSeMngSearchDto, + type CnstpSeMngService, + type CnstpSeMngTreeDto, +} from "../model/cnstp-interface"; + +const BASE = ENDPOINT.AS_SERVICE.CNSTP_SE; + +async function findAll(params: CnstpSeMngSearchDto): Promise> { + const response = await client.get>(BASE, { params }); + return response.data; +} + +async function findTree(): Promise { + const response = await client.get(`${BASE}/list/tree`); + return response.data; +} + +async function findById(cnstpSeCd: string): Promise { + const response = await client.get(`${BASE}/${cnstpSeCd}`); + return response.data; +} + +async function saveMulti(body: CnstpSeMngSaveRequestDto[]): Promise { + const response = await client.post(BASE, body); + return response.data; +} + +async function update(cnstpSeCd: string, body: CnstpSeMngPutRequestDto): Promise { + const response = await client.put(`${BASE}/${cnstpSeCd}`, { + ...body, + cnstpSeCd, + mode: CNSTP_SE_SAVE_MODE.UPDATE, + }); + return response.data; +} + +async function deleteCnstpSeCd(cnstpSeCd: string): Promise { + const response = await client.delete(`${BASE}/${cnstpSeCd}`); + return response.data; +} + +export const cnstpSeMngService = { + findAll, + findTree, + findById, + saveMulti, + update, + delete: deleteCnstpSeCd, +} satisfies CnstpSeMngService; diff --git a/apps/as/src/entities/cnstp/api/queries.ts b/apps/as/src/entities/cnstp/api/queries.ts new file mode 100644 index 000000000..a419af10c --- /dev/null +++ b/apps/as/src/entities/cnstp/api/queries.ts @@ -0,0 +1,73 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { ENDPOINT } from "@/shared/config"; + +import type { + CnstpSeMngPutRequestDto, + CnstpSeMngSaveRequestDto, + CnstpSeMngSearchDto, +} from "../model/cnstp-interface"; + +import { cnstpSeMngService } from "./cnstp-service"; + +const cnstpSeMngBaseKey = [ENDPOINT.AS_SERVICE.CNSTP_SE] as const; + +export function useCnstpSeMngPage(params: CnstpSeMngSearchDto) { + return useQuery({ + queryKey: [...cnstpSeMngBaseKey, "findAll", params], + queryFn: () => cnstpSeMngService.findAll(params), + }); +} + +export function useCnstpSeMngTree() { + return useQuery({ + queryKey: [...cnstpSeMngBaseKey, "findTree"], + queryFn: () => cnstpSeMngService.findTree(), + staleTime: Infinity, + }); +} + +export function useCnstpSeMng(cnstpSeCd: string | null | undefined) { + return useQuery({ + queryKey: [...cnstpSeMngBaseKey, "findById", cnstpSeCd], + queryFn: () => cnstpSeMngService.findById(cnstpSeCd!), + enabled: Boolean(cnstpSeCd), + }); +} + +export function useSaveMultiCnstpSeMng() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (body: CnstpSeMngSaveRequestDto[]) => cnstpSeMngService.saveMulti(body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: cnstpSeMngBaseKey }); + }, + }); +} + +export function useUpdateCnstpSeMng() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ cnstpSeCd, body }: { cnstpSeCd: string; body: CnstpSeMngPutRequestDto }) => + cnstpSeMngService.update(cnstpSeCd, body), + onSuccess: (_data, { cnstpSeCd }) => { + queryClient.invalidateQueries({ queryKey: cnstpSeMngBaseKey }); + queryClient.invalidateQueries({ + queryKey: [...cnstpSeMngBaseKey, "findById", cnstpSeCd], + }); + }, + }); +} + +export function useDeleteCnstpSeMng() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (cnstpSeCd: string) => cnstpSeMngService.delete(cnstpSeCd), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: cnstpSeMngBaseKey }); + }, + }); +} diff --git a/apps/as/src/entities/cnstp/index.ts b/apps/as/src/entities/cnstp/index.ts new file mode 100644 index 000000000..ea76468b3 --- /dev/null +++ b/apps/as/src/entities/cnstp/index.ts @@ -0,0 +1,19 @@ +export { cnstpSeMngService } from "./api/cnstp-service"; +export { + useCnstpSeMng, + useCnstpSeMngPage, + useCnstpSeMngTree, + useDeleteCnstpSeMng, + useSaveMultiCnstpSeMng, + useUpdateCnstpSeMng, +} from "./api/queries"; +export type { + BizSclTreeDto, + CnstpSeMngResponseDto, + CnstpSeMngSaveRequestDto, + CnstpSeMngSearchDto, + CnstpSeMngService, + CnstpSeMngTreeDto, + CnstpSeSaveMode, +} from "./model/cnstp-interface"; +export { CNSTP_SE_SAVE_MODE } from "./model/cnstp-interface"; diff --git a/apps/as/src/entities/cnstp/model/cnstp-interface.ts b/apps/as/src/entities/cnstp/model/cnstp-interface.ts new file mode 100644 index 000000000..eabd12f46 --- /dev/null +++ b/apps/as/src/entities/cnstp/model/cnstp-interface.ts @@ -0,0 +1,88 @@ +import type { PageableParams, PageResponse } from "@/shared/model"; + +export const CNSTP_SE_SAVE_MODE = { + CREATE: "C", + UPDATE: "U", + DELETE: "D", +} as const; + +export type CnstpSeSaveMode = (typeof CNSTP_SE_SAVE_MODE)[keyof typeof CNSTP_SE_SAVE_MODE]; + +/** 백엔드 CnstpSeMngResponseDto */ +export interface CnstpSeMngResponseDto { + cnstpSeCd: string; + cnstpSeCdNm: string; + upCnstpCd?: string; + cdLvNo: number; + useYn: string; + expln?: string; + sortSeq?: number; + rgtr?: string; + rgtrNm?: string; + mdfr?: string; + regDt?: string; + mdfcnDt?: string; +} + +/** 백엔드 CnstpSeMngSearchDto + Pageable */ +export interface CnstpSeMngSearchDto extends PageableParams { + cnstpSeCd?: string; + cnstpSeCdNm?: string; + useYn?: string; +} + +/** 백엔드 CnstpSeMngSaveRequestDto */ +export interface CnstpSeMngSaveRequestDto { + cnstpSeCd: string; + mode: CnstpSeSaveMode; + cnstpSeCdNm?: string; + upCnstpCd?: string; + cdLvNo?: number; + useYn?: string; + expln?: string; + sortSeq?: number; +} + +/** PUT /{cnstpSeCd} 요청 (mode는 서비스에서 U로 설정) */ +export interface CnstpSeMngPutRequestDto { + cnstpSeCd: string; + cnstpSeCdNm?: string; + upCnstpCd?: string; + cdLvNo?: number; + useYn?: string; + expln?: string; +} + +/** 백엔드 CnstpSeMngTreeDto */ +export interface CnstpSeMngTreeDto { + indCnstpSeCdNm: string; + cnstpSeCd: string; + cnstpSeCdNm: string; + upCnstpCd?: string; + useYn: string; + treeLevel: number; + treePath: string; +} + +/** 백엔드 BizSclTreeDto */ +export interface BizSclTreeDto { + indCnstpSeCdNm: string; + cnstpSeCd: string; + cnstpSeCdNm: string; + useYn: string; + treeLevel: number; + treePath: string; + bscPlanQty?: number; + bgtRfltQty?: number; + tstmDesignQty?: number; +} + +/** Controller public 메서드명·시그니처와 동일 */ +export interface CnstpSeMngService { + findAll(params: CnstpSeMngSearchDto): Promise>; + findTree(): Promise; + findById(cnstpSeCd: string): Promise; + saveMulti(body: CnstpSeMngSaveRequestDto[]): Promise; + update(cnstpSeCd: string, body: CnstpSeMngPutRequestDto): Promise; + delete(cnstpSeCd: string): Promise; +} diff --git a/apps/as/src/entities/code/api/code-service.ts b/apps/as/src/entities/code/api/code-service.ts new file mode 100644 index 000000000..2545330dd --- /dev/null +++ b/apps/as/src/entities/code/api/code-service.ts @@ -0,0 +1,27 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; + +import type { Code, CodeService } from "../model/code-interface"; + +async function getList(upCd: string): Promise { + const response = await client.get(`${ENDPOINT.CMS_SERVICE.CODE}/${upCd}`); + return response.data; +} + +async function getListByUseYn(upCd: string, useYn: string): Promise { + const response = await client.get(`${ENDPOINT.CMS_SERVICE.CODE}/${upCd}/${useYn}`); + return response.data; +} + +async function getCodeName(upCd: string, lwpoCd: string): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.CODE}/${upCd}/lwpoCd/${lwpoCd}`, + ); + return response.data; +} + +export const codeService = { + getList, + getListByUseYn, + getCodeName, +} satisfies CodeService; diff --git a/apps/as/src/entities/code/api/queries.ts b/apps/as/src/entities/code/api/queries.ts new file mode 100644 index 000000000..15dd51559 --- /dev/null +++ b/apps/as/src/entities/code/api/queries.ts @@ -0,0 +1,36 @@ +import { useQuery } from "@tanstack/react-query"; + +import { ENDPOINT } from "@/shared/config"; + +import { codeService } from "./code-service"; + +const codeBaseKey = [ENDPOINT.CMS_SERVICE.CODE] as const; + +const CODE_STALE_TIME = 1000 * 60 * 10; + +export function useCodeList(upCd: string | undefined) { + return useQuery({ + queryKey: [...codeBaseKey, upCd], + queryFn: () => codeService.getList(upCd!), + enabled: Boolean(upCd), + staleTime: CODE_STALE_TIME, + }); +} + +export function useCodeListByUseYn(upCd: string | undefined, useYn: string) { + return useQuery({ + queryKey: [...codeBaseKey, upCd, useYn], + queryFn: () => codeService.getListByUseYn(upCd!, useYn), + enabled: Boolean(upCd && useYn), + staleTime: CODE_STALE_TIME, + }); +} + +export function useCodeName(upCd: string | undefined, lwpoCd: string | undefined) { + return useQuery({ + queryKey: [...codeBaseKey, upCd, "name", lwpoCd], + queryFn: () => codeService.getCodeName(upCd!, lwpoCd!), + enabled: Boolean(upCd && lwpoCd), + staleTime: CODE_STALE_TIME, + }); +} diff --git a/apps/as/src/entities/code/index.ts b/apps/as/src/entities/code/index.ts new file mode 100644 index 000000000..0f1e5e050 --- /dev/null +++ b/apps/as/src/entities/code/index.ts @@ -0,0 +1,5 @@ +export { codeService } from "./api/code-service"; +export { useCodeList, useCodeListByUseYn, useCodeName } from "./api/queries"; +export { findCodeLabel } from "./lib/find-code-label"; +export type { Code, CodeService } from "./model/code-interface"; +export { CodeSelect } from "./ui/code-select"; diff --git a/apps/as/src/entities/code/lib/find-code-label.ts b/apps/as/src/entities/code/lib/find-code-label.ts new file mode 100644 index 000000000..6753a7758 --- /dev/null +++ b/apps/as/src/entities/code/lib/find-code-label.ts @@ -0,0 +1,7 @@ +import type { Code } from "../model/code-interface"; + +export function findCodeLabel(codes: Code[] | undefined, lwpoCd?: string): string { + if (!lwpoCd) return ""; + if (!codes?.length) return lwpoCd; + return codes.find((c) => c.lwpoCd === lwpoCd)?.lwpoCdNm ?? lwpoCd; +} diff --git a/apps/as/src/entities/code/model/code-interface.ts b/apps/as/src/entities/code/model/code-interface.ts new file mode 100644 index 000000000..30b0664a6 --- /dev/null +++ b/apps/as/src/entities/code/model/code-interface.ts @@ -0,0 +1,14 @@ +/** cms-service CodeResponseDto */ +export interface Code { + lwpoCd: string; + upCd: string; + lwpoCdNm: string; + unit?: string; + prntLwpoCd?: string; +} + +export interface CodeService { + getList(upCd: string): Promise; + getListByUseYn(upCd: string, useYn: string): Promise; + getCodeName(upCd: string, lwpoCd: string): Promise; +} diff --git a/apps/as/src/entities/code/ui/code-select.tsx b/apps/as/src/entities/code/ui/code-select.tsx new file mode 100644 index 000000000..e803e9285 --- /dev/null +++ b/apps/as/src/entities/code/ui/code-select.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { + cn, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@common/components"; + +import { useCodeList, useCodeListByUseYn } from "../api/queries"; + +interface CodeSelectProps extends React.ComponentPropsWithoutRef { + upCd: string; + useYn?: string; + placeholder?: string; + className?: string; +} + +function CodeSelect({ + upCd, + useYn, + placeholder = "선택", + className, + ...props +}: CodeSelectProps) { + const listQuery = useCodeList(useYn ? undefined : upCd); + const listByUseYnQuery = useCodeListByUseYn(useYn ? upCd : undefined, useYn ?? ""); + + const codes = useYn ? listByUseYnQuery.data : listQuery.data; + const isLoading = useYn ? listByUseYnQuery.isPending : listQuery.isPending; + + return ( + + ); +} + +export { CodeSelect }; diff --git a/apps/as/src/entities/elapr/api/elapr-apln-service.ts b/apps/as/src/entities/elapr/api/elapr-apln-service.ts new file mode 100644 index 000000000..a61b95f95 --- /dev/null +++ b/apps/as/src/entities/elapr/api/elapr-apln-service.ts @@ -0,0 +1,54 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { PageResponse } from "@/shared/model"; + +import type { + ElaprApln, + ElaprAplnListParams, + ElaprAplnReorderRequest, + ElaprAplnSaveRequest, + ElaprAplnService, + ElaprAplnUpdateRequest, +} from "../model/elapr-apln-interface"; + +async function findPage(params: ElaprAplnListParams): Promise> { + const response = await client.get>(ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN, { + params, + }); + return response.data; +} + +async function findById(aplnId: string): Promise { + const response = await client.get(`${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN}/${aplnId}`); + return response.data; +} + +async function saveMulti(body: ElaprAplnSaveRequest[]): Promise { + const response = await client.post(ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN, body); + return response.data; +} + +async function update(aplnId: string, body: ElaprAplnUpdateRequest): Promise { + const response = await client.put( + `${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN}/${aplnId}`, + body, + ); + return response.data; +} + +async function remove(aplnId: string): Promise { + await client.delete(`${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN}/${aplnId}`); +} + +async function reorder(body: ElaprAplnReorderRequest): Promise { + await client.put(`${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN}/reorder`, body); +} + +export const elaprAplnService = { + findPage, + findById, + saveMulti, + update, + remove, + reorder, +} satisfies ElaprAplnService; diff --git a/apps/as/src/entities/elapr/api/elapr-dmnd-service.ts b/apps/as/src/entities/elapr/api/elapr-dmnd-service.ts new file mode 100644 index 000000000..368600110 --- /dev/null +++ b/apps/as/src/entities/elapr/api/elapr-dmnd-service.ts @@ -0,0 +1,67 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { PageResponse } from "@/shared/model"; + +import type { + ElaprDmndResponseDto, + ElaprDmndSaveRequestDto, + ElaprDmndSearchDto, + ElaprDmndService, + ElaprDocPrgrsResponseDto, +} from "../model/elapr-dmnd-interface"; + +const BASE = ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND; + +async function findPage(params: ElaprDmndSearchDto): Promise> { + const response = await client.get>(BASE, { params }); + return response.data; +} + +async function findById(elaprDmndId: string): Promise { + const response = await client.get(`${BASE}/${elaprDmndId}`); + return response.data; +} + +async function save(body: ElaprDmndSaveRequestDto): Promise { + const response = await client.post(BASE, body); + return response.data; +} + +async function update( + elaprDmndId: string, + body: ElaprDmndSaveRequestDto, +): Promise { + const response = await client.put(`${BASE}/${elaprDmndId}`, body); + return response.data; +} + +async function updateStatus( + elaprDmndId: string, + atrzSttsComCd: string, +): Promise { + const response = await client.put(`${BASE}/${elaprDmndId}/status`, null, { + params: { atrzSttsComCd }, + }); + return response.data; +} + +async function remove(elaprDmndId: string): Promise { + await client.delete(`${BASE}/${elaprDmndId}`); +} + +async function findDocPrgrsByDocNo(elaprDocNo: string): Promise { + const response = await client.get( + `${BASE}/sanc/docprgrs/${elaprDocNo}`, + ); + return response.data; +} + +export const elaprDmndService = { + findPage, + findById, + save, + update, + updateStatus, + remove, + findDocPrgrsByDocNo, +} satisfies ElaprDmndService; 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/elapr-tmplt-service.ts b/apps/as/src/entities/elapr/api/elapr-tmplt-service.ts new file mode 100644 index 000000000..d9fcac55a --- /dev/null +++ b/apps/as/src/entities/elapr/api/elapr-tmplt-service.ts @@ -0,0 +1,51 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { PageResponse } from "@/shared/model"; + +import type { + ElaprTmplt, + ElaprTmpltSaveRequest, + ElaprTmpltSearchParams, + ElaprTmpltService, + ElaprTmpltUpdateRequest, +} from "../model/elapr-tmplt-interface"; + +async function findPage(params: ElaprTmpltSearchParams): Promise> { + const response = await client.get>( + ENDPOINT.AS_ELAPR_SERVICE.ELAPR_TMPLT, + { params }, + ); + return response.data; +} + +async function findById(elaprTmpltId: string): Promise { + const response = await client.get( + `${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_TMPLT}/${elaprTmpltId}`, + ); + return response.data; +} + +async function create(body: ElaprTmpltSaveRequest): Promise { + const response = await client.post(ENDPOINT.AS_ELAPR_SERVICE.ELAPR_TMPLT, body); + return response.data; +} + +async function update(elaprTmpltId: string, body: ElaprTmpltUpdateRequest): Promise { + const response = await client.put( + `${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_TMPLT}/${elaprTmpltId}`, + body, + ); + return response.data; +} + +async function remove(elaprTmpltId: string): Promise { + await client.delete(`${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_TMPLT}/${elaprTmpltId}`); +} + +export const elaprTmpltService = { + findPage, + findById, + create, + update, + remove, +} satisfies ElaprTmpltService; diff --git a/apps/as/src/entities/elapr/api/queries.ts b/apps/as/src/entities/elapr/api/queries.ts new file mode 100644 index 000000000..e93c1540e --- /dev/null +++ b/apps/as/src/entities/elapr/api/queries.ts @@ -0,0 +1,362 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { ENDPOINT } from "@/shared/config"; + +import type { + ElaprAplnListParams, + ElaprAplnReorderRequest, + ElaprAplnSaveRequest, + 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, + ElaprTmpltUpdateRequest, +} from "../model/elapr-tmplt-interface"; + +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({ + queryKey: [...elaprTmpltBaseKey, "page", params], + queryFn: () => elaprTmpltService.findPage(params), + }); +} + +export function useElaprTmplt(elaprTmpltId: string | null | undefined) { + return useQuery({ + queryKey: [...elaprTmpltBaseKey, elaprTmpltId], + queryFn: () => elaprTmpltService.findById(elaprTmpltId!), + enabled: Boolean(elaprTmpltId), + }); +} + +export function useCreateElaprTmplt() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (body: ElaprTmpltSaveRequest) => elaprTmpltService.create(body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: elaprTmpltBaseKey }); + }, + }); +} + +export function useUpdateElaprTmplt() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ elaprTmpltId, body }: { elaprTmpltId: string; body: ElaprTmpltUpdateRequest }) => + elaprTmpltService.update(elaprTmpltId, body), + onSuccess: (_data, { elaprTmpltId }) => { + queryClient.invalidateQueries({ queryKey: elaprTmpltBaseKey }); + queryClient.invalidateQueries({ + queryKey: [...elaprTmpltBaseKey, elaprTmpltId], + }); + }, + }); +} + +export function useDeleteElaprTmplt() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (elaprTmpltId: string) => elaprTmpltService.remove(elaprTmpltId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: elaprTmpltBaseKey }); + }, + }); +} + +export function useElaprAplnPage(params: ElaprAplnListParams) { + const { elaprTmpltId } = params; + + return useQuery({ + queryKey: [...elaprAplnBaseKey, "page", params], + queryFn: () => elaprAplnService.findPage(params), + enabled: Boolean(elaprTmpltId), + }); +} + +export function useElaprApln(aplnId: string | null | undefined) { + return useQuery({ + queryKey: [...elaprAplnBaseKey, aplnId], + queryFn: () => elaprAplnService.findById(aplnId!), + enabled: Boolean(aplnId), + }); +} + +export function useSaveMultiElaprApln() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ body }: { body: ElaprAplnSaveRequest[]; elaprTmpltId: string }) => + elaprAplnService.saveMulti(body), + onSuccess: (_data, { elaprTmpltId }) => { + queryClient.invalidateQueries({ queryKey: elaprAplnBaseKey }); + queryClient.invalidateQueries({ + queryKey: [...elaprAplnBaseKey, "page", { elaprTmpltId }], + }); + }, + }); +} + +export function useUpdateElaprApln() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ aplnId, body }: { aplnId: string; body: ElaprAplnUpdateRequest }) => + elaprAplnService.update(aplnId, body), + onSuccess: (_data, { body, aplnId }) => { + queryClient.invalidateQueries({ queryKey: elaprAplnBaseKey }); + queryClient.invalidateQueries({ + queryKey: [...elaprAplnBaseKey, aplnId], + }); + queryClient.invalidateQueries({ + queryKey: [...elaprAplnBaseKey, "page", { elaprTmpltId: body.elaprTmpltId }], + }); + }, + }); +} + +export function useDeleteElaprApln() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ aplnId }: { aplnId: string; elaprTmpltId: string }) => + elaprAplnService.remove(aplnId), + onSuccess: (_data, { elaprTmpltId }) => { + queryClient.invalidateQueries({ queryKey: elaprAplnBaseKey }); + queryClient.invalidateQueries({ + queryKey: [...elaprAplnBaseKey, "page", { elaprTmpltId }], + }); + }, + }); +} + +export function useReorderElaprApln() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ body }: { body: ElaprAplnReorderRequest; elaprTmpltId: string }) => + elaprAplnService.reorder(body), + onSuccess: (_data, { elaprTmpltId }) => { + queryClient.invalidateQueries({ queryKey: elaprAplnBaseKey }); + queryClient.invalidateQueries({ + queryKey: [...elaprAplnBaseKey, "page", { elaprTmpltId }], + }); + }, + }); +} + +export function useElaprDmndPage(params: ElaprDmndSearchDto) { + return useQuery({ + queryKey: [...elaprDmndBaseKey, "findPage", params], + queryFn: () => elaprDmndService.findPage(params), + }); +} + +export function useElaprDmnd(elaprDmndId: string | null | undefined) { + return useQuery({ + queryKey: [...elaprDmndBaseKey, "findById", elaprDmndId], + queryFn: () => elaprDmndService.findById(elaprDmndId!), + enabled: Boolean(elaprDmndId), + }); +} + +export function useElaprDocPrgrsByDocNo(elaprDocNo: string | null | undefined) { + return useQuery({ + queryKey: [...elaprDmndBaseKey, "findDocPrgrsByDocNo", elaprDocNo], + queryFn: () => elaprDmndService.findDocPrgrsByDocNo(elaprDocNo!), + enabled: Boolean(elaprDocNo), + }); +} + +export function useSaveElaprDmnd() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (body: ElaprDmndSaveRequestDto) => elaprDmndService.save(body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: elaprDmndBaseKey }); + }, + }); +} + +export function useUpdateElaprDmnd() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ elaprDmndId, body }: { elaprDmndId: string; body: ElaprDmndSaveRequestDto }) => + elaprDmndService.update(elaprDmndId, body), + onSuccess: (_data, { elaprDmndId }) => { + queryClient.invalidateQueries({ queryKey: elaprDmndBaseKey }); + queryClient.invalidateQueries({ + queryKey: [...elaprDmndBaseKey, "findById", elaprDmndId], + }); + }, + }); +} + +export function useUpdateElaprDmndStatus() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ elaprDmndId, atrzSttsComCd }: { elaprDmndId: string; atrzSttsComCd: string }) => + elaprDmndService.updateStatus(elaprDmndId, atrzSttsComCd), + onSuccess: (_data, { elaprDmndId }) => { + queryClient.invalidateQueries({ queryKey: elaprDmndBaseKey }); + queryClient.invalidateQueries({ + queryKey: [...elaprDmndBaseKey, "findById", elaprDmndId], + }); + }, + }); +} + +export function useRemoveElaprDmnd() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (elaprDmndId: string) => elaprDmndService.remove(elaprDmndId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: elaprDmndBaseKey }); + }, + }); +} + +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 new file mode 100644 index 000000000..adec3e931 --- /dev/null +++ b/apps/as/src/entities/elapr/index.ts @@ -0,0 +1,122 @@ +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/entities/elapr/lib/apln-save-utils.ts b/apps/as/src/entities/elapr/lib/apln-save-utils.ts new file mode 100644 index 000000000..17974a166 --- /dev/null +++ b/apps/as/src/entities/elapr/lib/apln-save-utils.ts @@ -0,0 +1,97 @@ +import { + ELAPR_APLN_SAVE_MODE, + type ElaprApln, + type ElaprAplnSaveRequest, +} from "../model/elapr-apln-interface"; +import type { ElaprAplnRow } from "../model/elapr-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 ( + row.aprvrUuid !== serverRow.aprvrUuid || + row.atrzSeComCd !== serverRow.atrzSeComCd || + row.atrzSeqNo !== serverRow.atrzSeqNo + ); +} + +function shouldIncludeUpdate(row: ElaprAplnRow, serverRows: ElaprApln[]): boolean { + if (row.isDirty) return true; + if (!row.aplnId) return false; + + const serverRow = serverRows.find((s) => s.aplnId === row.aplnId); + if (!serverRow) return false; + + 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, + serverRows: ElaprApln[], +): ElaprAplnSaveRequest[] { + const payload: ElaprAplnSaveRequest[] = []; + + for (const row of rows) { + if (row.isDeleted && row.aplnId) { + payload.push({ + mode: ELAPR_APLN_SAVE_MODE.DELETE, + aplnId: row.aplnId, + }); + } + } + + const visibleWithSeq = assignAtrzSeqNumbers(getVisibleRows(rows)); + + for (const row of visibleWithSeq) { + if (row.isNew) { + payload.push({ + mode: ELAPR_APLN_SAVE_MODE.CREATE, + elaprTmpltId, + aprvrUuid: row.aprvrUuid, + atrzSeComCd: row.atrzSeComCd, + atrzSeqNo: row.atrzSeqNo, + }); + continue; + } + + if (!row.aplnId) continue; + + if (shouldIncludeUpdate(row, serverRows)) { + payload.push({ + mode: ELAPR_APLN_SAVE_MODE.UPDATE, + aplnId: row.aplnId, + elaprTmpltId, + aprvrUuid: row.aprvrUuid, + atrzSeComCd: row.atrzSeComCd, + atrzSeqNo: row.atrzSeqNo, + }); + } + } + + return payload; +} diff --git a/apps/as/src/entities/elapr/lib/apln-validators.ts b/apps/as/src/entities/elapr/lib/apln-validators.ts new file mode 100644 index 000000000..91f1a95cf --- /dev/null +++ b/apps/as/src/entities/elapr/lib/apln-validators.ts @@ -0,0 +1,77 @@ +import type { ElaprAplnRow } from "../model/elapr-apln-row-types"; + +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: AtrzOrderValidateRow[]): string | null { + const visibleRows = rows.filter((row) => !row.isDeleted); + + const finalCount = visibleRows.filter((row) => row.atrzSeComCd === ATRZ_FINAL_CD).length; + if (finalCount === 0) { + return "최종승인자는 반드시 등록해야 합니다."; + } + if (finalCount > 1) { + return "최종승인자는 한 명만 등록할 수 있습니다."; + } + + if (finalCount === 1) { + const lastRow = visibleRows[visibleRows.length - 1]; + if (lastRow?.atrzSeComCd !== ATRZ_FINAL_CD) { + return "최종승인자는 결재선 마지막에 배치해야 합니다."; + } + } + + return null; +} + +export function validateAplnRow(row: ElaprAplnRow): string | null { + if (!row.atrzSeComCd?.trim()) { + return "결재구분을 선택해 주세요."; + } + if (!row.aprvrUuid?.trim()) { + return "결재자를 선택해 주세요."; + } + return null; +} + +export function validateAllRowsForSave( + rows: ElaprAplnRow[], + isEditingLocked: boolean, +): string | null { + if (isEditingLocked) { + return "편집 중인 행을 먼저 저장하거나 취소해 주세요."; + } + + const visibleRows = getVisibleRows(rows); + if (visibleRows.length === 0) { + return "저장할 결재선이 없습니다."; + } + + for (const row of visibleRows) { + const fieldError = validateAplnRow(row); + if (fieldError) return fieldError; + } + + return null; +} + +export function validateUniqueSeqNo(rows: ElaprAplnRow[]): string | null { + const seqSet = new Set(); + for (const row of getVisibleRows(rows)) { + if (row.atrzSeqNo == null) continue; + if (seqSet.has(row.atrzSeqNo)) { + return "동일 서식 내 결재순서번호가 중복됩니다."; + } + seqSet.add(row.atrzSeqNo); + } + return null; +} 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/entities/elapr/lib/approval-utils.ts b/apps/as/src/entities/elapr/lib/approval-utils.ts new file mode 100644 index 000000000..00d7d7b2a --- /dev/null +++ b/apps/as/src/entities/elapr/lib/approval-utils.ts @@ -0,0 +1,29 @@ +import type { ElaprDocPrgrsClientResponseDto } from "@/shared/model"; + +export interface ApproverStatusConfig { + label: string; + badgeClass: string; +} + +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-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( + list: ElaprDocPrgrsClientResponseDto[], +): ElaprDocPrgrsClientResponseDto[] { + return list.slice().sort((a, b) => Number(a.atrzSeqNo ?? 0) - Number(b.atrzSeqNo ?? 0)); +} + +export function getApproverStatus(status: string | undefined): ApproverStatusConfig { + return ( + APPROVER_STATUS_MAP[status ?? ""] ?? { + label: status || "-", + badgeClass: "bg-gray-100 text-gray-700", + } + ); +} 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/entities/elapr/lib/use-apln-rows.ts b/apps/as/src/entities/elapr/lib/use-apln-rows.ts new file mode 100644 index 000000000..384c8ffc1 --- /dev/null +++ b/apps/as/src/entities/elapr/lib/use-apln-rows.ts @@ -0,0 +1,191 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +import { arrayMove } from "@dnd-kit/sortable"; + +import type { ElaprApln } from "../model/elapr-apln-interface"; +import type { ElaprAplnRow } from "../model/elapr-apln-row-types"; + +import { assignAtrzSeqNumbers } from "./apln-save-utils"; +import { getVisibleRows } from "./apln-validators"; + +function toRow(item: ElaprApln): ElaprAplnRow { + return { + ...item, + rowKey: item.aplnId, + aprvrNm: item.aprvrNm, + isNew: false, + isDirty: false, + isDeleted: false, + }; +} + +interface UseAplnRowsOptions { + elaprTmpltId: string | null; + serverRows: ElaprApln[] | undefined; +} + +export function createDraftRowKey(): string { + return `draft-${Date.now()}`; +} + +export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) { + const [rows, setRows] = useState([]); + const [editingRowKey, setEditingRowKey] = useState(null); + + useEffect(() => { + if (!elaprTmpltId) { + setRows([]); + setEditingRowKey(null); + return; + } + setRows((serverRows ?? []).map(toRow)); + setEditingRowKey(null); + }, [elaprTmpltId, serverRows]); + + const isEditingLocked = editingRowKey != null; + const visibleRows = getVisibleRows(rows); + + const addRow = useCallback(() => { + if (!elaprTmpltId || isEditingLocked) return; + + const rowKey = createDraftRowKey(); + + setRows((prev) => [ + ...prev, + { + rowKey, + elaprTmpltId, + atrzSeComCd: "", + aprvrUuid: "", + aprvrNm: "", + isNew: true, + isDirty: true, + isDeleted: false, + }, + ]); + setEditingRowKey(rowKey); + }, [elaprTmpltId, isEditingLocked]); + + const startEdit = useCallback( + (rowKey: string) => { + if (isEditingLocked) return; + setEditingRowKey(rowKey); + setRows((prev) => + prev.map((row) => (row.rowKey === rowKey ? { ...row, snapshot: { ...row } } : row)), + ); + }, + [isEditingLocked], + ); + + 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 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) 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): ElaprAplnRow[] => { + if (isEditingLocked) return []; + + let payloadRows: ElaprAplnRow[] = []; + + setRows((prev) => { + const visible = getVisibleRows(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 = assignAtrzSeqNumbers(arrayMove(visible, oldIndex, newIndex)).map( + (row) => ({ ...row, isDirty: true }), + ); + + 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; + return merged; + }); + + return payloadRows; + }, + [isEditingLocked], + ); + + return { + rows, + visibleRows, + setRows, + editingRowKey, + isEditingLocked, + addRow, + startEdit, + updateRow, + cancelEdit, + commitEdit, + markDeleted, + reorderRows, + }; +} diff --git a/apps/as/src/entities/elapr/lib/use-dnd-sensors.ts b/apps/as/src/entities/elapr/lib/use-dnd-sensors.ts new file mode 100644 index 000000000..039414303 --- /dev/null +++ b/apps/as/src/entities/elapr/lib/use-dnd-sensors.ts @@ -0,0 +1,13 @@ +"use client"; + +import { KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; +import { sortableKeyboardCoordinates } from "@dnd-kit/sortable"; + +export function useDndSensors() { + return useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); +} 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 new file mode 100644 index 000000000..31e0d8a25 --- /dev/null +++ b/apps/as/src/entities/elapr/model/elapr-apln-interface.ts @@ -0,0 +1,62 @@ +import type { PageableParams, PageResponse } from "@/shared/model"; + +export const ELAPR_APLN_SAVE_MODE = { + CREATE: "C", + UPDATE: "U", + DELETE: "D", +} as const; + +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; + regDt?: string; + rgtrUuid?: string; + mdfcnDt?: string; + mdfrUuid?: string; +} + +export interface ElaprAplnListParams extends PageableParams { + elaprTmpltId?: string; +} + +export interface ElaprAplnSaveRequest { + mode: ElaprAplnSaveMode; + aplnId?: string; + elaprTmpltId?: string; + aprvrUuid?: string; + atrzSeComCd?: string; + atrzSeqNo?: number; +} + +export interface ElaprAplnUpdateRequest { + elaprTmpltId: string; + aprvrUuid?: string; + atrzSeComCd?: string; + atrzSeqNo?: number; +} + +export interface ElaprAplnReorderItem { + aplnId: string; + atrzSeqNo: number; +} + +export interface ElaprAplnReorderRequest { + items: ElaprAplnReorderItem[]; +} + +export interface ElaprAplnService { + findPage(params: ElaprAplnListParams): Promise>; + findById(aplnId: string): Promise; + saveMulti(body: ElaprAplnSaveRequest[]): Promise; + update(aplnId: string, body: ElaprAplnUpdateRequest): Promise; + remove(aplnId: string): Promise; + reorder(body: ElaprAplnReorderRequest): Promise; +} diff --git a/apps/as/src/entities/elapr/model/elapr-apln-row-types.ts b/apps/as/src/entities/elapr/model/elapr-apln-row-types.ts new file mode 100644 index 000000000..b3532df8a --- /dev/null +++ b/apps/as/src/entities/elapr/model/elapr-apln-row-types.ts @@ -0,0 +1,16 @@ +import type { ElaprApln } from "./elapr-apln-interface"; + +/** 그리드 로컬 행 — API 응답 + UI 보조 필드 */ +export interface ElaprAplnRow extends Omit { + /** React key · DnD · 편집 잠금용 — 서버 aplnId와 분리 */ + rowKey: string; + aplnId?: string; + /** 결재자 표시명 — 응답 DTO 또는 선택 시 로컬 유지 */ + aprvrNm?: string; + isNew?: boolean; + isDirty?: boolean; + /** 소프트 삭제 — 최종 저장 시 mode:D */ + isDeleted?: boolean; + /** 수정 취소용 스냅샷 */ + snapshot?: ElaprAplnRow; +} diff --git a/apps/as/src/entities/elapr/model/elapr-dmnd-interface.ts b/apps/as/src/entities/elapr/model/elapr-dmnd-interface.ts new file mode 100644 index 000000000..a4eb14c7c --- /dev/null +++ b/apps/as/src/entities/elapr/model/elapr-dmnd-interface.ts @@ -0,0 +1,47 @@ +import type { ElaprDocPrgrsClientResponseDto, PageableParams, PageResponse } from "@/shared/model"; + +import type { ElaprPrgrsSttsSaveRequestDto } from "./elapr-prgrs-stts-interface"; + +/** 백엔드 ElaprDmndSaveRequestDto */ +export interface ElaprDmndSaveRequestDto { + elaprDocNo: string; + dmndTtl: string; + dmndCn: string; + elaprPrgrsSttsList?: ElaprPrgrsSttsSaveRequestDto[]; +} + +/** 백엔드 ElaprDmndResponseDto */ +export interface ElaprDmndResponseDto { + elaprDmndId: string; + rqstrUuid?: string; + elaprDocNo: string; + dmndTtl: string; + dmndCn: string; + atrzSttsComCd: string; + atrzSttsComNm?: string; + dmndDt?: string; + updtDt?: string; +} + +/** 백엔드 ElaprDmndSearchDto + Pageable */ +export interface ElaprDmndSearchDto extends PageableParams { + rqstrUuid?: string; + atrzSttsComCd?: string; + dmndTtl?: string; + dmndDtStart?: string; + dmndDtEnd?: string; +} + +/** 백엔드 ElaprDocPrgrsResponseDto */ +export type ElaprDocPrgrsResponseDto = ElaprDocPrgrsClientResponseDto; + +/** Controller public 메서드명·시그니처와 동일 */ +export interface ElaprDmndService { + findPage(params: ElaprDmndSearchDto): Promise>; + findById(elaprDmndId: string): Promise; + save(body: ElaprDmndSaveRequestDto): Promise; + update(elaprDmndId: string, body: ElaprDmndSaveRequestDto): Promise; + updateStatus(elaprDmndId: string, atrzSttsComCd: string): Promise; + remove(elaprDmndId: string): Promise; + findDocPrgrsByDocNo(elaprDocNo: string): Promise; +} 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/elapr-tmplt-interface.ts b/apps/as/src/entities/elapr/model/elapr-tmplt-interface.ts new file mode 100644 index 000000000..4d87d97e8 --- /dev/null +++ b/apps/as/src/entities/elapr/model/elapr-tmplt-interface.ts @@ -0,0 +1,40 @@ +import type { PageableParams, PageResponse } from "@/shared/model"; + +export interface ElaprTmplt { + elaprTmpltId: string; + elaprTmpltNm: string; + elaprTmpltExpln?: string; + docSeComCd?: string; + docSeComNm?: string; + regDt?: string; + rgtrUuid?: string; + mdfcnDt?: string; + mdfrUuid?: string; +} + +export interface ElaprTmpltSearchParams extends PageableParams { + elaprTmpltNm?: string; + docSeComCd?: string; + regDtStart?: string; + regDtEnd?: string; +} + +export interface ElaprTmpltSaveRequest { + elaprTmpltNm: string; + elaprTmpltExpln?: string; + docSeComCd?: string; +} + +export interface ElaprTmpltUpdateRequest { + elaprTmpltNm: string; + elaprTmpltExpln?: string; + docSeComCd?: string; +} + +export interface ElaprTmpltService { + findPage(params: ElaprTmpltSearchParams): Promise>; + findById(elaprTmpltId: string): Promise; + create(body: ElaprTmpltSaveRequest): Promise; + update(elaprTmpltId: string, body: ElaprTmpltUpdateRequest): Promise; + remove(elaprTmpltId: string): 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 && ( +
+

반려 의견

+