From 25affc06b911fc167d75df732a0c4e77bbe42733 Mon Sep 17 00:00:00 2001 From: bicochan Date: Mon, 18 May 2026 17:40:21 +0900 Subject: [PATCH] =?UTF-8?q?[as]=20feat:=20=EA=B2=B0=EC=9E=AC=EC=84=A0=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../approval-line-management/page.tsx | 1 + apps/as/docs/approval-line-management/SPEC.md | 509 ++++++++++-------- apps/as/package.json | 4 + apps/as/src/entities/code/api/code-service.ts | 29 + apps/as/src/entities/code/api/queries.ts | 36 ++ apps/as/src/entities/code/index.ts | 6 + .../src/entities/code/lib/find-code-label.ts | 7 + .../src/entities/code/lib/up-cd-constants.ts | 5 + .../src/entities/code/model/code-interface.ts | 14 + apps/as/src/entities/code/ui/code-select.tsx | 50 ++ .../entities/elapr/api/elapr-apln-service.ts | 51 ++ .../entities/elapr/api/elapr-tmplt-service.ts | 57 ++ apps/as/src/entities/elapr/api/queries.ts | 151 ++++++ apps/as/src/entities/elapr/index.ts | 28 + .../elapr/model/elapr-apln-interface.ts | 41 ++ .../elapr/model/elapr-tmplt-interface.ts | 41 ++ apps/as/src/features/aprvr-picker/index.ts | 2 + .../aprvr-picker/model/mock-reviewers.ts | 17 + .../aprvr-picker/ui/aprvr-picker-dialog.tsx | 99 ++++ .../src/features/elapr-tmplt-dialog/index.ts | 2 + .../ui/elapr-tmplt-form-dialog.tsx | 127 +++++ .../pages/approval-line-management/index.ts | 1 + .../ui/approval-line-management-page.tsx | 29 + apps/as/src/shared/config/api.ts | 7 + apps/as/src/shared/lib/generate-client-id.ts | 5 + apps/as/src/shared/lib/index.ts | 3 + apps/as/src/shared/lib/match-query-status.tsx | 30 ++ apps/as/src/shared/lib/use-data-table.tsx | 43 ++ apps/as/src/shared/model/index.ts | 2 + apps/as/src/shared/model/page.ts | 32 ++ apps/as/src/shared/ui/index.ts | 1 + apps/as/src/shared/ui/no-data.tsx | 9 + apps/as/src/widgets/elapr-apln-grid/index.ts | 1 + .../elapr-apln-grid/lib/use-apln-rows.ts | 148 +++++ .../elapr-apln-grid/lib/use-dnd-sensors.ts | 16 + .../widgets/elapr-apln-grid/lib/validators.ts | 26 + .../widgets/elapr-apln-grid/model/types.ts | 11 + .../elapr-apln-grid/ui/elapr-apln-grid.tsx | 278 ++++++++++ .../elapr-apln-grid/ui/sortable-apln-row.tsx | 165 ++++++ apps/as/src/widgets/elapr-tmplt-list/index.ts | 1 + .../elapr-tmplt-list/model/columns.tsx | 57 ++ .../elapr-tmplt-list/ui/elapr-tmplt-list.tsx | 143 +++++ pnpm-lock.yaml | 22 +- 43 files changed, 2071 insertions(+), 236 deletions(-) create mode 100644 apps/as/app/(with-base-layout)/approval-line-management/page.tsx create mode 100644 apps/as/src/entities/code/api/code-service.ts create mode 100644 apps/as/src/entities/code/api/queries.ts create mode 100644 apps/as/src/entities/code/index.ts create mode 100644 apps/as/src/entities/code/lib/find-code-label.ts create mode 100644 apps/as/src/entities/code/lib/up-cd-constants.ts create mode 100644 apps/as/src/entities/code/model/code-interface.ts create mode 100644 apps/as/src/entities/code/ui/code-select.tsx create mode 100644 apps/as/src/entities/elapr/api/elapr-apln-service.ts create mode 100644 apps/as/src/entities/elapr/api/elapr-tmplt-service.ts create mode 100644 apps/as/src/entities/elapr/api/queries.ts create mode 100644 apps/as/src/entities/elapr/index.ts create mode 100644 apps/as/src/entities/elapr/model/elapr-apln-interface.ts create mode 100644 apps/as/src/entities/elapr/model/elapr-tmplt-interface.ts create mode 100644 apps/as/src/features/aprvr-picker/index.ts create mode 100644 apps/as/src/features/aprvr-picker/model/mock-reviewers.ts create mode 100644 apps/as/src/features/aprvr-picker/ui/aprvr-picker-dialog.tsx create mode 100644 apps/as/src/features/elapr-tmplt-dialog/index.ts create mode 100644 apps/as/src/features/elapr-tmplt-dialog/ui/elapr-tmplt-form-dialog.tsx create mode 100644 apps/as/src/pages/approval-line-management/index.ts create mode 100644 apps/as/src/pages/approval-line-management/ui/approval-line-management-page.tsx create mode 100644 apps/as/src/shared/lib/generate-client-id.ts create mode 100644 apps/as/src/shared/lib/match-query-status.tsx create mode 100644 apps/as/src/shared/lib/use-data-table.tsx create mode 100644 apps/as/src/shared/model/page.ts create mode 100644 apps/as/src/shared/ui/no-data.tsx create mode 100644 apps/as/src/widgets/elapr-apln-grid/index.ts create mode 100644 apps/as/src/widgets/elapr-apln-grid/lib/use-apln-rows.ts create mode 100644 apps/as/src/widgets/elapr-apln-grid/lib/use-dnd-sensors.ts create mode 100644 apps/as/src/widgets/elapr-apln-grid/lib/validators.ts create mode 100644 apps/as/src/widgets/elapr-apln-grid/model/types.ts create mode 100644 apps/as/src/widgets/elapr-apln-grid/ui/elapr-apln-grid.tsx create mode 100644 apps/as/src/widgets/elapr-apln-grid/ui/sortable-apln-row.tsx create mode 100644 apps/as/src/widgets/elapr-tmplt-list/index.ts create mode 100644 apps/as/src/widgets/elapr-tmplt-list/model/columns.tsx create mode 100644 apps/as/src/widgets/elapr-tmplt-list/ui/elapr-tmplt-list.tsx 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/docs/approval-line-management/SPEC.md b/apps/as/docs/approval-line-management/SPEC.md index 8528f8241..c8994ab8a 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 편집 중인 행 없음 (editingRowId == null) + ADD 영역 B-2 그리드 맨 아래 편집 가능 행 + SET editingRowId = 신규 행 임시 키 + SET 편집상태 = 신규 + SET aplnId = 클라이언트 임시 ID (저장 시 서버 등록용 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,177 @@ 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 | 등록 시 클라이언트 또는 정책에 따라 발급 | +| 결재서식명 | `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 갱신 | -| 결재서식 수정 | 행별 [수정] | 결재서식명, 문서구분 | 기존 값으로 초기화 | -| 결재서식 삭제 확인 | 행별 [삭제] | (확인/취소) | | +| 다이얼로그명 | 트리거 | 입력 항목 | 비고 | +| ------------------ | ----------- | ----------------------------------------------- | ---------------------- | +| 결재서식 등록 | [서식 등록] | `elaprTmpltId`, `elaprTmpltNm`, `docSeComCd` | `elaprTmpltExpln` 선택 | +| 결재서식 수정 | 행별 [수정] | `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` | 읽기 전용 | 신규 행: 임시 ID 표시, 저장 후 서버 ID | +| 결재순서번호 | `atrzSeqNo` | 숫자 Input | 드래그 시 자동 재부여 가능 | +| 결재구분 | `atrzSeComCd` | Select (`ATRZSECD` 공통코드) | 표시명 `atrzSeComNm` (조회 행) | +| 결재자 | `aprvrUuid` | 읽기 전용 이름 + **[선택]** 버튼 | 응답에 성명 없음 — 선택 시 로컬 `aprvrNm` 유지 | +| 등록일시 | `regDt` | 읽기 전용 | 신규 행은 `—` | +| 관리 | — | 편집 중인 행: **[저장] [취소]** · 조회 행: **[수정] [삭제]** (다른 행 편집 중이면 비활성) | -- 표시 정렬: 기본 `결재순서번호` 오름차순. -- 임시 행(서버 미저장)과 저장된 행의 시각적 구분 필요(예: ID 컬럼 표기 또는 행 스타일). 세부 UI는 TBD. -- 페이지네이션: TBD. +#### 인라인 편집 정책 + +| 항목 | 내용 | +| ------------------- | -------------------------------------------------------------------------------------------------------- | +| **단일 행 편집** | **행 추가 또는 한 행 수정 중에는 다른 행을 편집할 수 없다.** `editingRowId` 로 현재 편집 행 1건만 허용. | +| 편집 잠금 시 비활성 | 다른 행: 셀·[수정]·[삭제] 비활성 · `[행 추가]` 비활성 · 행 드래그 비활성 · (선택) `[결재선 저장]` 비활성 | +| 편집 해제 | 행별 **[저장]** 성공 또는 **[취소]** 시 `editingRowId` 초기화 후 전체 조회 모드 복귀 | +| 조회 행 | 편집 중이 아닐 때 읽기 전용. **[수정]** 클릭 시 해당 행만 편집 모드 진입 | +| 신규 행 | `[행 추가]` 시 맨 아래 편집 행 1건 생성 (`editingRowId` = 임시 키) | +| 필수값 | `atrzSeComCd`, `aprvrUuid`, `atrzSeqNo` | +| 순서 중복 | 동일 서식 내 `atrzSeqNo` 중복 시 저장 전 검증·안내 | +| 결재구분 코드 | `ATRZSECD` — 예: `ATRZ01` 검토자, `ATRZ02` 승인자, `ATRZ03` 최종승인자 | +| 저장 단위 | 행별 **[저장]** (단건 POST/PUT) 또는 툴바 **[결재선 저장]** (일괄) | + +#### 행 드래그 정렬 (`@dnd-kit` + `useSortable`) + +| 항목 | 내용 | +| ---------- | ------------------------------------------------------------------------------------------- | +| 목적 | 결재 단계 순서를 그리드에서 직접 변경 | +| 라이브러리 | `@dnd-kit/core`, `@dnd-kit/sortable` — 테이블 행 POC 후 확정 | +| 저장 시점 | 드래그는 클라이언트 상태만 변경 → **[결재선 저장]** 또는 행별 **[저장]** 시 `atrzSeqNo` PUT | +| 편집 중 | `editingRowId != null` 이면 드래그 **불가** | + +### 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**: `elaprTmpltId`_, `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**: `aplnId`_, `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 +360,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/` | +| 위젯 | `widgets/elapr-tmplt-list/`, `widgets/elapr-apln-grid/` | +| 피처 | `features/aprvr-picker/` (검토인력 [선택]만) | +| 엔티티 | `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. +- `aplnId` / `elaprTmpltId` 발급 정책(클라이언트 생성 규칙). +- `@dnd-kit` + react-table 행 인라인 Input 동시 적용 POC. +- 결재자 성명 표시: `aprvrUuid` → 사용자명 조회 API. +- 영역 A·B 페이지네이션·검색 UI 노출 여부. +- 권한(역할별 CRUD). diff --git a/apps/as/package.json b/apps/as/package.json index efafdcc50..a6954bdef 100644 --- a/apps/as/package.json +++ b/apps/as/package.json @@ -13,6 +13,10 @@ "@common/assets": "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", "@hookform/resolvers": "^4.1.3", "@tanstack/react-query": "^5.75.5", "@tanstack/react-query-devtools": "^5.75.5", 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..d2fb84f45 --- /dev/null +++ b/apps/as/src/entities/code/api/code-service.ts @@ -0,0 +1,29 @@ +import { client } from "@/shared/api/client"; +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..ded8f7731 --- /dev/null +++ b/apps/as/src/entities/code/index.ts @@ -0,0 +1,6 @@ +export { codeService } from "./api/code-service"; +export { useCodeList, useCodeListByUseYn, useCodeName } from "./api/queries"; +export { findCodeLabel } from "./lib/find-code-label"; +export { UP_CD } from "./lib/up-cd-constants"; +export { CodeSelect } from "./ui/code-select"; +export type { Code, CodeService } from "./model/code-interface"; 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/lib/up-cd-constants.ts b/apps/as/src/entities/code/lib/up-cd-constants.ts new file mode 100644 index 000000000..c85f4d986 --- /dev/null +++ b/apps/as/src/entities/code/lib/up-cd-constants.ts @@ -0,0 +1,5 @@ +/** 결재선 관리 SPEC 상위코드 */ +export const UP_CD = { + DOC_SE: "ASDOCSECD", + ATRZ_SE: "ATRZSECD", +} as const; 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..906b95de9 --- /dev/null +++ b/apps/as/src/entities/elapr/api/elapr-apln-service.ts @@ -0,0 +1,51 @@ +import { client } from "@/shared/api/client"; +import { ENDPOINT } from "@/shared/config"; +import type { PageResponse } from "@/shared/model"; + +import type { + ElaprApln, + ElaprAplnListParams, + 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 create(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}`); +} + +export const elaprAplnService = { + findPage, + findById, + create, + update, + remove, +} satisfies ElaprAplnService; 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..c721ff9b9 --- /dev/null +++ b/apps/as/src/entities/elapr/api/elapr-tmplt-service.ts @@ -0,0 +1,57 @@ +import { client } from "@/shared/api/client"; +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..0e1304977 --- /dev/null +++ b/apps/as/src/entities/elapr/api/queries.ts @@ -0,0 +1,151 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { ENDPOINT } from "@/shared/config"; + +import type { + ElaprAplnListParams, + ElaprAplnSaveRequest, + ElaprAplnUpdateRequest, +} from "../model/elapr-apln-interface"; +import type { + ElaprTmpltSaveRequest, + ElaprTmpltSearchParams, + ElaprTmpltUpdateRequest, +} from "../model/elapr-tmplt-interface"; + +import { elaprAplnService } from "./elapr-apln-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; + +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 useCreateElaprApln() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (body: ElaprAplnSaveRequest) => elaprAplnService.create(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 }], + }); + }, + }); +} diff --git a/apps/as/src/entities/elapr/index.ts b/apps/as/src/entities/elapr/index.ts new file mode 100644 index 000000000..cc776a82d --- /dev/null +++ b/apps/as/src/entities/elapr/index.ts @@ -0,0 +1,28 @@ +export { elaprAplnService } from "./api/elapr-apln-service"; +export { elaprTmpltService } from "./api/elapr-tmplt-service"; +export { + useCreateElaprApln, + useCreateElaprTmplt, + useDeleteElaprApln, + useDeleteElaprTmplt, + useElaprApln, + useElaprAplnPage, + useElaprTmplt, + useElaprTmpltPage, + useUpdateElaprApln, + useUpdateElaprTmplt, +} from "./api/queries"; +export type { + ElaprApln, + ElaprAplnListParams, + ElaprAplnSaveRequest, + ElaprAplnService, + ElaprAplnUpdateRequest, +} from "./model/elapr-apln-interface"; +export type { + ElaprTmplt, + ElaprTmpltSaveRequest, + ElaprTmpltSearchParams, + ElaprTmpltService, + ElaprTmpltUpdateRequest, +} from "./model/elapr-tmplt-interface"; 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..1aed3c1ce --- /dev/null +++ b/apps/as/src/entities/elapr/model/elapr-apln-interface.ts @@ -0,0 +1,41 @@ +import type { PageableParams, PageResponse } from "@/shared/model"; + +export interface ElaprApln { + aplnId: string; + elaprTmpltId: string; + aprvrUuid?: 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 { + aplnId: string; + elaprTmpltId: string; + aprvrUuid?: string; + atrzSeComCd?: string; + atrzSeqNo?: number; +} + +export interface ElaprAplnUpdateRequest { + elaprTmpltId: string; + aprvrUuid?: string; + atrzSeComCd?: string; + atrzSeqNo?: number; +} + +export interface ElaprAplnService { + findPage(params: ElaprAplnListParams): Promise>; + findById(aplnId: string): Promise; + create(body: ElaprAplnSaveRequest): Promise; + update(aplnId: string, body: ElaprAplnUpdateRequest): Promise; + remove(aplnId: string): 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..351b954c9 --- /dev/null +++ b/apps/as/src/entities/elapr/model/elapr-tmplt-interface.ts @@ -0,0 +1,41 @@ +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 { + elaprTmpltId: string; + 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/features/aprvr-picker/index.ts b/apps/as/src/features/aprvr-picker/index.ts new file mode 100644 index 000000000..0b98531cc --- /dev/null +++ b/apps/as/src/features/aprvr-picker/index.ts @@ -0,0 +1,2 @@ +export { AprvrPickerDialog } from "./ui/aprvr-picker-dialog"; +export type { ReviewerOption } from "./model/mock-reviewers"; diff --git a/apps/as/src/features/aprvr-picker/model/mock-reviewers.ts b/apps/as/src/features/aprvr-picker/model/mock-reviewers.ts new file mode 100644 index 000000000..863331be6 --- /dev/null +++ b/apps/as/src/features/aprvr-picker/model/mock-reviewers.ts @@ -0,0 +1,17 @@ +import { fakerKO as faker } from "@faker-js/faker"; + +export interface ReviewerOption { + uuid: string; + name: string; + deptNm: string; +} + +/** TBD: 검토인력 목록 API 연동 전 목 데이터 */ +export function createMockReviewers(count = 12): ReviewerOption[] { + faker.seed(42); + return Array.from({ length: count }, () => ({ + uuid: faker.string.uuid(), + name: faker.person.fullName(), + deptNm: faker.commerce.department(), + })); +} diff --git a/apps/as/src/features/aprvr-picker/ui/aprvr-picker-dialog.tsx b/apps/as/src/features/aprvr-picker/ui/aprvr-picker-dialog.tsx new file mode 100644 index 000000000..bb01c68e8 --- /dev/null +++ b/apps/as/src/features/aprvr-picker/ui/aprvr-picker-dialog.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + Button, + DataTable, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Input, +} from "@common/components"; +import { + createColumnHelper, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; + +import type { ReviewerOption } from "../model/mock-reviewers"; +import { createMockReviewers } from "../model/mock-reviewers"; + +interface AprvrPickerDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSelect: (reviewer: ReviewerOption) => void; +} + +const columnHelper = createColumnHelper(); + +function AprvrPickerDialog({ open, onOpenChange, onSelect }: AprvrPickerDialogProps) { + const [keyword, setKeyword] = useState(""); + const reviewers = useMemo(() => createMockReviewers(), []); + + const filtered = useMemo(() => { + const q = keyword.trim().toLowerCase(); + if (!q) return reviewers; + return reviewers.filter( + (r) => + r.name.toLowerCase().includes(q) || r.deptNm.toLowerCase().includes(q), + ); + }, [keyword, reviewers]); + + const columns = useMemo( + () => [ + columnHelper.accessor("name", { header: "성명" }), + columnHelper.accessor("deptNm", { header: "부서" }), + columnHelper.display({ + id: "actions", + header: "선택", + cell: ({ row }) => ( + + ), + }), + ], + [onOpenChange, onSelect], + ); + + const table = useReactTable({ + data: filtered, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + return ( + + + + 검토인력 선택 + +
+ setKeyword(e.target.value)} + /> + +
+ + + +
+
+ ); +} + +export { AprvrPickerDialog }; diff --git a/apps/as/src/features/elapr-tmplt-dialog/index.ts b/apps/as/src/features/elapr-tmplt-dialog/index.ts new file mode 100644 index 000000000..105850949 --- /dev/null +++ b/apps/as/src/features/elapr-tmplt-dialog/index.ts @@ -0,0 +1,2 @@ +export { ElaprTmpltFormDialog } from "./ui/elapr-tmplt-form-dialog"; +export type { ElaprTmpltFormValues } from "./ui/elapr-tmplt-form-dialog"; diff --git a/apps/as/src/features/elapr-tmplt-dialog/ui/elapr-tmplt-form-dialog.tsx b/apps/as/src/features/elapr-tmplt-dialog/ui/elapr-tmplt-form-dialog.tsx new file mode 100644 index 000000000..e4c0b94fe --- /dev/null +++ b/apps/as/src/features/elapr-tmplt-dialog/ui/elapr-tmplt-form-dialog.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, + Textarea, +} from "@common/components"; + +import { CodeSelect, UP_CD } from "@/entities/code"; +import type { ElaprTmplt } from "@/entities/elapr"; +import { generateClientId } from "@/shared/lib"; + +export interface ElaprTmpltFormValues { + elaprTmpltId: string; + elaprTmpltNm: string; + docSeComCd: string; + elaprTmpltExpln: string; +} + +interface ElaprTmpltFormDialogProps { + mode: "create" | "edit"; + open: boolean; + onOpenChange: (open: boolean) => void; + initial?: ElaprTmplt | null; + isSubmitting?: boolean; + onSubmit: (values: ElaprTmpltFormValues) => void; +} + +function ElaprTmpltFormDialog({ + mode, + open, + onOpenChange, + initial, + isSubmitting, + onSubmit, +}: ElaprTmpltFormDialogProps) { + const { register, handleSubmit, reset, setValue, watch } = useForm({ + defaultValues: { + elaprTmpltId: "", + elaprTmpltNm: "", + docSeComCd: "", + elaprTmpltExpln: "", + }, + }); + + const docSeComCd = watch("docSeComCd"); + + useEffect(() => { + if (!open) return; + + if (mode === "edit" && initial) { + reset({ + elaprTmpltId: initial.elaprTmpltId, + elaprTmpltNm: initial.elaprTmpltNm, + docSeComCd: initial.docSeComCd ?? "", + elaprTmpltExpln: initial.elaprTmpltExpln ?? "", + }); + return; + } + + reset({ + // TBD: elaprTmpltId 발급 정책 — 현재 클라이언트 임시 ID + elaprTmpltId: generateClientId("TMPL"), + elaprTmpltNm: "", + docSeComCd: "", + elaprTmpltExpln: "", + }); + }, [open, mode, initial, reset]); + + return ( + + + + {mode === "create" ? "결재서식 등록" : "결재서식 수정"} + +
+
+ + +
+
+ + +
+
+ + setValue("docSeComCd", v)} + placeholder="문서구분 선택" + /> +
+
+ +