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)/acntg-code-management/page.tsx b/apps/as/app/(with-base-layout)/acntg-code-management/page.tsx new file mode 100644 index 000000000..c22d0f0b4 --- /dev/null +++ b/apps/as/app/(with-base-layout)/acntg-code-management/page.tsx @@ -0,0 +1 @@ +export { AcntgCodeManagementPage as default } from "@/pages/acntg-code-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)/cnstp-code-management/page.tsx b/apps/as/app/(with-base-layout)/cnstp-code-management/page.tsx new file mode 100644 index 000000000..d20a49e4f --- /dev/null +++ b/apps/as/app/(with-base-layout)/cnstp-code-management/page.tsx @@ -0,0 +1 @@ +export { CnstpCodeManagementPage as default } from "@/pages/cnstp-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)/instl-sncn/register/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn/register/page.tsx index f2e0cd185..ffe6a71a4 100644 --- a/apps/as/app/(with-base-layout)/instl-sncn/register/page.tsx +++ b/apps/as/app/(with-base-layout)/instl-sncn/register/page.tsx @@ -1,3 +1,5 @@ import { CnfmRegistPage } from "@/pages/instl-sncn"; -export default CnfmRegistPage; +export default function Page(props: any) { + 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/docs/checklist-management/SPEC.md b/apps/as/docs/checklist-management/SPEC.md index 6b1a0abd6..927b6c53a 100644 --- a/apps/as/docs/checklist-management/SPEC.md +++ b/apps/as/docs/checklist-management/SPEC.md @@ -24,24 +24,23 @@ ``` ┌───────────────────────────────────────────────────────────────────────────────────────────────┐ -│ 페이지 타이틀: 체크리스트관리 브레드크럼: 디지털행정지원 > … > │ +│ 페이지 타이틀: 체크리스트관리 브레드크럼: 디지털행정지원 > … > │ ├──────────────────────────────────────────────┬────────────────────────────────────────────────┤ │ [영역 A] 점검표 관리 │ [영역 B] 점검항목 관리 │ -│ [점검표 추가] │ [점검항목 추가] │ +│ [점검표 추가] │ [점검항목 추가] │ │ ┌────────────────────────────────────────┐ │ ┌──────────────────────────────────────────┐ │ -│ │ 점검표일련번호 | 점검표명 | 문서구분 │ │ │ 점검항목코드 | 점검항목명 | 사용여부 │ │ -│ │ 업무구분 | 사용여부 | 등록일시 | 관리 │ │ │ 등록일시 | 관리 │ │ +│ │ 점검표일련번호 | 점검표명 | 문서구분 │ │ │ 점검항목코드 | 점검항목명 | 사용여부 │ │ +│ │ 업무구분 | 사용여부 | 등록일시 | 관리 │ │ │ 등록일시 | 관리 │ │ │ ├────────────────────────────────────────┤ │ ├──────────────────────────────────────────┤ │ -│ │ 1 (선택 행 강조) 설치인가 기본 … [관리]│ │ │ ITEM_001 요건검토 … [관리][삭제]│ │ -│ │ [삭제] │ │ │ ITEM_002 법령검토 … [관리][삭제]│ │ +│ │ 1 (선택 행 강조) [관리] [삭제] │ │ │ ITEM_001 요건검토 … [관리][삭제] │ │ +│ │ │ │ │ ITEM_002 법령검토 … [관리][삭제] │ │ │ └────────────────────────────────────────┘ │ └──────────────────────────────────────────┘ │ ├──────────────────────────────────────────────┴────────────────────────────────────────────────┤ -│ [영역 C] 체크리스트 항목 (선택된 점검표: {cklstNm}) │ -│ [미리보기] [추가] [저장] │ +│ [영역 C] 체크리스트 항목 [미리보기] [추가] [저장] │ │ ┌─────────────────────────────────────────────────────────────────────────────────────────┐ │ -│ │ 점검항목 ▼ | 주요 검토내용 | 사용 여부 ○예 ○아니오 | 구분 | 관리 │ │ -│ │ L1 [요건검토 ▼] | (입력) | ○예 ○아니오 | 1단계| [하위추가]│ │ -│ │ L2 [요건검토 ▼] | (입력) | ○예 ○아니오 | 2단계| [삭제] │ │ +│ │ 점검항목 ▼ | 주요 검토내용 | 사용 여부 | 구분 | 관리 │ │ +│ │ L1 [요건검토 ▼] | (입력) | ○예 ○아니오 | 1단계 | [하위추가]│ │ +│ │ L2 [요건검토 ▼] | (입력) | ○예 ○아니오 | 2단계 | [삭제] │ │ │ └─────────────────────────────────────────────────────────────────────────────────────────┘ │ └───────────────────────────────────────────────────────────────────────────────────────────────┘ ``` @@ -61,7 +60,6 @@ BEGIN // 영역 A: 점검표 행 선택 ON 영역 A 행 CLICK DO SET 선택된 점검표 = 행.cklstSn - DISPLAY "선택된 점검표: {cklstNm}" (영역 C 헤더) CALL GET /as-checklist-service/api/v1/cklst-rvw?cklstSn={cklstSn} // TBD: 점검표 단위 전체 검토 트리 조회 API — 현재는 항목별 by-artcl 조합 또는 클라이언트 병합 DISPLAY 체크리스트 항목 그리드 (영역 C) @@ -174,42 +172,42 @@ END ### 4-1. 헤더 / 툴바 -| 항목 | 내용 | -| ----------- | ----------------------------------------- | -| 섹션 타이틀 | 점검표 관리 | +| 항목 | 내용 | +| ----------- | ------------------------------------------ | +| 섹션 타이틀 | 점검표 관리 | | 우측 버튼 | **[점검표 추가]** — 점검표 등록 다이얼로그 | ### 4-2. 그리드 컬럼 -| 컬럼명 | Field | 타입 | 비고 | -| ---------------- | ------------- | ------ | ------------------------------------------------- | -| 점검표일련번호 | `cklstSn` | number | PK | -| 점검표명 | `cklstNm` | string | | +| 컬럼명 | 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` | -| 관리 | — | — | 행별 **[관리] · [삭제]** | +| 업무구분공통코드 | `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 초기화. | +| 관리 | 행별 | 점검표 수정 다이얼로그 (기존 값 초기화) | +| 삭제 | 행별 | 확인 후 삭제. 선택 중인 점검표이면 영역 C 초기화. | ### 4-4. 다이얼로그: 점검표 추가 / 수정 -| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 | -| -------- | ---------------- | ---- | ------ | ----------------- | ----------------------------------------- | -| 점검표명 | Text | Y | — | `cklstNm` | placeholder: "예: 설치인가 체크리스트" | -| 문서구분 | Select(공통코드) | N | — | `docSeComCd` | 예: FNRS, INSTL, PWRK, DESIGN | +| 표시명 | 입력 유형 | 필수 | 기본값 | 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 표시: 예/아니오 | +| 사용여부 | Select | N | Y | `useYn` | Y/N, UI 표시: 예/아니오 | | 버튼명 | 동작 | | ------ | -------------------------------------- | @@ -225,29 +223,29 @@ END ### 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` | -| 관리 | — | — | 행별 **[관리] · [삭제]** | +| 컬럼명 | 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 파라미터 매핑 | 비고 | -| ---------- | --------- | ---- | ------ | ----------------- | ----------------------- | +| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 | +| ---------- | --------- | ---- | ------ | ----------------- | --------------------------- | | 점검항목명 | Text | Y | — | `cklstArtclNm` | placeholder: "예: 법령검토" | -| 사용여부 | Select | N | Y | `useYn` | Y/N | +| 사용여부 | Select | N | Y | `useYn` | Y/N | | 버튼명 | 동작 | | ------ | -------------------------------------- | @@ -270,32 +268,32 @@ END ### 6-2. 그리드 컬럼 -| 컬럼명 | Field | 입력 UI | 비고 | -| -------------- | --------------- | ------------------------------- | ------------------------------------------------- | -| 점검항목 | `cklstArtclSn` | Select (영역 B 항목 목록) | L1/L2 접두 표시, `cklstArtclNm` 라벨 | -| 주요 검토내용 | `rvwCn` | Text / Textarea | 최대 4000자 | -| 사용 여부 | `useYn` | Radio (예/아니오) | `Y` / `N` | -| 구분 | `rvwCnLvNo` | 읽기 전용 | 1→1단계, 2→2단계 | -| 관리 | — | **[하위추가]** (1단계만) · **[삭제]** | 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단계 행은 [하위추가] 미표시 | #### 계층 정책 | 항목 | 내용 | | -------------- | -------------------------------------------------------------------- | -| 최대 깊이 | 2단계 (`rvwCnLvNo`: 1, 2) | +| 최대 깊이 | 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단계 자식 행 추가 | -| 삭제 | 확인 후 DELETE 또는 미저장 행 로컬 제거 | +| 버튼명 | 동작 | +| -------- | ---------------------------------------------------- | +| 추가 | 1단계 검토 행 1건 추가 (편집 가능) | +| 저장 | 변경·신규 검토 행 일괄 POST/PUT (일괄 저장 API 없음) | +| 미리보기 | 선택 점검표 체크리스트 읽기 전용 미리보기 (TBD) | +| 하위추가 | 선택 1·2단계 행 아래 하위 단계(2·3단계) 자식 행 추가 | +| 삭제 | 확인 후 DELETE 또는 미저장 행 로컬 제거 | --- @@ -303,39 +301,38 @@ END ### 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`) | -| 미구현 엔티티 | `@/entities/cklst-rvw` — **TBD** (영역 C용 `cklst-rvw` API) | +| 항목 | 내용 | +| ------------- | ------------------------------------------------------------- | +| 서비스 | `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` | +| 기능 | 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? | 수정일시 | +| 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?` @@ -345,25 +342,25 @@ END ### 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` | +| 기능 | 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? | 등록일시 | +| Field | 타입 | 설명 | +| ----------------- | ------- | ------------------ | +| `cklstArtclSn` | number | 점검표항목일련번호 | +| `cklstSn` | number | 점검표일련번호 | +| `cklstArtclNm` | string? | 점검표항목명 | +| `useYn` | string? | 사용여부 | +| `rgtr` / `rgtrNm` | string? | 등록자 | +| `regDt` | string? | 등록일시 | **CklstArtclSaveRequest**: `cklstSn`\*, `cklstArtclNm?`, `useYn?` @@ -375,37 +372,39 @@ END ### 7-4. 점검표검토 (영역 C) — `cklst-rvw` -> **entities 미구현** — `@/entities/cklst-rvw` 추가 예정. 아래는 백엔드 `CklstRvwController` 기준. - -| 기능 | Method | URL | Request / Query | Response | 클라이언트 (예정) | -| ---------------------------- | ------ | -------------------------------------------------- | -------------------------- | ------------------ | ------------------------ | -| 목록 조회 (페이징) | GET | `/cklst-rvw` | `CklstRvwSearchDto` + Pageable | `Page` | TBD `cklstRvwService.findPage` | -| 항목별 검토 전체 목록 | GET | `/cklst-rvw/by-artcl/{cklstSn}/{cklstArtclSn}` | — | `CklstRvw[]` | TBD `findByArtcl` | -| 단건 조회 | GET | `/cklst-rvw/{cklstRvwSn}/{cklstArtclSn}/{cklstSn}` | — | `CklstRvw` | TBD `findById` | -| 등록 | POST | `/cklst-rvw` | `CklstRvwSaveRequestDto` | `CklstRvwId` | TBD `create` | -| 수정 | PUT | `/cklst-rvw/{cklstRvwSn}/{cklstArtclSn}/{cklstSn}` | `CklstRvwUpdateRequestDto` | `CklstRvwId` | TBD `update` | -| 삭제 | DELETE | `/cklst-rvw/{cklstRvwSn}/{cklstArtclSn}/{cklstSn}` | — | `CklstRvwId` | TBD `remove` | +| 기능 | 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? | 등록일시 | +| Field | 타입 | 설명 | +| -------------- | ------- | ---------------------- | +| `cklstRvwSn` | number | 점검표검토일련번호 | +| `cklstArtclSn` | number | 점검표항목일련번호 | +| `cklstSn` | number | 점검표일련번호 | +| `upCklstRvwSn` | number? | 상위점검표검토일련번호 | +| `rvwCnLvNo` | number? | 검토내용레벨번호 | +| `rvwCn` | string? | 검토내용 | +| `bchckUseYn` | string? | 체크사용여부 | +| `sortSeq` | number? | 정렬순서 | +| `useYn` | string? | 사용여부 | +| `regDt` | string? | 등록일시 | -**CklstRvwSaveRequestDto**: `cklstArtclSn`\*, `cklstSn`\*, `upCklstRvwSn?`, `rvwCnLvNo?`, `rvwCn?`, `bchckUseYn?`, `sortSeq?`, `useYn?` +**CklstRvwSaveRequest**: `cklstArtclSn`\*, `cklstSn`\*, `upCklstRvwSn?`, `rvwCnLvNo?`, `rvwCn?`, `bchckUseYn?`, `sortSeq?`, `useYn?` -**CklstRvwUpdateRequestDto**: `upCklstRvwSn?`, `rvwCnLvNo?`, `rvwCn?`, `bchckUseYn?`, `sortSeq?`, `useYn?` +**CklstRvwUpdateRequest**: `upCklstRvwSn?`, `rvwCnLvNo?`, `rvwCn?`, `bchckUseYn?`, `sortSeq?`, `useYn?` -**CklstRvwSearchDto**: `cklstSn?`, `cklstArtclSn?`, `useYn?` + Pageable +**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. 공통코드 (보조) @@ -425,49 +424,50 @@ END - **공통 컴포넌트**: `@common/components` (`Dialog`, `Button`, `Input`, `Select`, `RadioGroup` 등) - **테이블**: `@tanstack/react-table` + `useDataTable` -- **데이터**: React Query — `@/entities/cklst` hooks (검토 API는 entities 추가 후 연동) +- **데이터**: 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) | +| 구분 | 경로 | +| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 라우트 | `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/` (구현됨), `entities/cklst-rvw/` (TBD) | -| 문서 | `apps/as/docs/checklist-management/SPEC.md` | +| 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 | +| 영역 | 주요 UI | +| ---- | ---------------------------------------------------------------------------- | +| A | DataTable + 행 선택 + 점검표 추가/수정 Dialog | +| B | DataTable + 점검항목 추가/수정 Dialog | | C | 계층형 편집 그리드(1·2단계) + [추가][저장][미리보기] + 행별 [하위추가][삭제] | -| 공통 | `CodeSelect` (문서구분·업무구분), 사용여부 Y/N Select/Radio | +| 공통 | `CodeSelect` (문서구분·업무구분), 사용여부 Y/N Select/Radio | ### 8-4. 다이얼로그·라우팅 -| 다이얼로그명 | 트리거 | API 연동 | -| ------------------ | ----------------- | ------------------------------------- | -| 점검표 추가 | [점검표 추가] | `useCreateCklstMng` | -| 점검표 수정 | 행 [관리] | `useUpdateCklstMng` | -| 점검표 삭제 확인 | 행 [삭제] | `useDeleteCklstMng` | -| 점검항목 추가 | [점검항목 추가] | `useCreateCklstArtcl` | -| 점검항목 수정 | 행 [관리] | `useUpdateCklstArtcl` | -| 체크리스트 미리보기 | [미리보기] | TBD — 읽기 전용 트리 렌더 | +| 다이얼로그명 | 트리거 | API 연동 | +| ------------------- | --------------- | ------------------------- | +| 점검표 추가 | [점검표 추가] | `useCreateCklstMng` | +| 점검표 수정 | 행 [관리] | `useUpdateCklstMng` | +| 점검표 삭제 확인 | 행 [삭제] | `useDeleteCklstMng` | +| 점검항목 추가 | [점검항목 추가] | `useCreateCklstArtcl` | +| 점검항목 수정 | 행 [관리] | `useUpdateCklstArtcl` | +| 체크리스트 미리보기 | [미리보기] | TBD — 읽기 전용 트리 렌더 | 라우트: `/checklist-management` (메뉴 URL은 CMS 등록값과 일치시킴) ### 8-5. 데이터 흐름(요약) - **진입**: `useCklstMngPage` → 영역 A, `useCklstArtclPage` → 영역 B. C 비활성. -- **점검표 선택**: `cklstSn` 설정 → 영역 C 활성 → `cklst-rvw` 목록 조회(TBD) → 트리 그리드 표시. +- **점검표 선택**: `cklstSn` 설정 → 영역 C 활성 → `useCklstRvwPage({ cklstSn })` → 트리 그리드 표시. - **점검표 CRUD**: Dialog → `useCreateCklstMng` / `useUpdateCklstMng` / `useDeleteCklstMng`. - **점검항목 CRUD**: Dialog → `useCreateCklstArtcl` / `useUpdateCklstArtcl` / `useDeleteCklstArtcl`. - **검토 저장**: [저장] → 신규 POST / 기존 PUT 순차 호출 (일괄 API 없음). @@ -478,7 +478,8 @@ END - [ ] 영역 B 점검항목 등록 시 `cklstSn` 필수 vs UI 무소속 마스터 — 백엔드·기획 확인 - [ ] `점검항목코드`(ITEM_001) 표시 형식 vs `cklstArtclSn` 숫자 PK - [ ] 영역 C 전체 검토 트리 조회: `cklstSn` 단위 일괄 API 부재 — `by-artcl` 다건 호출 또는 백엔드 확장 -- [ ] `@/entities/cklst-rvw` entities·hooks 구현 +- [x] `@/entities/cklst` — `cklstRvwService`·hooks 구현 +- [x] `pages/checklist-management` — 영역 A·B·C UI 구현 (`/checklist-management`) - [ ] 문서구분·업무구분 공통코드 상위코드 ID (`ASDOCSECD` 등) - [ ] [미리보기] 다이얼로그 UI·데이터 소스 - [ ] `bchckUseYn` UI 반영 여부 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/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 0b7be3348..94a1d0381 100644 --- a/apps/as/middleware.ts +++ b/apps/as/middleware.ts @@ -1,6 +1,6 @@ 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; @@ -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/package.json b/apps/as/package.json index 389b5bc05..5122cbbb5 100644 --- a/apps/as/package.json +++ b/apps/as/package.json @@ -11,12 +11,14 @@ }, "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", "@tanstack/react-query": "^5.75.5", "@tanstack/react-query-devtools": "^5.75.5", @@ -25,8 +27,9 @@ "dayjs": "^1.11.13", "js-cookie": "^3.0.5", "lucide-react": "^0.474.0", - "next": "15.3.8", + "next": "15.5.18", "react-hook-form": "^7.54.2", + "tus-js-client": "^4.3.1", "zod": "^3.24.2" }, "devDependencies": { diff --git a/apps/as/src/app/providers/index.tsx b/apps/as/src/app/providers/index.tsx index 35ca749f8..20ec0b72e 100644 --- a/apps/as/src/app/providers/index.tsx +++ b/apps/as/src/app/providers/index.tsx @@ -1,11 +1,13 @@ "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"; @@ -17,10 +19,13 @@ 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..957113dec --- /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_COMMON_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..c2bcc7f9f --- /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_COMMON_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..c1457208d --- /dev/null +++ b/apps/as/src/entities/acntg/model/acntg-interface.ts @@ -0,0 +1,86 @@ +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; + 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; +} + +/** 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/auth/api/auth-service.ts b/apps/as/src/entities/auth/api/auth-service.ts new file mode 100644 index 000000000..0c1730054 --- /dev/null +++ b/apps/as/src/entities/auth/api/auth-service.ts @@ -0,0 +1,79 @@ +import { ACCESS_TOKEN, AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "@/shared/config"; +import { getRemainingMs } from "@common/auth"; + +import { clearAuthToken, setToken, setupRefreshTimer } from "../lib/token-manager"; +import type { AuthPayload, AuthService } from "../model/auth-interface"; + +async function login(payload: AuthPayload): Promise { + const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + 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", + }); + + 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); + clearAuthToken(); + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("auth:logout")); + } + } +} + +async function logout(): Promise { + clearAuthToken(); + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("auth:logout")); + } +} + +export const authService: AuthService = { + login, + silentRefresh, + 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/token-manager.ts b/apps/as/src/entities/auth/lib/token-manager.ts index c5eeffc3a..d449e711b 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, + clearAuthToken, + getAccessToken, + getRefreshToken, + getUserId, +} = tokenManager; diff --git a/apps/as/src/entities/auth/model/auth-interface.ts b/apps/as/src/entities/auth/model/auth-interface.ts new file mode 100644 index 000000000..fb6e10630 --- /dev/null +++ b/apps/as/src/entities/auth/model/auth-interface.ts @@ -0,0 +1,10 @@ +export interface AuthPayload { + userId: string; + password: string; +} + +export interface AuthService { + login(payload: AuthPayload): Promise; + silentRefresh(): Promise; + logout(): Promise; +} diff --git a/apps/as/src/entities/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/cklst/api/cklst-mng-service.ts b/apps/as/src/entities/cklst/api/cklst-mng-service.ts index 67f22d3b1..fdae4e8ff 100644 --- a/apps/as/src/entities/cklst/api/cklst-mng-service.ts +++ b/apps/as/src/entities/cklst/api/cklst-mng-service.ts @@ -7,6 +7,8 @@ import type { CklstMngSaveRequest, CklstMngSearchParams, CklstMngService, + CklstTreeNode, + CklstTreeSearchParams, } from "../model/cklst-mng-interface"; async function findPage(params: CklstMngSearchParams): Promise> { @@ -21,6 +23,13 @@ async function findById(cklstSn: number): Promise { return response.data; } +async function findTree(params: CklstTreeSearchParams): Promise { + const response = await client.get(`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST}/list/tree`, { + params, + }); + return response.data; +} + async function create(body: CklstMngSaveRequest): Promise { const response = await client.post(ENDPOINT.AS_CHECKLIST_SERVICE.CKLST, body); return response.data; @@ -42,6 +51,7 @@ async function remove(cklstSn: number): Promise { export const cklstMngService = { findPage, findById, + findTree, create, update, remove, 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..0039bfbc1 --- /dev/null +++ b/apps/as/src/entities/cklst/api/cklst-rvw-service.ts @@ -0,0 +1,79 @@ +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"; + +async function findPage(params: CklstRvwSearchParams): Promise> { + const response = await client.get>( + ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW, + { params }, + ); + return response.data; +} + +async function findByArtcl(cklstSn: number, cklstArtclSn: number): Promise { + const response = await client.get( + `${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW}/by-artcl/${cklstSn}/${cklstArtclSn}`, + ); + return response.data; +} + +async function findById( + cklstRvwSn: number, + cklstArtclSn: number, + cklstSn: number, +): Promise { + const response = await client.get( + `${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW}/${cklstRvwSn}/${cklstArtclSn}/${cklstSn}`, + ); + return response.data; +} + +async function create(body: CklstRvwSaveRequest): Promise { + const response = await client.post( + ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW, + body, + ); + return response.data; +} + +async function update( + cklstRvwSn: number, + cklstArtclSn: number, + cklstSn: number, + body: CklstRvwUpdateRequest, +): Promise { + const response = await client.put( + `${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW}/${cklstRvwSn}/${cklstArtclSn}/${cklstSn}`, + body, + ); + return response.data; +} + +async function remove( + cklstRvwSn: number, + cklstArtclSn: number, + cklstSn: number, +): Promise { + const response = await client.delete( + `${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW}/${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 index d7b8fd2e5..33280c41a 100644 --- a/apps/as/src/entities/cklst/api/queries.ts +++ b/apps/as/src/entities/cklst/api/queries.ts @@ -8,12 +8,19 @@ import type { 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_CHECKLIST_SERVICE.CKLST] as const; const cklstArtclBaseKey = [ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL] as const; +const cklstRvwBaseKey = [ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW] as const; export function useCklstMngPage(params: CklstMngSearchParams) { return useQuery({ @@ -165,3 +172,110 @@ export function useDeleteCklstArtcl() { }, }); } + +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 index 499003f90..12796cd18 100644 --- a/apps/as/src/entities/cklst/index.ts +++ b/apps/as/src/entities/cklst/index.ts @@ -1,17 +1,24 @@ 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, @@ -26,4 +33,14 @@ export type { 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-mng-interface.ts b/apps/as/src/entities/cklst/model/cklst-mng-interface.ts index ad1302b00..f6c80e630 100644 --- a/apps/as/src/entities/cklst/model/cklst-mng-interface.ts +++ b/apps/as/src/entities/cklst/model/cklst-mng-interface.ts @@ -13,6 +13,26 @@ export interface CklstMng { 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; @@ -30,6 +50,7 @@ export interface CklstMngSaveRequest { 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..f39be006a --- /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_COMMON_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..2ebb44dea --- /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_COMMON_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..def16901a --- /dev/null +++ b/apps/as/src/entities/cnstp/model/cnstp-interface.ts @@ -0,0 +1,86 @@ +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; + 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; +} + +/** 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/elapr/api/elapr-apln-service.ts b/apps/as/src/entities/elapr/api/elapr-apln-service.ts index 795ae8f77..a61b95f95 100644 --- a/apps/as/src/entities/elapr/api/elapr-apln-service.ts +++ b/apps/as/src/entities/elapr/api/elapr-apln-service.ts @@ -1,48 +1,54 @@ -import { client } from "@/shared/api"; -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; +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-tmplt-service.ts b/apps/as/src/entities/elapr/api/elapr-tmplt-service.ts index a41299653..d9fcac55a 100644 --- a/apps/as/src/entities/elapr/api/elapr-tmplt-service.ts +++ b/apps/as/src/entities/elapr/api/elapr-tmplt-service.ts @@ -1,51 +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; +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 index 0e1304977..59a6bb91b 100644 --- a/apps/as/src/entities/elapr/api/queries.ts +++ b/apps/as/src/entities/elapr/api/queries.ts @@ -4,6 +4,7 @@ import { ENDPOINT } from "@/shared/config"; import type { ElaprAplnListParams, + ElaprAplnReorderRequest, ElaprAplnSaveRequest, ElaprAplnUpdateRequest, } from "../model/elapr-apln-interface"; @@ -94,11 +95,16 @@ export function useElaprApln(aplnId: string | null | undefined) { }); } -export function useCreateElaprApln() { +export function useSaveMultiElaprApln() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (body: ElaprAplnSaveRequest) => elaprAplnService.create(body), + mutationFn: ({ + body, + }: { + body: ElaprAplnSaveRequest[]; + elaprTmpltId: string; + }) => elaprAplnService.saveMulti(body), onSuccess: (_data, { elaprTmpltId }) => { queryClient.invalidateQueries({ queryKey: elaprAplnBaseKey }); queryClient.invalidateQueries({ @@ -149,3 +155,22 @@ export function useDeleteElaprApln() { }, }); } + +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 }], + }); + }, + }); +} diff --git a/apps/as/src/entities/elapr/index.ts b/apps/as/src/entities/elapr/index.ts index cc776a82d..caa639e51 100644 --- a/apps/as/src/entities/elapr/index.ts +++ b/apps/as/src/entities/elapr/index.ts @@ -1,7 +1,6 @@ export { elaprAplnService } from "./api/elapr-apln-service"; export { elaprTmpltService } from "./api/elapr-tmplt-service"; export { - useCreateElaprApln, useCreateElaprTmplt, useDeleteElaprApln, useDeleteElaprTmplt, @@ -9,15 +8,21 @@ export { useElaprAplnPage, useElaprTmplt, useElaprTmpltPage, + useReorderElaprApln, + useSaveMultiElaprApln, useUpdateElaprApln, useUpdateElaprTmplt, } from "./api/queries"; -export type { - ElaprApln, - ElaprAplnListParams, - ElaprAplnSaveRequest, - ElaprAplnService, - ElaprAplnUpdateRequest, +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 { ElaprTmplt, diff --git a/apps/as/src/entities/elapr/model/elapr-apln-interface.ts b/apps/as/src/entities/elapr/model/elapr-apln-interface.ts index 3d328d3f4..a41add2cd 100644 --- a/apps/as/src/entities/elapr/model/elapr-apln-interface.ts +++ b/apps/as/src/entities/elapr/model/elapr-apln-interface.ts @@ -1,9 +1,19 @@ 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; atrzSeComCd?: string; atrzSeComNm?: string; atrzSeqNo?: number; @@ -18,7 +28,9 @@ export interface ElaprAplnListParams extends PageableParams { } export interface ElaprAplnSaveRequest { - elaprTmpltId: string; + mode: ElaprAplnSaveMode; + aplnId?: string; + elaprTmpltId?: string; aprvrUuid?: string; atrzSeComCd?: string; atrzSeqNo?: number; @@ -31,10 +43,20 @@ export interface ElaprAplnUpdateRequest { 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; - create(body: ElaprAplnSaveRequest): 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/esntl/api/esntl-service.ts b/apps/as/src/entities/esntl/api/esntl-service.ts new file mode 100644 index 000000000..458940399 --- /dev/null +++ b/apps/as/src/entities/esntl/api/esntl-service.ts @@ -0,0 +1,68 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { PageResponse } from "@/shared/model"; + +import type { + EsntlAtchFileMng, + EsntlAtchFileMngSaveRequest, + EsntlAtchFileMngSearchParams, + EsntlAtchFileMngService, + EsntlAtchFileMngTreeResponseDto, + EsntlAtchFileMngTreeSearchParams, +} from "../model/esntl-interface"; + +async function findTree( + params: EsntlAtchFileMngTreeSearchParams, +): Promise { + const response = await client.get( + `${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/list/tree`, + { params }, + ); + return response.data; +} + +async function findPage( + params: EsntlAtchFileMngSearchParams, +): Promise> { + const response = await client.get>( + ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE, + { params }, + ); + return response.data; +} + +async function findById(esntlAtchFileId: string): Promise { + const response = await client.get( + `${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/${esntlAtchFileId}`, + ); + return response.data; +} + +async function create(body: EsntlAtchFileMngSaveRequest): Promise { + const response = await client.post(ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE, body); + return response.data; +} + +async function update(esntlAtchFileId: string, body: EsntlAtchFileMngSaveRequest): Promise { + const response = await client.put( + `${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/${esntlAtchFileId}`, + body, + ); + return response.data; +} + +async function remove(esntlAtchFileId: string): Promise { + const response = await client.delete( + `${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/${esntlAtchFileId}`, + ); + return response.data; +} + +export const esntlAtchFileMngService = { + findTree, + findPage, + findById, + create, + update, + remove, +} satisfies EsntlAtchFileMngService; diff --git a/apps/as/src/entities/esntl/api/queries.ts b/apps/as/src/entities/esntl/api/queries.ts new file mode 100644 index 000000000..48e30fc68 --- /dev/null +++ b/apps/as/src/entities/esntl/api/queries.ts @@ -0,0 +1,79 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { ENDPOINT } from "@/shared/config"; + +import type { + EsntlAtchFileMngSaveRequest, + EsntlAtchFileMngSearchParams, + EsntlAtchFileMngTreeSearchParams, +} from "../model/esntl-interface"; + +import { esntlAtchFileMngService } from "./esntl-service"; + +const esntlAtchFileMngBaseKey = [ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE] as const; + +export function useEsntlAtchFileMngTree(params: EsntlAtchFileMngTreeSearchParams) { + return useQuery({ + queryKey: [...esntlAtchFileMngBaseKey, "tree", params], + queryFn: () => esntlAtchFileMngService.findTree(params), + enabled: Boolean(params.docSeComCd), + staleTime: Infinity, + }); +} + +export function useEsntlAtchFileMngPage(params: EsntlAtchFileMngSearchParams) { + return useQuery({ + queryKey: [...esntlAtchFileMngBaseKey, "page", params], + queryFn: () => esntlAtchFileMngService.findPage(params), + }); +} + +export function useEsntlAtchFileMng(esntlAtchFileId: string | null | undefined) { + return useQuery({ + queryKey: [...esntlAtchFileMngBaseKey, esntlAtchFileId], + queryFn: () => esntlAtchFileMngService.findById(esntlAtchFileId!), + enabled: Boolean(esntlAtchFileId), + }); +} + +export function useCreateEsntlAtchFileMng() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (body: EsntlAtchFileMngSaveRequest) => esntlAtchFileMngService.create(body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: esntlAtchFileMngBaseKey }); + }, + }); +} + +export function useUpdateEsntlAtchFileMng() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + esntlAtchFileId, + body, + }: { + esntlAtchFileId: string; + body: EsntlAtchFileMngSaveRequest; + }) => esntlAtchFileMngService.update(esntlAtchFileId, body), + onSuccess: (_data, { esntlAtchFileId }) => { + queryClient.invalidateQueries({ queryKey: esntlAtchFileMngBaseKey }); + queryClient.invalidateQueries({ + queryKey: [...esntlAtchFileMngBaseKey, esntlAtchFileId], + }); + }, + }); +} + +export function useDeleteEsntlAtchFileMng() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (esntlAtchFileId: string) => esntlAtchFileMngService.remove(esntlAtchFileId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: esntlAtchFileMngBaseKey }); + }, + }); +} diff --git a/apps/as/src/entities/esntl/index.ts b/apps/as/src/entities/esntl/index.ts new file mode 100644 index 000000000..f359e1b44 --- /dev/null +++ b/apps/as/src/entities/esntl/index.ts @@ -0,0 +1,17 @@ +export { esntlAtchFileMngService } from "./api/esntl-service"; +export { + useCreateEsntlAtchFileMng, + useDeleteEsntlAtchFileMng, + useEsntlAtchFileMng, + useEsntlAtchFileMngPage, + useEsntlAtchFileMngTree, + useUpdateEsntlAtchFileMng, +} from "./api/queries"; +export type { + EsntlAtchFileMng, + EsntlAtchFileMngSaveRequest, + EsntlAtchFileMngSearchParams, + EsntlAtchFileMngService, + EsntlAtchFileMngTreeResponseDto, + EsntlAtchFileMngTreeSearchParams, +} from "./model/esntl-interface"; diff --git a/apps/as/src/entities/esntl/model/esntl-interface.ts b/apps/as/src/entities/esntl/model/esntl-interface.ts new file mode 100644 index 000000000..56fd8b1dd --- /dev/null +++ b/apps/as/src/entities/esntl/model/esntl-interface.ts @@ -0,0 +1,83 @@ +import type { PageableParams, PageResponse } from "@/shared/model"; + +/** 필수첨부파일관리 트리 조회 응답 DTO */ +export interface EsntlAtchFileMngTreeResponseDto { + /** 필수첨부파일아이디 */ + esntlAtchFileId: string; + /** 문서구분공통코드 */ + docSeComCd: string; + /** 필수첨부파일명 */ + esntlAtchFileNm: string; + /** 필수첨부여부 ("Y" | "N") */ + esntlAtchYn: "Y" | "N"; + /** 필수첨부파일비고 (파일형식 등) */ + esntlAtchFileRmrk: string; +} + +/** 필수첨부파일관리 엔티티 (목록/상세 공용 응답 DTO) */ +export interface EsntlAtchFileMng { + /** 필수첨부파일아이디 */ + esntlAtchFileId: string; + /** 문서구분공통코드 */ + docSeComCd: string; + /** 필수첨부파일명 */ + esntlAtchFileNm: string; + /** 필수첨부여부 ("Y" | "N") */ + esntlAtchYn: "Y" | "N"; + /** 필수첨부파일비고 */ + esntlAtchFileRmrk?: string; + /** 등록자UUID */ + rgtr?: string; + /** 등록자명 */ + rgtrNm?: string; + /** 수정자UUID */ + mdfr?: string; + /** 등록일시 (yyyy-MM-dd HH:mm:ss) */ + regDt?: string; + /** 수정일시 */ + mdfcnDt?: string; +} + +/** 필수첨부파일관리 트리 조회 파라미터 */ +export interface EsntlAtchFileMngTreeSearchParams { + /** 문서구분공통코드 (예: "INSTL") */ + docSeComCd: string; +} + +/** 필수첨부파일관리 목록 조회 파라미터 (페이징 포함) */ +export interface EsntlAtchFileMngSearchParams extends PageableParams { + /** 문서구분공통코드 (예: "INSTL") */ + docSeComCd?: string; + /** 필수첨부파일명 */ + esntlAtchFileNm?: string; + /** 필수첨부여부 ("Y" | "N") */ + esntlAtchYn?: "Y" | "N"; +} + +/** 필수첨부파일관리 서비스 인터페이스 */ +export interface EsntlAtchFileMngService { + /** 필수첨부파일 트리 조회 */ + findTree(params: EsntlAtchFileMngTreeSearchParams): Promise; + /** 필수첨부파일관리 목록 조회 (페이징) */ + findPage(params: EsntlAtchFileMngSearchParams): Promise>; + /** 필수첨부파일관리 상세 조회 */ + findById(esntlAtchFileId: string): Promise; + /** 필수첨부파일관리 등록 */ + create(body: EsntlAtchFileMngSaveRequest): Promise; + /** 필수첨부파일관리 수정 */ + update(esntlAtchFileId: string, body: EsntlAtchFileMngSaveRequest): Promise; + /** 필수첨부파일관리 삭제 */ + remove(esntlAtchFileId: string): Promise; +} + +/** 필수첨부파일관리 저장 요청 DTO */ +export interface EsntlAtchFileMngSaveRequest { + /** 문서구분공통코드 */ + docSeComCd: string; + /** 필수첨부파일명 */ + esntlAtchFileNm?: string; + /** 필수첨부여부 ("Y" | "N") */ + esntlAtchYn?: "Y" | "N"; + /** 필수첨부파일비고 */ + esntlAtchFileRmrk?: string; +} diff --git a/apps/as/src/entities/instl-sncn/api/instl-sncn-service.ts b/apps/as/src/entities/instl-sncn/api/instl-sncn-service.ts index 29d15fcf0..208e0172d 100644 --- a/apps/as/src/entities/instl-sncn/api/instl-sncn-service.ts +++ b/apps/as/src/entities/instl-sncn/api/instl-sncn-service.ts @@ -1,10 +1,12 @@ import { client } from "@/shared/api"; import { ENDPOINT } from "@/shared/config"; import type { + InstlSncnApplySaveRequestDto, InstlSncnMng, InstlSncnMngSearchParams, InstlSncnMngService, SpringPage, + InstlSncnMngDetResponse, } from "../model/instl-sncn-interface"; export class InstlSncnApiService implements InstlSncnMngService { @@ -15,8 +17,23 @@ export class InstlSncnApiService implements InstlSncnMngService { return response.data; } - public async getById(id: string): Promise { - const response = await client.get(`${ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN}/${id}`); + public async getById(id: string): Promise { + const response = await client.get(`${ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN}/${id}`); + return response.data; + } + + public async saveApply(payload: InstlSncnApplySaveRequestDto): Promise { + const response = await client.post( + `${ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN}/save-apply`, + payload, + ); + return response.data; + } + public async techReview(payload: InstlSncnApplySaveRequestDto): Promise { + const response = await client.post( + `${ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN}/tech-review`, + payload, + ); return response.data; } } diff --git a/apps/as/src/entities/instl-sncn/index.ts b/apps/as/src/entities/instl-sncn/index.ts index 8fdbb442f..5da39b459 100644 --- a/apps/as/src/entities/instl-sncn/index.ts +++ b/apps/as/src/entities/instl-sncn/index.ts @@ -1,6 +1,11 @@ export { InstlSncnApi } from "./api/instl-sncn-service"; -export type { - InstlSncnMng, - InstlSncnMngSearchParams, - SpringPage, +export { + INSTL_SNCN_REGISTER_SELECTION_KEY, + type InstlSncnApplySaveRequestDto, + type InstlSncnBizCstSaveRequestDto, + type InstlSncnBzScaleSaveDto, + type InstlSncnCklstResultSaveDto, + type InstlSncnMng, + type InstlSncnMngSearchParams, + type SpringPage, } from "./model/instl-sncn-interface"; diff --git a/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts b/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts index 00a51b76b..e9a4060ab 100644 --- a/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts +++ b/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts @@ -1,3 +1,5 @@ +export const INSTL_SNCN_REGISTER_SELECTION_KEY = "instlSncnRegisterSelection"; + export interface SpringPage { content: T[]; totalElements: number; @@ -21,12 +23,13 @@ export interface InstlSncnMngSearchParams { export interface InstlSncnMng { instlSncnDocNo: string; - dongId: string; + admdstCd: string; sidoNm?: string; sggNm?: string; chrgrNm?: string; instNm?: string; envoSeComCd: string; + sncnSeComCd: string; envoSeCdNm: string; taskSeComCd: string; taskSeCdNm: string; @@ -52,7 +55,70 @@ export interface InstlSncnMng { rgtrNm: string; } +/** 설치인가 신청 - 사업규모 저장 DTO(배열 원소) */ +export interface InstlSncnBzScaleSaveDto { + /** 공종구분코드 */ + cnstpSeCd: string; + /** 기본계획(A) */ + bscPl: number; + /** 예산반영(B) */ + budgetReflct: number; + /** 금회설계시(C) */ + thtiDsgn: number; + /** 증감(C-B) */ + diff: number; +} + +/** 설치인가 신청 - 총사업비 저장 DTO(배열 원소) */ +export interface InstlSncnBizCstSaveRequestDto { + /** 회계구분코드 */ + acntgSeCd: string; + /** 기본계획금액 */ + bscPlanAmt: number; + /** 예산반영금액 */ + bgtRfltAmt: number; + /** 금회설계금액 */ + tstmDesignAmt: number; +} + +/** 설치인가 신청 - 체크리스트 결과 저장 DTO(배열 원소) */ +export interface InstlSncnCklstResultSaveDto { + cklstSn: number; + cklstArtclSn: number; + cklstRvwSn: number; + rvwStpComCd: string; + selected: "Y" | "N" | null; + picRvwCn?: string | null; +} + +/** 설치인가 신청 - 필수첨부파일 저장 DTO(배열 원소) */ +export interface InstlSncnEsntlAtchFileSaveDto { + esntlAtchFileId: string; + atchFileGroupId: string; +} + +/** 설치인가 신청 - 통합 저장 요청 DTO */ +export interface InstlSncnApplySaveRequestDto { + instlSncnMng: InstlSncnMng; + bzScaleList: InstlSncnBzScaleSaveDto[]; + bizCstList: InstlSncnBizCstSaveRequestDto[]; + cklstResultList: InstlSncnCklstResultSaveDto[]; + esntlAtchFileList: InstlSncnEsntlAtchFileSaveDto[]; +} + +export type ReviewStepCode = "LOCAL" | "ENV" | "KECO"; + +export interface InstlSncnMngDetResponse { + detDto: InstlSncnMng; + bizSclList: any[]; + bizCstList: any[]; + cklstResultList?: any[]; + esntlFileList: any[]; + etcFileList: any[]; +} + export interface InstlSncnMngService { getPage: (params: InstlSncnMngSearchParams) => Promise>; - getById: (id: string) => Promise; + getById: (id: string) => Promise; + saveApply: (payload: InstlSncnApplySaveRequestDto) => Promise; } diff --git a/apps/as/src/entities/rvw-mnpw/api/queries.ts b/apps/as/src/entities/rvw-mnpw/api/queries.ts new file mode 100644 index 000000000..6e075e4d5 --- /dev/null +++ b/apps/as/src/entities/rvw-mnpw/api/queries.ts @@ -0,0 +1,87 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { ENDPOINT } from "@/shared/config"; + +import type { + RvwMnpwMngSaveRequestDto, + RvwMnpwMngSearchDto, + RvwMnpwMngUpdateRequestDto, +} from "../model/rvw-mnpw-interface"; + +import { rvwMnpwMngService } from "./rvw-mnpw-service"; + +const rvwMnpwMngBaseKey = [ENDPOINT.AS_COMMON_SERVICE.RVW_MNPW] as const; + +export function useRvwMnpwMngPage(params: RvwMnpwMngSearchDto, options?: { enabled?: boolean }) { + return useQuery({ + queryKey: [...rvwMnpwMngBaseKey, "findPage", params], + queryFn: () => rvwMnpwMngService.findPage(params), + enabled: options?.enabled, + }); +} + +export function useRvwMnpwMng( + atrzSeComCd: string | null | undefined, + ognzSn: number | null | undefined, + userUuid: string | null | undefined, +) { + return useQuery({ + queryKey: [...rvwMnpwMngBaseKey, "findById", atrzSeComCd, ognzSn, userUuid], + queryFn: () => rvwMnpwMngService.findById(atrzSeComCd!, ognzSn!, userUuid!), + enabled: Boolean(atrzSeComCd && ognzSn != null && userUuid), + }); +} + +export function useSaveRvwMnpwMng() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (body: RvwMnpwMngSaveRequestDto) => rvwMnpwMngService.save(body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: rvwMnpwMngBaseKey }); + }, + }); +} + +export function useUpdateRvwMnpwMng() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + atrzSeComCd, + ognzSn, + userUuid, + body, + }: { + atrzSeComCd: string; + ognzSn: number; + userUuid: string; + body: RvwMnpwMngUpdateRequestDto; + }) => rvwMnpwMngService.update(atrzSeComCd, ognzSn, userUuid, body), + onSuccess: (_data, { atrzSeComCd, ognzSn, userUuid }) => { + queryClient.invalidateQueries({ queryKey: rvwMnpwMngBaseKey }); + queryClient.invalidateQueries({ + queryKey: [...rvwMnpwMngBaseKey, "findById", atrzSeComCd, ognzSn, userUuid], + }); + }, + }); +} + +export function useDeleteRvwMnpwMng() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + atrzSeComCd, + ognzSn, + userUuid, + }: { + atrzSeComCd: string; + ognzSn: number; + userUuid: string; + }) => rvwMnpwMngService.delete(atrzSeComCd, ognzSn, userUuid), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: rvwMnpwMngBaseKey }); + }, + }); +} diff --git a/apps/as/src/entities/rvw-mnpw/api/rvw-mnpw-service.ts b/apps/as/src/entities/rvw-mnpw/api/rvw-mnpw-service.ts new file mode 100644 index 000000000..4f756787f --- /dev/null +++ b/apps/as/src/entities/rvw-mnpw/api/rvw-mnpw-service.ts @@ -0,0 +1,66 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { PageResponse } from "@/shared/model"; + +import type { + RvwMnpwMngResponseDto, + RvwMnpwMngSaveRequestDto, + RvwMnpwMngSearchDto, + RvwMnpwMngService, + RvwMnpwMngUpdateRequestDto, +} from "../model/rvw-mnpw-interface"; + +const BASE = ENDPOINT.AS_COMMON_SERVICE.RVW_MNPW; + +function pkPath(atrzSeComCd: string, ognzSn: number, userUuid: string): string { + return `${BASE}/${atrzSeComCd}/${ognzSn}/${userUuid}`; +} + +async function findPage(params: RvwMnpwMngSearchDto): Promise> { + const response = await client.get>(BASE, { params }); + return response.data; +} + +async function findById( + atrzSeComCd: string, + ognzSn: number, + userUuid: string, +): Promise { + const response = await client.get(pkPath(atrzSeComCd, ognzSn, userUuid)); + return response.data; +} + +async function save(body: RvwMnpwMngSaveRequestDto): Promise { + const response = await client.post(BASE, body); + return response.data; +} + +async function update( + atrzSeComCd: string, + ognzSn: number, + userUuid: string, + body: RvwMnpwMngUpdateRequestDto, +): Promise { + const response = await client.put( + pkPath(atrzSeComCd, ognzSn, userUuid), + body, + ); + return response.data; +} + +async function deleteRvwMnpw( + atrzSeComCd: string, + ognzSn: number, + userUuid: string, +): Promise { + const response = await client.delete(pkPath(atrzSeComCd, ognzSn, userUuid)); + return response.data; +} + +export const rvwMnpwMngService = { + findPage, + findById, + save, + update, + delete: deleteRvwMnpw, +} satisfies RvwMnpwMngService; diff --git a/apps/as/src/entities/rvw-mnpw/index.ts b/apps/as/src/entities/rvw-mnpw/index.ts new file mode 100644 index 000000000..14dd27dfc --- /dev/null +++ b/apps/as/src/entities/rvw-mnpw/index.ts @@ -0,0 +1,15 @@ +export { + useDeleteRvwMnpwMng, + useRvwMnpwMng, + useRvwMnpwMngPage, + useSaveRvwMnpwMng, + useUpdateRvwMnpwMng, +} from "./api/queries"; +export { rvwMnpwMngService } from "./api/rvw-mnpw-service"; +export type { + RvwMnpwMngResponseDto, + RvwMnpwMngSaveRequestDto, + RvwMnpwMngSearchDto, + RvwMnpwMngService, + RvwMnpwMngUpdateRequestDto, +} from "./model/rvw-mnpw-interface"; diff --git a/apps/as/src/entities/rvw-mnpw/model/rvw-mnpw-interface.ts b/apps/as/src/entities/rvw-mnpw/model/rvw-mnpw-interface.ts new file mode 100644 index 000000000..92cbff071 --- /dev/null +++ b/apps/as/src/entities/rvw-mnpw/model/rvw-mnpw-interface.ts @@ -0,0 +1,48 @@ +import type { PageableParams, PageResponse } from "@/shared/model"; + +/** 백엔드 RvwMnpwMngResponseDto */ +export interface RvwMnpwMngResponseDto { + atrzSeComCd: string; + atrzSeComCdNm?: string; + ognzSn: number; + userUuid: string; + userUuidNm?: string; + ognzNm?: string; + jbpsSeNm?: string; + jbgdSeNm?: string; + rgtr?: string; + regDt?: string; +} + +/** 백엔드 RvwMnpwMngSearchDto + Pageable */ +export interface RvwMnpwMngSearchDto extends PageableParams { + atrzSeComCd?: string; + ognzSn?: number; + userUuid?: string; +} + +/** 백엔드 RvwMnpwMngSaveRequestDto */ +export interface RvwMnpwMngSaveRequestDto { + atrzSeComCd: string; + ognzSn: number; + userUuid: string; +} + +/** 백엔드 RvwMnpwMngUpdateRequestDto */ +export interface RvwMnpwMngUpdateRequestDto { + newAtrzSeComCd: string; +} + +/** Controller public 메서드명·시그니처와 동일 */ +export interface RvwMnpwMngService { + findPage(params: RvwMnpwMngSearchDto): Promise>; + findById(atrzSeComCd: string, ognzSn: number, userUuid: string): Promise; + save(body: RvwMnpwMngSaveRequestDto): Promise; + update( + atrzSeComCd: string, + ognzSn: number, + userUuid: string, + body: RvwMnpwMngUpdateRequestDto, + ): Promise; + delete(atrzSeComCd: string, ognzSn: number, userUuid: string): Promise; +} diff --git a/apps/as/src/entities/user-organization/api/queries.ts b/apps/as/src/entities/user-organization/api/queries.ts new file mode 100644 index 000000000..726903dd7 --- /dev/null +++ b/apps/as/src/entities/user-organization/api/queries.ts @@ -0,0 +1,15 @@ +import { useQuery } from "@tanstack/react-query"; + +import { ENDPOINT } from "@/shared/config"; + +import { userOgnzService } from "./user-ognz-service"; + +const userOgnzTreeBaseKey = [ENDPOINT.CMS_SERVICE.USER_ORGANIZATION, "treeuser"] as const; + +export function useOgnzUserTree(options?: { enabled?: boolean }) { + return useQuery({ + queryKey: userOgnzTreeBaseKey, + queryFn: () => userOgnzService.findOgnzUserTree(), + enabled: options?.enabled ?? true, + }); +} diff --git a/apps/as/src/entities/user-organization/api/user-ognz-service.ts b/apps/as/src/entities/user-organization/api/user-ognz-service.ts new file mode 100644 index 000000000..55a4b0355 --- /dev/null +++ b/apps/as/src/entities/user-organization/api/user-ognz-service.ts @@ -0,0 +1,18 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; + +import type { + OgnzUserTreeResponseDto, + UserOgnzService, +} from "../model/ognz-user-tree-interface"; + +async function findOgnzUserTree(): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.USER_ORGANIZATION}/treeuser`, + ); + return response.data; +} + +export const userOgnzService = { + findOgnzUserTree, +} satisfies UserOgnzService; diff --git a/apps/as/src/entities/user-organization/index.ts b/apps/as/src/entities/user-organization/index.ts new file mode 100644 index 000000000..26c43cfa7 --- /dev/null +++ b/apps/as/src/entities/user-organization/index.ts @@ -0,0 +1,7 @@ +export { useOgnzUserTree } from "./api/queries"; +export { userOgnzService } from "./api/user-ognz-service"; +export type { + OgnzUserTreeResponseDto, + OgnzUserTreeUserDto, + UserOgnzService, +} from "./model/ognz-user-tree-interface"; diff --git a/apps/as/src/entities/user-organization/model/ognz-user-tree-interface.ts b/apps/as/src/entities/user-organization/model/ognz-user-tree-interface.ts new file mode 100644 index 000000000..47e75d944 --- /dev/null +++ b/apps/as/src/entities/user-organization/model/ognz-user-tree-interface.ts @@ -0,0 +1,25 @@ +/** OgnzUserTreeResponseDto.UserDto */ +export interface OgnzUserTreeUserDto { + userUnqId: string; + userNm: string; + jbpsSeCd: string; + jbgdSeCd: string; + jbpsSeNm: string; + jbgdSeNm: string; +} + +/** OgnzUserTreeResponseDto */ +export interface OgnzUserTreeResponseDto { + ognzSn: number; + upOgnzSn: number | null; + ognzNm: string; + ognzExpln?: string; + sortSeq?: number; + useYn?: string; + users: OgnzUserTreeUserDto[]; + children: OgnzUserTreeResponseDto[]; +} + +export interface UserOgnzService { + findOgnzUserTree(): Promise; +} diff --git a/apps/as/src/pages/acntg-code-management/index.ts b/apps/as/src/pages/acntg-code-management/index.ts new file mode 100644 index 000000000..ee4bfa445 --- /dev/null +++ b/apps/as/src/pages/acntg-code-management/index.ts @@ -0,0 +1 @@ +export { AcntgCodeManagementPage } from "./ui/acntg-code-management-page"; diff --git a/apps/as/src/pages/acntg-code-management/lib/acntg-se-grid.ts b/apps/as/src/pages/acntg-code-management/lib/acntg-se-grid.ts new file mode 100644 index 000000000..c0ca7abc7 --- /dev/null +++ b/apps/as/src/pages/acntg-code-management/lib/acntg-se-grid.ts @@ -0,0 +1,245 @@ +import type { AcntgSeMngResponseDto } from "@/entities/acntg"; + +import type { + AcntgDisplaySlot, + AcntgSeBaseEntry, + AcntgSeGridState, +} from "../model/acntg-se-grid-types"; +import type { AcntgSeRow } from "../model/acntg-se-row-types"; +import { buildAcntgDisplayRows } from "../model/build-acntg-display-rows"; + +export function createLoadedRowKey(index: number, acntgSeCd: string): string { + return `loaded-${index}-${acntgSeCd}`; +} + +export function createDraftRowKey(): string { + return `draft-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; +} + +export function createEmptyGridState(): AcntgSeGridState { + return { + baseByKey: {}, + structure: [], + drafts: {}, + patches: {}, + deletedKeys: new Set(), + }; +} + +function toInitialRow(entry: AcntgSeBaseEntry): AcntgSeRow { + return { + ...entry, + isNew: false, + isDirty: false, + isDeleted: false, + }; +} + +export function buildStructureFromBaseRows( + baseByKey: Record, +): AcntgDisplaySlot[] { + const rows = Object.values(baseByKey).map(toInitialRow); + const display = buildAcntgDisplayRows(rows); + const cdToRowKey = new Map(rows.map((row) => [row.acntgSeCd, row.rowKey])); + + return display.map(({ row, depth }) => ({ + rowKey: row.rowKey, + depth, + parentRowKey: row.upAcntgSeCd?.trim() ? (cdToRowKey.get(row.upAcntgSeCd.trim()) ?? null) : null, + })); +} + +export function hydrateGridFromServer(content: AcntgSeMngResponseDto[]): AcntgSeGridState { + const baseByKey: Record = {}; + + content.forEach((item, index) => { + const rowKey = createLoadedRowKey(index, item.acntgSeCd); + baseByKey[rowKey] = { ...item, rowKey }; + }); + + return { + baseByKey, + structure: buildStructureFromBaseRows(baseByKey), + drafts: {}, + patches: {}, + deletedKeys: new Set(), + }; +} + +export function resolveParentAcntgSeCd( + state: AcntgSeGridState, + parentRowKey: string | null, +): string | undefined { + if (!parentRowKey) return undefined; + const parent = mergeAcntgRow(state, parentRowKey); + const cd = parent?.acntgSeCd?.trim(); + return cd || undefined; +} + +export function mergeAcntgRow(state: AcntgSeGridState, rowKey: string): AcntgSeRow | null { + const draft = state.drafts[rowKey]; + if (draft) { + return { + rowKey, + acntgSeCd: draft.acntgSeCd, + acntgSeCdNm: draft.acntgSeCdNm, + expln: draft.expln, + useYn: draft.useYn, + cdLvNo: draft.cdLvNo, + upAcntgSeCd: resolveParentAcntgSeCd(state, draft.parentRowKey), + isNew: true, + isDirty: true, + isDeleted: false, + }; + } + + const base = state.baseByKey[rowKey]; + if (!base) return null; + + const patch = state.patches[rowKey] ?? {}; + const hasPatch = Object.keys(patch).length > 0; + + return { + ...base, + ...patch, + rowKey, + isNew: false, + isDirty: hasPatch, + isDeleted: state.deletedKeys.has(rowKey), + }; +} + +export function mergeAllAcntgRows(state: AcntgSeGridState): AcntgSeRow[] { + const keys = new Set([...Object.keys(state.baseByKey), ...Object.keys(state.drafts)]); + + return [...keys] + .map((rowKey) => mergeAcntgRow(state, rowKey)) + .filter((row): row is AcntgSeRow => row != null); +} + +export function hasAcntgGridPendingChanges(state: AcntgSeGridState): boolean { + return ( + state.deletedKeys.size > 0 || + Object.keys(state.drafts).length > 0 || + Object.keys(state.patches).length > 0 + ); +} + +export function collectDescendantRowKeys( + structure: AcntgDisplaySlot[], + rootRowKey: string, +): Set { + const result = new Set([rootRowKey]); + const queue = [rootRowKey]; + + while (queue.length > 0) { + const parentKey = queue.shift()!; + for (const slot of structure) { + if (slot.parentRowKey === parentKey && !result.has(slot.rowKey)) { + result.add(slot.rowKey); + queue.push(slot.rowKey); + } + } + } + + return result; +} + +export function insertRootSlot( + structure: AcntgDisplaySlot[], + slot: AcntgDisplaySlot, +): AcntgDisplaySlot[] { + const firstRootIndex = structure.findIndex((item) => item.parentRowKey === null); + if (firstRootIndex === -1) return [slot, ...structure]; + return [...structure.slice(0, firstRootIndex), slot, ...structure.slice(firstRootIndex)]; +} + +export function insertChildSlot( + structure: AcntgDisplaySlot[], + parentRowKey: string, + slot: AcntgDisplaySlot, +): AcntgDisplaySlot[] { + const parentIndex = structure.findIndex((item) => item.rowKey === parentRowKey); + if (parentIndex === -1) return [...structure, slot]; + + const insertAt = parentIndex + 1; + return [...structure.slice(0, insertAt), slot, ...structure.slice(insertAt)]; +} + +export function removeSlotsFromStructure( + structure: AcntgDisplaySlot[], + rowKeysToRemove: Set, +): AcntgDisplaySlot[] { + return structure.filter((slot) => !rowKeysToRemove.has(slot.rowKey)); +} + +export function patchAcntgGridRow( + state: AcntgSeGridState, + rowKey: string, + patch: Partial, +): AcntgSeGridState { + const draft = state.drafts[rowKey]; + if (draft) { + return { + ...state, + drafts: { + ...state.drafts, + [rowKey]: { + ...draft, + ...(patch.acntgSeCd !== undefined ? { acntgSeCd: patch.acntgSeCd } : {}), + ...(patch.acntgSeCdNm !== undefined ? { acntgSeCdNm: patch.acntgSeCdNm ?? "" } : {}), + ...(patch.expln !== undefined ? { expln: patch.expln ?? "" } : {}), + ...(patch.useYn !== undefined ? { useYn: patch.useYn } : {}), + }, + }, + }; + } + + const { rowKey: _rk, isNew: _n, isDirty: _d, isDeleted: _del, ...dtoPatch } = patch; + + return { + ...state, + patches: { + ...state.patches, + [rowKey]: { ...state.patches[rowKey], ...dtoPatch }, + }, + }; +} + +export function markAcntgRowsDeleted( + state: AcntgSeGridState, + rootRowKey: string, +): AcntgSeGridState { + const keysToDelete = collectDescendantRowKeys(state.structure, rootRowKey); + const deletedKeys = new Set(state.deletedKeys); + for (const key of keysToDelete) { + deletedKeys.add(key); + } + return { ...state, deletedKeys }; +} + +export function validateDraftParentAcntgSeCd( + state: AcntgSeGridState, + rowKey: string, +): string | null { + const draft = state.drafts[rowKey]; + if (!draft?.parentRowKey) return null; + + const parent = mergeAcntgRow(state, draft.parentRowKey); + if (!parent?.acntgSeCd?.trim()) { + return "하위 항목을 추가하려면 상위 코드를 먼저 입력하세요."; + } + + return null; +} + +export function removeDraftFromGrid(state: AcntgSeGridState, rowKey: string): AcntgSeGridState { + const { [rowKey]: _removed, ...restDrafts } = state.drafts; + const keysToRemove = collectDescendantRowKeys(state.structure, rowKey); + + return { + ...state, + drafts: restDrafts, + structure: removeSlotsFromStructure(state.structure, keysToRemove), + }; +} diff --git a/apps/as/src/pages/acntg-code-management/lib/build-save-multi-payload.ts b/apps/as/src/pages/acntg-code-management/lib/build-save-multi-payload.ts new file mode 100644 index 000000000..f18634b51 --- /dev/null +++ b/apps/as/src/pages/acntg-code-management/lib/build-save-multi-payload.ts @@ -0,0 +1,62 @@ +import { ACNTG_SE_SAVE_MODE, type AcntgSeMngSaveRequestDto } from "@/entities/acntg"; + +import type { AcntgSeRow } from "../model/acntg-se-row-types"; + +import { getVisibleRows } from "./row-state"; + +function compareCdLvNoDesc(a: AcntgSeRow, b: AcntgSeRow): number { + return (b.cdLvNo ?? 1) - (a.cdLvNo ?? 1); +} + +function compareCdLvNoAsc(a: AcntgSeRow, b: AcntgSeRow): number { + return (a.cdLvNo ?? 1) - (b.cdLvNo ?? 1); +} + +export function buildSaveMultiPayload(rows: AcntgSeRow[]): AcntgSeMngSaveRequestDto[] { + const payload: AcntgSeMngSaveRequestDto[] = []; + + const deletedRows = rows.filter((row) => row.isDeleted && !row.isNew).sort(compareCdLvNoDesc); + + for (const row of deletedRows) { + payload.push({ + mode: ACNTG_SE_SAVE_MODE.DELETE, + acntgSeCd: row.acntgSeCd, + acntgSeCdNm: row.acntgSeCdNm?.trim() || "", + upAcntgSeCd: row.upAcntgSeCd, + cdLvNo: row.cdLvNo, + useYn: row.useYn, + expln: row.expln?.trim() || undefined, + }); + } + + const visibleRows = getVisibleRows(rows).sort(compareCdLvNoAsc); + + for (const row of visibleRows) { + if (row.isNew) { + payload.push({ + mode: ACNTG_SE_SAVE_MODE.CREATE, + acntgSeCd: row.acntgSeCd.trim(), + acntgSeCdNm: row.acntgSeCdNm.trim(), + upAcntgSeCd: row.upAcntgSeCd, + cdLvNo: row.cdLvNo, + useYn: row.useYn, + expln: row.expln?.trim() || undefined, + }); + continue; + } + + if (row.isDirty) { + payload.push({ + mode: ACNTG_SE_SAVE_MODE.UPDATE, + acntgSeCd: row.acntgSeCd.trim(), + acntgSeCdNm: row.acntgSeCdNm.trim(), + upAcntgSeCd: row.upAcntgSeCd, + cdLvNo: row.cdLvNo, + useYn: row.useYn, + expln: row.expln?.trim() || undefined, + }); + } + } + + return payload; +} diff --git a/apps/as/src/pages/acntg-code-management/lib/row-state.ts b/apps/as/src/pages/acntg-code-management/lib/row-state.ts new file mode 100644 index 000000000..908df1f70 --- /dev/null +++ b/apps/as/src/pages/acntg-code-management/lib/row-state.ts @@ -0,0 +1,5 @@ +import type { AcntgSeRow } from "../model/acntg-se-row-types"; + +export function getVisibleRows(rows: AcntgSeRow[]): AcntgSeRow[] { + return rows.filter((row) => !row.isDeleted); +} diff --git a/apps/as/src/pages/acntg-code-management/lib/use-acntg-se-rows.ts b/apps/as/src/pages/acntg-code-management/lib/use-acntg-se-rows.ts new file mode 100644 index 000000000..5fd9f85c5 --- /dev/null +++ b/apps/as/src/pages/acntg-code-management/lib/use-acntg-se-rows.ts @@ -0,0 +1,172 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import type { AcntgSeMngResponseDto } from "@/entities/acntg"; + +import type { AcntgSeGridState } from "../model/acntg-se-grid-types"; +import type { AcntgSeRow } from "../model/acntg-se-row-types"; + +import { + createDraftRowKey, + createEmptyGridState, + hasAcntgGridPendingChanges, + hydrateGridFromServer, + insertChildSlot, + insertRootSlot, + markAcntgRowsDeleted, + mergeAcntgRow, + mergeAllAcntgRows, + patchAcntgGridRow, + removeDraftFromGrid, + validateDraftParentAcntgSeCd, +} from "./acntg-se-grid"; +import { buildSaveMultiPayload } from "./build-save-multi-payload"; + +function validateAcntgSeRow(row: AcntgSeRow): string | null { + if (!row.acntgSeCd.trim()) return "코드를 입력하세요."; + if (!row.acntgSeCdNm?.trim()) return "코드명을 입력하세요."; + const parentCd = row.upAcntgSeCd?.trim(); + if (parentCd && row.acntgSeCd.trim() === parentCd) { + return "상위 코드와 동일한 코드는 사용할 수 없습니다."; + } + return null; +} + +interface UseAcntgSeRowsOptions { + serverContent: AcntgSeMngResponseDto[] | undefined; + onAlert: (message: string) => void; + onSave: (payload: ReturnType) => Promise; +} + +export function useAcntgSeRows({ serverContent, onAlert, onSave }: UseAcntgSeRowsOptions) { + const [grid, setGrid] = useState(createEmptyGridState); + const hasPendingRef = useRef(false); + + hasPendingRef.current = hasAcntgGridPendingChanges(grid); + + useEffect(() => { + if (serverContent == null) return; + if (hasPendingRef.current) return; + setGrid(hydrateGridFromServer(serverContent)); + }, [serverContent]); + + const displayRows = useMemo(() => { + return grid.structure + .filter((slot) => !grid.deletedKeys.has(slot.rowKey)) + .map((slot) => { + const row = mergeAcntgRow(grid, slot.rowKey); + return row ? { row, depth: slot.depth } : null; + }) + .filter((item): item is { row: AcntgSeRow; depth: number } => item != null); + }, [grid]); + + const canSave = hasAcntgGridPendingChanges(grid); + + const updateRow = useCallback((rowKey: string, patch: Partial) => { + setGrid((prev) => patchAcntgGridRow(prev, rowKey, patch)); + }, []); + + const addRootRow = useCallback(() => { + const rowKey = createDraftRowKey(); + setGrid((prev) => ({ + ...prev, + drafts: { + ...prev.drafts, + [rowKey]: { + rowKey, + parentRowKey: null, + cdLvNo: 1, + acntgSeCd: "", + acntgSeCdNm: "", + expln: "", + useYn: "Y", + }, + }, + structure: insertRootSlot(prev.structure, { rowKey, depth: 0, parentRowKey: null }), + })); + }, []); + + const addChildRow = useCallback((parent: AcntgSeRow) => { + const rowKey = createDraftRowKey(); + const slot = { + rowKey, + depth: 0, + parentRowKey: parent.rowKey, + }; + + setGrid((prev) => { + const parentSlot = prev.structure.find((item) => item.rowKey === parent.rowKey); + const depth = (parentSlot?.depth ?? 0) + 1; + + return { + ...prev, + drafts: { + ...prev.drafts, + [rowKey]: { + rowKey, + parentRowKey: parent.rowKey, + cdLvNo: (parent.cdLvNo ?? 1) + 1, + acntgSeCd: "", + acntgSeCdNm: "", + expln: "", + useYn: "Y", + }, + }, + structure: insertChildSlot(prev.structure, parent.rowKey, { + ...slot, + depth, + }), + }; + }); + }, []); + + const removeDraft = useCallback((rowKey: string) => { + setGrid((prev) => removeDraftFromGrid(prev, rowKey)); + }, []); + + const markDeleted = useCallback((rowKey: string) => { + setGrid((prev) => markAcntgRowsDeleted(prev, rowKey)); + }, []); + + const resetFromServer = useCallback((content: AcntgSeMngResponseDto[]) => { + setGrid(hydrateGridFromServer(content)); + }, []); + + const save = useCallback(async () => { + const mergedRows = mergeAllAcntgRows(grid); + const payload = buildSaveMultiPayload(mergedRows); + if (payload.length === 0) return false; + + for (const row of mergedRows) { + if (!row.isDeleted && (row.isNew || row.isDirty)) { + const parentCodeError = validateDraftParentAcntgSeCd(grid, row.rowKey); + if (parentCodeError) { + onAlert(parentCodeError); + return false; + } + + const fieldError = validateAcntgSeRow(row); + if (fieldError) { + onAlert(fieldError); + return false; + } + } + } + + await onSave(payload); + return true; + }, [grid, onAlert, onSave]); + + return { + displayRows, + canSave, + updateRow, + addRootRow, + addChildRow, + removeDraft, + markDeleted, + resetFromServer, + save, + }; +} diff --git a/apps/as/src/pages/acntg-code-management/model/acntg-se-grid-types.ts b/apps/as/src/pages/acntg-code-management/model/acntg-se-grid-types.ts new file mode 100644 index 000000000..a115c122d --- /dev/null +++ b/apps/as/src/pages/acntg-code-management/model/acntg-se-grid-types.ts @@ -0,0 +1,34 @@ +import type { AcntgSeMngResponseDto } from "@/entities/acntg"; + +export interface AcntgDisplaySlot { + rowKey: string; + depth: number; + parentRowKey: string | null; +} + +export interface AcntgSeDraft { + rowKey: string; + parentRowKey: string | null; + cdLvNo: number; + acntgSeCd: string; + acntgSeCdNm: string; + expln: string; + useYn: string; +} + +export interface AcntgSeBaseEntry extends AcntgSeMngResponseDto { + rowKey: string; +} + +export interface AcntgSeGridState { + baseByKey: Record; + structure: AcntgDisplaySlot[]; + drafts: Record; + patches: Record>; + deletedKeys: Set; +} + +export interface AcntgSeDisplayItem { + rowKey: string; + depth: number; +} diff --git a/apps/as/src/pages/acntg-code-management/model/acntg-se-row-types.ts b/apps/as/src/pages/acntg-code-management/model/acntg-se-row-types.ts new file mode 100644 index 000000000..7ee397014 --- /dev/null +++ b/apps/as/src/pages/acntg-code-management/model/acntg-se-row-types.ts @@ -0,0 +1,8 @@ +import type { AcntgSeMngResponseDto } from "@/entities/acntg"; + +export interface AcntgSeRow extends AcntgSeMngResponseDto { + rowKey: string; + isNew: boolean; + isDirty: boolean; + isDeleted: boolean; +} diff --git a/apps/as/src/pages/acntg-code-management/model/build-acntg-display-rows.ts b/apps/as/src/pages/acntg-code-management/model/build-acntg-display-rows.ts new file mode 100644 index 000000000..e0e55d02a --- /dev/null +++ b/apps/as/src/pages/acntg-code-management/model/build-acntg-display-rows.ts @@ -0,0 +1,77 @@ +import type { AcntgSeRow } from "./acntg-se-row-types"; + +export interface AcntgSeDisplayRow { + row: AcntgSeRow; + depth: number; +} + +export function formatCdLvNo(lvNo?: number): string { + return `${lvNo ?? 1}단계`; +} + +export function canAddChildRow(): boolean { + return true; +} + +export function formatTreeBranchPrefix(depth: number): string { + if (depth <= 0) return ""; + const vertical = "\u2502 "; + const branch = "\u251C\u2500"; + return vertical.repeat(depth - 1) + branch; +} + +function isRootRow(row: AcntgSeRow): boolean { + return (row.cdLvNo ?? 1) === 1 && !row.upAcntgSeCd?.trim(); +} + +function rowOrderIndex(rows: AcntgSeRow[], row: AcntgSeRow): number { + return rows.findIndex((candidate) => candidate.rowKey === row.rowKey); +} + +function pushDisplayRow( + result: AcntgSeDisplayRow[], + displayedRowKeys: Set, + row: AcntgSeRow, + depth: number, +) { + if (displayedRowKeys.has(row.rowKey)) return; + displayedRowKeys.add(row.rowKey); + result.push({ row, depth }); +} + +export function buildAcntgDisplayRows(rows: AcntgSeRow[]): AcntgSeDisplayRow[] { + const result: AcntgSeDisplayRow[] = []; + const displayedRowKeys = new Set(); + + const roots = rows.filter(isRootRow); + + function appendSubtree(parent: AcntgSeRow, depth: number) { + const children = rows + .filter( + (row) => + row.rowKey !== parent.rowKey && + row.upAcntgSeCd != null && + row.upAcntgSeCd === parent.acntgSeCd && + row.acntgSeCd !== parent.acntgSeCd, + ) + .sort((a, b) => rowOrderIndex(rows, a) - rowOrderIndex(rows, b)); + + for (const child of children) { + pushDisplayRow(result, displayedRowKeys, child, depth); + appendSubtree(child, depth + 1); + } + } + + for (const root of roots) { + pushDisplayRow(result, displayedRowKeys, root, 0); + appendSubtree(root, 1); + } + + const orphans = rows.filter((row) => !displayedRowKeys.has(row.rowKey)); + + for (const orphan of orphans) { + pushDisplayRow(result, displayedRowKeys, orphan, Math.max(0, (orphan.cdLvNo ?? 1) - 1)); + } + + return result; +} diff --git a/apps/as/src/pages/acntg-code-management/ui/acntg-code-management-page.tsx b/apps/as/src/pages/acntg-code-management/ui/acntg-code-management-page.tsx new file mode 100644 index 000000000..d480ced59 --- /dev/null +++ b/apps/as/src/pages/acntg-code-management/ui/acntg-code-management-page.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { useCallback } from "react"; + +import { useConfirm } from "@frontend-opensource/use-react-hooks"; + +import { useAlertStore } from "@/shared/lib"; +import { useAcntgSeMngPage, useSaveMultiAcntgSeMng } from "@/entities/acntg"; +import { + Button, + Skeleton, + Table, + TableBody, + TableHead, + TableHeader, + TableRow, +} from "@common/components"; + +import { useAcntgSeRows } from "../lib/use-acntg-se-rows"; +import type { AcntgSeRow } from "../model/acntg-se-row-types"; + +import { AcntgSeRowComponent } from "./acntg-se-row"; + +function AcntgCodeManagementPage() { + const { confirm } = useConfirm(); + const alert = useAlertStore((state) => state.setMessage); + + const pageQuery = useAcntgSeMngPage({ + page: 0, + size: 500, + sort: "cdLvNo,asc", + }); + + const saveMultiMutation = useSaveMultiAcntgSeMng(); + + const handlePersist = useCallback( + async (payload: Parameters[0]) => { + await saveMultiMutation.mutateAsync(payload); + }, + [saveMultiMutation], + ); + + const { + displayRows, + canSave, + updateRow, + addRootRow, + addChildRow, + removeDraft, + markDeleted, + resetFromServer, + save, + } = useAcntgSeRows({ + serverContent: pageQuery.data?.content, + onAlert: alert, + onSave: handlePersist, + }); + + const handleDelete = async (row: AcntgSeRow) => { + if (!(await confirm("삭제하시겠습니까?"))) return; + + if (row.isNew) { + removeDraft(row.rowKey); + return; + } + + markDeleted(row.rowKey); + }; + + const handleSave = async () => { + if (!(await confirm("저장하시겠습니까?"))) return; + + const saved = await save(); + if (!saved) return; + + alert("저장되었습니다."); + const result = await pageQuery.refetch(); + resetFromServer(result.data?.content ?? []); + }; + + const isSaving = saveMultiMutation.isPending; + + return ( +
+
+
+

회계코드 관리

+
+ + +
+
+ + {pageQuery.isPending ? ( +
+ + +
+ ) : displayRows.length === 0 ? ( +

+ 등록된 코드가 없습니다. [코드 추가]로 1단계 코드를 등록하세요. +

+ ) : ( +
+ + + + + + + + + + + + 코드 + 코드명 + 설명 + 사용 여부 + 구분 + 관리 + + + + {displayRows.map(({ row, depth }) => ( + updateRow(row.rowKey, patch)} + onAddChild={() => addChildRow(row)} + onDelete={() => handleDelete(row)} + /> + ))} + +
+
+ )} +
+
+ ); +} + +export { AcntgCodeManagementPage }; diff --git a/apps/as/src/pages/acntg-code-management/ui/acntg-se-row.tsx b/apps/as/src/pages/acntg-code-management/ui/acntg-se-row.tsx new file mode 100644 index 000000000..69b53ec60 --- /dev/null +++ b/apps/as/src/pages/acntg-code-management/ui/acntg-se-row.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { + Button, + Input, + Label, + RadioGroup, + RadioGroupItem, + TableCell, + TableRow, +} from "@common/components"; + +import type { AcntgSeRow } from "../model/acntg-se-row-types"; +import { + canAddChildRow, + formatCdLvNo, + formatTreeBranchPrefix, +} from "../model/build-acntg-display-rows"; + +interface AcntgSeRowProps { + row: AcntgSeRow; + depth: number; + onChange: (patch: Partial) => void; + onAddChild: () => void; + onDelete: () => void; +} + +function AcntgSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: AcntgSeRowProps) { + const levelLabel = formatCdLvNo(row.cdLvNo); + const branchPrefix = formatTreeBranchPrefix(depth); + + return ( + + +
+ {branchPrefix ? ( + {branchPrefix} + ) : null} + onChange({ acntgSeCd: e.target.value })} + placeholder="코드" + maxLength={10} + disabled={!row.isNew} + className="flex-1" + /> +
+
+ + onChange({ acntgSeCdNm: e.target.value })} + placeholder="코드명" + maxLength={100} + /> + + + onChange({ expln: e.target.value })} + placeholder="설명" + maxLength={1000} + /> + + + onChange({ useYn: v })} + className="flex justify-center gap-4" + > +
+ + +
+
+ + +
+
+
+ {levelLabel} + +
+ + +
+
+
+ ); +} + +export { AcntgSeRowComponent }; diff --git a/apps/as/src/pages/approval-line-management/lib/build-save-multi-payload.ts b/apps/as/src/pages/approval-line-management/lib/build-save-multi-payload.ts new file mode 100644 index 000000000..0c66b7623 --- /dev/null +++ b/apps/as/src/pages/approval-line-management/lib/build-save-multi-payload.ts @@ -0,0 +1,70 @@ +import { ELAPR_APLN_SAVE_MODE, type ElaprApln, type ElaprAplnSaveRequest } from "@/entities/elapr"; + +import { assignAtrzSeqNumbers } from "./assign-atrz-seq"; +import { getVisibleRows } from "./validators"; +import type { ElaprAplnRow } from "../model/apln-row-types"; + +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 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/pages/approval-line-management/lib/has-unsaved-changes.ts b/apps/as/src/pages/approval-line-management/lib/has-unsaved-changes.ts new file mode 100644 index 000000000..46efa59f3 --- /dev/null +++ b/apps/as/src/pages/approval-line-management/lib/has-unsaved-changes.ts @@ -0,0 +1,32 @@ +import type { ElaprApln } from "@/entities/elapr"; + +import { assignAtrzSeqNumbers } from "./assign-atrz-seq"; +import { getVisibleRows } from "./validators"; +import type { ElaprAplnRow } from "../model/apln-row-types"; + +function rowDiffersFromServer(row: ElaprAplnRow, serverRow: ElaprApln): boolean { + return ( + row.aprvrUuid !== serverRow.aprvrUuid || + row.atrzSeComCd !== serverRow.atrzSeComCd || + row.atrzSeqNo !== serverRow.atrzSeqNo + ); +} + +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; +} diff --git a/apps/as/src/pages/approval-line-management/lib/use-apln-rows.ts b/apps/as/src/pages/approval-line-management/lib/use-apln-rows.ts index 26dda19be..01c71ce3b 100644 --- a/apps/as/src/pages/approval-line-management/lib/use-apln-rows.ts +++ b/apps/as/src/pages/approval-line-management/lib/use-apln-rows.ts @@ -9,10 +9,17 @@ import type { ElaprApln } from "@/entities/elapr"; import type { ElaprAplnRow } from "../model/apln-row-types"; import { assignAtrzSeqNumbers } from "./assign-atrz-seq"; -import { validateAtrzOrder } from "./validators"; +import { getVisibleRows } from "./validators"; function toRow(item: ElaprApln): ElaprAplnRow { - return { ...item, rowKey: item.aplnId, isNew: false, isDirty: false }; + return { + ...item, + rowKey: item.aplnId, + aprvrNm: item.aprvrNm, + isNew: false, + isDirty: false, + isDeleted: false, + }; } interface UseAplnRowsOptions { @@ -39,6 +46,7 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) { }, [elaprTmpltId, serverRows]); const isEditingLocked = editingRowKey != null; + const visibleRows = getVisibleRows(rows); const addRow = useCallback(() => { if (!elaprTmpltId || isEditingLocked) return; @@ -55,6 +63,7 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) { aprvrNm: "", isNew: true, isDirty: true, + isDeleted: false, }, ]); setEditingRowKey(rowKey); @@ -92,8 +101,8 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) { return prev.map((row) => { if (row.rowKey !== editingRowKey) return row; if (row.snapshot) { - const { snapshot: _s, isNew: _n, isDirty: _d, ...restored } = row.snapshot; - return { ...restored, isNew: false, isDirty: false }; + const { snapshot: _s, isNew: _n, isDirty: _d, isDeleted: _del, ...restored } = row.snapshot; + return { ...restored, isNew: false, isDirty: false, isDeleted: false }; } return { ...row, isDirty: false }; }); @@ -101,52 +110,68 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) { setEditingRowKey(null); }, [editingRowKey]); - const clearEditing = useCallback(() => { - setEditingRowKey(null); - }, []); + const commitEdit = useCallback( + (rowKey: string) => { + setRows((prev) => + prev.map((row) => { + if (row.rowKey !== rowKey) return row; + const { snapshot: _s, ...committed } = row; + return { ...committed, isDirty: true }; + }), + ); + setEditingRowKey(null); + }, + [], + ); - const removeRowLocal = useCallback((rowKey: string) => { - setRows((prev) => prev.filter((r) => r.rowKey !== rowKey)); + 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, - ): { payloadRows?: ElaprAplnRow[]; orderError?: string | null } => { - if (isEditingLocked) return {}; + (activeKey: string, overKey: string): ElaprAplnRow[] => { + if (isEditingLocked) return []; let payloadRows: ElaprAplnRow[] = []; - let orderError: string | null = null; setRows((prev) => { - const oldIndex = prev.findIndex((r) => r.rowKey === activeKey); - const newIndex = prev.findIndex((r) => r.rowKey === overKey); + 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 moved = assignAtrzSeqNumbers(arrayMove(prev, oldIndex, newIndex)).map((row) => ({ - ...row, - isDirty: true, - })); + const reorderedVisible = assignAtrzSeqNumbers(arrayMove(visible, oldIndex, newIndex)).map( + (row) => ({ ...row, isDirty: true }), + ); - const error = validateAtrzOrder(moved); - if (error) { - orderError = error; - return prev; - } + 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 = moved; - return moved; + payloadRows = reorderedVisible; + return merged; }); - return { payloadRows, orderError }; + return payloadRows; }, [isEditingLocked], ); return { rows, + visibleRows, setRows, editingRowKey, isEditingLocked, @@ -154,8 +179,8 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) { startEdit, updateRow, cancelEdit, - clearEditing, - removeRowLocal, + commitEdit, + markDeleted, reorderRows, }; } diff --git a/apps/as/src/pages/approval-line-management/lib/validators.ts b/apps/as/src/pages/approval-line-management/lib/validators.ts index a61de75b8..4e301afa9 100644 --- a/apps/as/src/pages/approval-line-management/lib/validators.ts +++ b/apps/as/src/pages/approval-line-management/lib/validators.ts @@ -1,37 +1,40 @@ import type { ElaprAplnRow } from "../model/apln-row-types"; -export const ATRZ_REVIEWER_CD = "ATRZ01"; -export const ATRZ_FINAL_CD = "ATRZ03"; +const ATRZ_RECEIVER_CD = "ATRZ01"; +const ATRZ_FINAL_CD = "ATRZ99"; -/** 검토자 최상단 · 최종승인자 최하단 순서 검증 */ +export function getVisibleRows(rows: ElaprAplnRow[]): ElaprAplnRow[] { + return rows.filter((row) => !row.isDeleted); +} + +/** 접수자·최종승인자 위치·중복 검증 (최종 저장 시점) */ export function validateAtrzOrder(rows: ElaprAplnRow[]): string | null { - const reviewerIndices = rows - .map((row, index) => (row.atrzSeComCd === ATRZ_REVIEWER_CD ? index : -1)) - .filter((index) => index >= 0); + const visibleRows = getVisibleRows(rows); - if (reviewerIndices.length > 0) { - const lastReviewerIndex = Math.max(...reviewerIndices); - const hasNonReviewerAbove = rows - .slice(0, lastReviewerIndex) - .some((row) => row.atrzSeComCd && row.atrzSeComCd !== ATRZ_REVIEWER_CD); - - if (hasNonReviewerAbove) { - return "검토자는 결재선 최상단에만 배치할 수 있습니다."; - } + const receiverCount = visibleRows.filter((row) => row.atrzSeComCd === ATRZ_RECEIVER_CD).length; + if (receiverCount === 0) { + return "접수자는 반드시 등록해야 합니다."; + } + if (receiverCount > 1) { + return "접수자는 한 명만 등록할 수 있습니다."; } - const finalIndices = rows - .map((row, index) => (row.atrzSeComCd === ATRZ_FINAL_CD ? index : -1)) - .filter((index) => index >= 0); + const finalCount = visibleRows.filter((row) => row.atrzSeComCd === ATRZ_FINAL_CD).length; + if (finalCount === 0) { + return "최종승인자는 반드시 등록해야 합니다."; + } + if (finalCount > 1) { + return "최종승인자는 한 명만 등록할 수 있습니다."; + } - if (finalIndices.length > 0) { - const firstFinalIndex = Math.min(...finalIndices); - const hasNonFinalBelow = rows - .slice(firstFinalIndex + 1) - .some((row) => row.atrzSeComCd && row.atrzSeComCd !== ATRZ_FINAL_CD); + if (receiverCount === 1 && visibleRows[0]?.atrzSeComCd !== ATRZ_RECEIVER_CD) { + return "접수자는 결재선 첫 번째에 배치해야 합니다."; + } - if (hasNonFinalBelow) { - return "최종승인자는 결재선 최하단에만 배치할 수 있습니다."; + if (finalCount === 1) { + const lastRow = visibleRows[visibleRows.length - 1]; + if (lastRow?.atrzSeComCd !== ATRZ_FINAL_CD) { + return "최종승인자는 결재선 마지막에 배치해야 합니다."; } } @@ -48,9 +51,30 @@ export function validateAplnRow(row: ElaprAplnRow): string | null { 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 rows) { + for (const row of getVisibleRows(rows)) { if (row.atrzSeqNo == null) continue; if (seqSet.has(row.atrzSeqNo)) { return "동일 서식 내 결재순서번호가 중복됩니다."; diff --git a/apps/as/src/pages/approval-line-management/model/apln-row-types.ts b/apps/as/src/pages/approval-line-management/model/apln-row-types.ts index b2066fc78..cabc7bb08 100644 --- a/apps/as/src/pages/approval-line-management/model/apln-row-types.ts +++ b/apps/as/src/pages/approval-line-management/model/apln-row-types.ts @@ -5,10 +5,12 @@ export interface ElaprAplnRow extends Omit { /** React key · DnD · 편집 잠금용 — 서버 aplnId와 분리 */ rowKey: string; aplnId?: string; - /** 결재자 표시명 — 응답 DTO에 없음, 선택 시 로컬 유지 */ + /** 결재자 표시명 — 응답 DTO 또는 선택 시 로컬 유지 */ aprvrNm?: string; isNew?: boolean; isDirty?: boolean; + /** 소프트 삭제 — 최종 저장 시 mode:D */ + isDeleted?: boolean; /** 수정 취소용 스냅샷 */ snapshot?: ElaprAplnRow; } diff --git a/apps/as/src/pages/approval-line-management/model/columns.tsx b/apps/as/src/pages/approval-line-management/model/columns.tsx index aa00e9afc..a7beb668b 100644 --- a/apps/as/src/pages/approval-line-management/model/columns.tsx +++ b/apps/as/src/pages/approval-line-management/model/columns.tsx @@ -35,10 +35,10 @@ export function createTmpltColumns({ onEdit, onDelete }: CreateTmpltColumnsOptio header: "관리", cell: ({ row }) => (
e.stopPropagation()}> - -
diff --git a/apps/as/src/pages/approval-line-management/model/mock-reviewers.ts b/apps/as/src/pages/approval-line-management/model/mock-reviewers.ts deleted file mode 100644 index 863331be6..000000000 --- a/apps/as/src/pages/approval-line-management/model/mock-reviewers.ts +++ /dev/null @@ -1,17 +0,0 @@ -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/pages/approval-line-management/model/reviewer-option.ts b/apps/as/src/pages/approval-line-management/model/reviewer-option.ts new file mode 100644 index 000000000..cb79bbf13 --- /dev/null +++ b/apps/as/src/pages/approval-line-management/model/reviewer-option.ts @@ -0,0 +1,15 @@ +import type { RvwMnpwMngResponseDto } from "@/entities/rvw-mnpw"; + +export interface ReviewerOption { + uuid: string; + name: string; + deptNm: string; +} + +export function fromRvwMnpwMng(item: RvwMnpwMngResponseDto): ReviewerOption { + return { + uuid: item.userUuid, + name: item.userUuidNm ?? "", + deptNm: item.ognzNm ?? "", + }; +} diff --git a/apps/as/src/pages/approval-line-management/ui/aprvr-picker-dialog.tsx b/apps/as/src/pages/approval-line-management/ui/aprvr-picker-dialog.tsx index 6f20a1db3..38f7c810d 100644 --- a/apps/as/src/pages/approval-line-management/ui/aprvr-picker-dialog.tsx +++ b/apps/as/src/pages/approval-line-management/ui/aprvr-picker-dialog.tsx @@ -1,90 +1,120 @@ "use client"; -import { useMemo, useState } from "react"; - -import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table"; - +import { useRvwMnpwMngPage } from "@/entities/rvw-mnpw"; import { Button, - DataTable, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, - Input, + Skeleton, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, } from "@common/components"; -import type { ReviewerOption } from "../model/mock-reviewers"; -import { createMockReviewers } from "../model/mock-reviewers"; +import { fromRvwMnpwMng, type ReviewerOption } from "../model/reviewer-option"; + +const PAGE_SIZE = 500; interface AprvrPickerDialogProps { open: boolean; onOpenChange: (open: boolean) => void; + atrzSeComCd: string; 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], +function AprvrPickerDialog({ + open, + onOpenChange, + atrzSeComCd, + onSelect, +}: AprvrPickerDialogProps) { + const { data, isPending, isError } = useRvwMnpwMngPage( + { + atrzSeComCd, + page: 0, + size: PAGE_SIZE, + sort: "userUuidNm,asc", + }, + { enabled: open }, ); - const table = useReactTable({ - data: filtered, - columns, - getCoreRowModel: getCoreRowModel(), - }); + const items = data?.content ?? []; + + const handleSelect = (item: (typeof items)[number]) => { + onSelect(fromRvwMnpwMng(item)); + onOpenChange(false); + }; return ( - + 검토인력 선택 -
- setKeyword(e.target.value)} - /> - + +
+ {isPending ? ( +
+ + +
+ ) : isError ? ( +

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

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

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

+ ) : ( + + + + + + + + + + + 성명 + 부서 + 직위 + 직급 + 선택 + + + + {items.map((item) => ( + + + {item.userUuidNm || item.userUuid} + + {item.ognzNm || "-"} + {item.jbpsSeNm || "-"} + {item.jbgdSeNm || "-"} + + + + + ))} + +
+ )}
+ - diff --git a/apps/as/src/pages/approval-line-management/ui/elapr-apln-grid.tsx b/apps/as/src/pages/approval-line-management/ui/elapr-apln-grid.tsx index e3647d441..c2393715b 100644 --- a/apps/as/src/pages/approval-line-management/ui/elapr-apln-grid.tsx +++ b/apps/as/src/pages/approval-line-management/ui/elapr-apln-grid.tsx @@ -4,14 +4,10 @@ import { useState } from "react"; import { closestCenter, DndContext, type DragEndEvent } from "@dnd-kit/core"; import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { useAlertStore } from "@/shared/lib"; -import { - useCreateElaprApln, - useDeleteElaprApln, - useElaprAplnPage, - useUpdateElaprApln, -} from "@/entities/elapr"; +import { useElaprAplnPage, useSaveMultiElaprApln } from "@/entities/elapr"; import { Button, Skeleton, @@ -22,12 +18,18 @@ import { TableRow, } from "@common/components"; -import { assignAtrzSeqNumbers } from "../lib/assign-atrz-seq"; +import { buildSaveMultiPayload } from "../lib/build-save-multi-payload"; +import { hasUnsavedChanges } from "../lib/has-unsaved-changes"; import { useAplnRows } from "../lib/use-apln-rows"; import { useDndSensors } from "../lib/use-dnd-sensors"; -import { validateAplnRow, validateAtrzOrder } from "../lib/validators"; +import { + validateAllRowsForSave, + validateAplnRow, + validateAtrzOrder, + validateUniqueSeqNo, +} from "../lib/validators"; import type { ElaprAplnRow } from "../model/apln-row-types"; -import type { ReviewerOption } from "../model/mock-reviewers"; +import type { ReviewerOption } from "../model/reviewer-option"; import { AprvrPickerDialog } from "./aprvr-picker-dialog"; import { SortableAplnRow } from "./sortable-apln-row"; @@ -37,6 +39,7 @@ interface ElaprAplnGridProps { } function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) { + const { confirm } = useConfirm(); const [pickerOpen, setPickerOpen] = useState(false); const [pickingRowKey, setPickingRowKey] = useState(null); @@ -49,20 +52,19 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) { sort: "atrzSeqNo,asc", }); - const createMutation = useCreateElaprApln(); - const updateMutation = useUpdateElaprApln(); - const deleteMutation = useDeleteElaprApln(); + const saveMultiMutation = useSaveMultiElaprApln(); const { rows, + visibleRows, editingRowKey, isEditingLocked, addRow, startEdit, updateRow, cancelEdit, - clearEditing, - removeRowLocal, + commitEdit, + markDeleted, reorderRows, } = useAplnRows({ elaprTmpltId, @@ -71,116 +73,65 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) { const sensors = useDndSensors(); const isDisabled = !elaprTmpltId; + const serverRows = aplnQuery.data?.content ?? []; + const canSave = hasUnsavedChanges(rows, serverRows); + const pickingRow = pickingRowKey ? rows.find((r) => r.rowKey === pickingRowKey) : undefined; const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) return; - const { orderError } = reorderRows(String(active.id), String(over.id)); - if (orderError) { - alert(orderError); - } + reorderRows(String(active.id), String(over.id)); }; const handleRowChange = (row: ElaprAplnRow, patch: Partial) => { updateRow(row.rowKey, patch); }; - const handleRowSave = async (row: ElaprAplnRow) => { + const handleRowSave = (row: ElaprAplnRow) => { const fieldError = validateAplnRow(row); if (fieldError) { alert(fieldError); return; } - const rowsWithSeq = assignAtrzSeqNumbers(rows.map((r) => (r.rowKey === row.rowKey ? row : r))); - const savedRow = rowsWithSeq.find((r) => r.rowKey === row.rowKey); - if (!savedRow) return; - - const orderError = validateAtrzOrder(rowsWithSeq); - if (orderError) { - alert(orderError); - return; - } - - if (!elaprTmpltId) return; - - if (savedRow.isNew) { - await createMutation.mutateAsync({ - elaprTmpltId, - aprvrUuid: savedRow.aprvrUuid, - atrzSeComCd: savedRow.atrzSeComCd, - atrzSeqNo: savedRow.atrzSeqNo, - }); - } else if (savedRow.aplnId) { - await updateMutation.mutateAsync({ - aplnId: savedRow.aplnId, - body: { - elaprTmpltId, - aprvrUuid: savedRow.aprvrUuid, - atrzSeComCd: savedRow.atrzSeComCd, - atrzSeqNo: savedRow.atrzSeqNo, - }, - }); - } - - clearEditing(); - await aplnQuery.refetch(); + commitEdit(row.rowKey); }; const handleRowDelete = async (row: ElaprAplnRow) => { - if (!window.confirm("선택한 결재선을 삭제하시겠습니까?")) return; + markDeleted(row.rowKey); + }; - if (row.isNew) { - removeRowLocal(row.rowKey); + const handleSaveAll = async () => { + if (!elaprTmpltId) return; + + const rowsError = validateAllRowsForSave(rows, isEditingLocked); + if (rowsError) { + alert(rowsError); return; } - if (!elaprTmpltId || !row.aplnId) return; - - await deleteMutation.mutateAsync({ aplnId: row.aplnId, elaprTmpltId }); - await aplnQuery.refetch(); - }; - - const handleBulkSave = async () => { - if (!elaprTmpltId || isEditingLocked) return; - - const rowsWithSeq = assignAtrzSeqNumbers(rows); - const orderError = validateAtrzOrder(rowsWithSeq); + const orderError = validateAtrzOrder(rows); if (orderError) { alert(orderError); return; } - const dirtyRows = rowsWithSeq.filter((r) => r.isDirty || r.isNew); - - for (const row of dirtyRows) { - const fieldError = validateAplnRow(row); - if (fieldError) { - alert(fieldError); - return; - } - - if (row.isNew) { - await createMutation.mutateAsync({ - elaprTmpltId, - aprvrUuid: row.aprvrUuid, - atrzSeComCd: row.atrzSeComCd, - atrzSeqNo: row.atrzSeqNo, - }); - } else if (row.aplnId) { - await updateMutation.mutateAsync({ - aplnId: row.aplnId, - body: { - elaprTmpltId, - aprvrUuid: row.aprvrUuid, - atrzSeComCd: row.atrzSeComCd, - atrzSeqNo: row.atrzSeqNo, - }, - }); - } + const seqError = validateUniqueSeqNo(rows); + if (seqError) { + alert(seqError); + return; } + const payload = buildSaveMultiPayload(rows, elaprTmpltId, serverRows); + if (payload.length === 0) { + alert("저장할 변경 사항이 없습니다."); + return; + } + + if (!(await confirm("결재선을 저장하시겠습니까?"))) return; + + await saveMultiMutation.mutateAsync({ body: payload, elaprTmpltId }); alert("결재선이 저장되었습니다."); await aplnQuery.refetch(); }; @@ -201,13 +152,17 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
-
@@ -222,13 +177,22 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {

상단 목록에서 결재서식을 선택해 주세요.

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

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

) : ( + + + + + + + + + @@ -242,10 +206,10 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) { r.rowKey)} + items={visibleRows.map((r) => r.rowKey)} strategy={verticalListSortingStrategy} > - {rows.map((row, index) => ( + {visibleRows.map((row, index) => ( diff --git a/apps/as/src/pages/approval-line-management/ui/elapr-tmplt-list.tsx b/apps/as/src/pages/approval-line-management/ui/elapr-tmplt-list.tsx index ae2b97e4c..7955f8b6b 100644 --- a/apps/as/src/pages/approval-line-management/ui/elapr-tmplt-list.tsx +++ b/apps/as/src/pages/approval-line-management/ui/elapr-tmplt-list.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from "react"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { getCoreRowModel, type RowSelectionState } from "@tanstack/react-table"; import { useDataTable } from "@/shared/lib"; @@ -24,6 +25,7 @@ interface ElaprTmpltListProps { } function ElaprTmpltList({ selectedTmpltId, onSelectTmplt }: ElaprTmpltListProps) { + const { confirm } = useConfirm(); const [dialogMode, setDialogMode] = useState<"create" | "edit" | null>(null); const [editingTmplt, setEditingTmplt] = useState(null); const [rowSelection, setRowSelection] = useState({}); @@ -46,14 +48,14 @@ function ElaprTmpltList({ selectedTmpltId, onSelectTmplt }: ElaprTmpltListProps) setDialogMode("edit"); }, onDelete: async (row) => { - if (!window.confirm("선택한 결재서식을 삭제하시겠습니까?")) return; + if (!(await confirm("선택한 결재서식을 삭제하시겠습니까?"))) return; await deleteMutation.mutateAsync(row.elaprTmpltId); if (selectedTmpltId === row.elaprTmpltId) { onSelectTmplt(null); } }, }), - [deleteMutation, onSelectTmplt, selectedTmpltId], + [confirm, deleteMutation, onSelectTmplt, selectedTmpltId], ); const { tableComponent } = useDataTable({ @@ -112,7 +114,7 @@ function ElaprTmpltList({ selectedTmpltId, onSelectTmplt }: ElaprTmpltListProps)

전자결재선 관리 목록

-
diff --git a/apps/as/src/pages/approval-line-management/ui/sortable-apln-row.tsx b/apps/as/src/pages/approval-line-management/ui/sortable-apln-row.tsx index c9fc44af6..fcb315bdb 100644 --- a/apps/as/src/pages/approval-line-management/ui/sortable-apln-row.tsx +++ b/apps/as/src/pages/approval-line-management/ui/sortable-apln-row.tsx @@ -36,7 +36,7 @@ function SortableAplnRow({ onChange, onPickAprvr, }: SortableAplnRowProps) { - const { data: atrzSeCodes } = useCodeList("ATRZSECD"); + const { data: atrzSeCodes } = useCodeList("ATRZSE"); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: row.rowKey, @@ -50,7 +50,7 @@ function SortableAplnRow({ }; const rowDisabled = isLocked && !isEditing; - const atrzSeLabel = row.atrzSeComNm ?? findCodeLabel(atrzSeCodes, row.atrzSeComCd); + const atrzSeLabel = row.atrzSeComNm || findCodeLabel(atrzSeCodes, row.atrzSeComCd); return ( @@ -72,7 +72,7 @@ function SortableAplnRow({ {isEditing ? ( @@ -103,7 +103,7 @@ function SortableAplnRow({ {isEditing ? ( <> - diff --git a/apps/as/src/pages/auth/login/ui/login-page.tsx b/apps/as/src/pages/auth/login/ui/login-page.tsx index 60faf66bb..be1c65e13 100644 --- a/apps/as/src/pages/auth/login/ui/login-page.tsx +++ b/apps/as/src/pages/auth/login/ui/login-page.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import Image from "next/image"; -import { LoginApi } from "@/entities/auth"; +import { authService } from "@/entities/auth"; import { images } from "@common/assets"; import "./login.css"; @@ -20,7 +20,7 @@ function LoginPage() { const password = formData.get("password") as string; try { - await LoginApi.login({ userId, password }); + await authService.login({ userId, password }); window.location.href = "/"; } catch (error) { const message = error instanceof Error ? error.message : "로그인에 실패했습니다."; diff --git a/apps/as/src/pages/checklist-management/index.ts b/apps/as/src/pages/checklist-management/index.ts new file mode 100644 index 000000000..fbd074ed9 --- /dev/null +++ b/apps/as/src/pages/checklist-management/index.ts @@ -0,0 +1 @@ +export { ChecklistManagementPage } from "./ui/checklist-management-page"; diff --git a/apps/as/src/pages/checklist-management/model/build-rvw-display-rows.ts b/apps/as/src/pages/checklist-management/model/build-rvw-display-rows.ts new file mode 100644 index 000000000..f82fc9a96 --- /dev/null +++ b/apps/as/src/pages/checklist-management/model/build-rvw-display-rows.ts @@ -0,0 +1,90 @@ +import type { CklstRvwRow } from "./cklst-rvw-row-types"; + +export const MAX_RVW_CN_LV_NO = 3; + +export interface CklstRvwDisplayRow { + row: CklstRvwRow; + depth: number; +} + +export function formatRvwCnLvNo(lvNo?: number): string { + if (lvNo === 3) return "3단계"; + if (lvNo === 2) return "2단계"; + return "1단계"; +} + +export function canAddChildRow(lvNo?: number): boolean { + return (lvNo ?? 1) < MAX_RVW_CN_LV_NO; +} + +const TREE_BRANCH_ENTITY = { + horizontal: "─", + vertical: "│", + branch: "├", +} as const; + +const TREE_BRANCH_ENTITY_CHARS: Record = { + [TREE_BRANCH_ENTITY.horizontal]: "\u2500", + [TREE_BRANCH_ENTITY.vertical]: "\u2502", + [TREE_BRANCH_ENTITY.branch]: "\u251C", +}; + +function decodeTreeBranchEntityCodes(value: string): string { + return Object.entries(TREE_BRANCH_ENTITY_CHARS).reduce( + (text, [entity, char]) => text.replaceAll(entity, char), + value, + ); +} + +export function formatTreeBranchPrefix(depth: number): string { + if (depth === 1) + return decodeTreeBranchEntityCodes( + `${TREE_BRANCH_ENTITY.branch}${TREE_BRANCH_ENTITY.horizontal}`, + ); + if (depth === 2) { + return decodeTreeBranchEntityCodes( + `${TREE_BRANCH_ENTITY.vertical} ${TREE_BRANCH_ENTITY.branch}${TREE_BRANCH_ENTITY.horizontal}`, + ); + } + return ""; +} + +function compareSortSeq(a: CklstRvwRow, b: CklstRvwRow): number { + return (a.sortSeq ?? 0) - (b.sortSeq ?? 0); +} + +export function buildRvwDisplayRows(rows: CklstRvwRow[]): CklstRvwDisplayRow[] { + const result: CklstRvwDisplayRow[] = []; + + const roots = rows + .filter((row) => (row.rvwCnLvNo ?? 1) === 1 && row.upCklstRvwSn == null) + .sort(compareSortSeq); + + function appendSubtree(parent: CklstRvwRow, depth: number) { + const children = rows + .filter((row) => row.upCklstRvwSn != null && row.upCklstRvwSn === parent.cklstRvwSn) + .sort(compareSortSeq); + + for (const child of children) { + result.push({ row: child, depth }); + if (depth < MAX_RVW_CN_LV_NO - 1) { + appendSubtree(child, depth + 1); + } + } + } + + for (const root of roots) { + result.push({ row: root, depth: 0 }); + appendSubtree(root, 1); + } + + const orphans = rows.filter( + (row) => !result.some((display) => display.row.rowKey === row.rowKey), + ); + + for (const orphan of orphans.sort(compareSortSeq)) { + result.push({ row: orphan, depth: Math.max(0, (orphan.rvwCnLvNo ?? 1) - 1) }); + } + + return result; +} diff --git a/apps/as/src/pages/checklist-management/model/cklst-artcl-columns.tsx b/apps/as/src/pages/checklist-management/model/cklst-artcl-columns.tsx new file mode 100644 index 000000000..cc519945e --- /dev/null +++ b/apps/as/src/pages/checklist-management/model/cklst-artcl-columns.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { createColumnHelper } from "@tanstack/react-table"; + +import type { CklstArtcl } from "@/entities/cklst"; +import { Button } from "@common/components"; + +const columnHelper = createColumnHelper(); + +interface CreateCklstArtclColumnsOptions { + onEdit: (row: CklstArtcl) => void; + onDelete: (row: CklstArtcl) => void; +} + +export function createCklstArtclColumns({ onEdit, onDelete }: CreateCklstArtclColumnsOptions) { + return [ + columnHelper.accessor("cklstArtclSn", { + header: "점검항목코드", + meta: { align: "center" }, + }), + columnHelper.accessor("cklstArtclNm", { + header: "점검항목명", + cell: ({ getValue }) => getValue() ?? "-", + }), + columnHelper.accessor("useYn", { + header: "사용여부", + cell: ({ getValue }) => { + const value = getValue(); + return value === "Y" ? "예" : value === "N" ? "아니오" : "-"; + }, + }), + columnHelper.accessor("regDt", { + header: "등록일시", + cell: ({ getValue }) => getValue() ?? "-", + }), + columnHelper.display({ + id: "actions", + header: "관리", + cell: ({ row }) => ( +
+ + +
+ ), + }), + ]; +} diff --git a/apps/as/src/pages/checklist-management/model/cklst-mng-columns.tsx b/apps/as/src/pages/checklist-management/model/cklst-mng-columns.tsx new file mode 100644 index 000000000..fd74d47ce --- /dev/null +++ b/apps/as/src/pages/checklist-management/model/cklst-mng-columns.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { createColumnHelper } from "@tanstack/react-table"; + +import type { CklstMng } from "@/entities/cklst"; +import { Button } from "@common/components"; + +type CklstMngRow = CklstMng & { + docSeComNm?: string; + taskSeCdNm?: string; +}; + +const columnHelper = createColumnHelper(); + +interface CreateCklstMngColumnsOptions { + onEdit: (row: CklstMngRow) => void; + onDelete: (row: CklstMngRow) => void; +} + +export function createCklstMngColumns({ onEdit, onDelete }: CreateCklstMngColumnsOptions) { + return [ + columnHelper.accessor("cklstSn", { + header: "점검표일련번호", + meta: { align: "center" }, + }), + columnHelper.accessor("cklstNm", { + header: "점검표명", + meta: { align: "left" }, + }), + columnHelper.accessor("docSeComNm", { + header: "문서구분", + cell: ({ getValue, row }) => getValue() ?? row.original.docSeComCd ?? "-", + }), + columnHelper.accessor("taskSeCdNm", { + header: "업무구분", + cell: ({ getValue, row }) => getValue() ?? row.original.taskSeComCd ?? "-", + }), + columnHelper.accessor("useYn", { + header: "사용여부", + cell: ({ getValue }) => { + const value = getValue(); + return value === "Y" ? "예" : value === "N" ? "아니오" : "-"; + }, + }), + columnHelper.accessor("regDt", { + header: "등록일시", + cell: ({ getValue }) => getValue() ?? "-", + }), + columnHelper.display({ + id: "actions", + header: "관리", + cell: ({ row }) => ( +
e.stopPropagation()}> + + +
+ ), + }), + ]; +} diff --git a/apps/as/src/pages/checklist-management/model/cklst-rvw-row-types.ts b/apps/as/src/pages/checklist-management/model/cklst-rvw-row-types.ts new file mode 100644 index 000000000..d77ecc200 --- /dev/null +++ b/apps/as/src/pages/checklist-management/model/cklst-rvw-row-types.ts @@ -0,0 +1,8 @@ +import type { CklstRvw } from "@/entities/cklst"; + +export interface CklstRvwRow extends Omit { + cklstRvwSn?: number; + rowKey: string; + isNew: boolean; + isDirty: boolean; +} diff --git a/apps/as/src/pages/checklist-management/ui/checklist-management-page.tsx b/apps/as/src/pages/checklist-management/ui/checklist-management-page.tsx new file mode 100644 index 000000000..d49d7ed6a --- /dev/null +++ b/apps/as/src/pages/checklist-management/ui/checklist-management-page.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useState } from "react"; + +import type { CklstMng } from "@/entities/cklst"; + +import { CklstArtclList } from "./cklst-artcl-list"; +import { CklstMngList } from "./cklst-mng-list"; +import { CklstRvwGrid } from "./cklst-rvw-grid"; + +function ChecklistManagementPage() { + const [selectedCklst, setSelectedCklst] = useState(null); + + return ( +
+
+ + +
+ +
+ ); +} + +export { ChecklistManagementPage }; diff --git a/apps/as/src/pages/checklist-management/ui/cklst-artcl-form-dialog.tsx b/apps/as/src/pages/checklist-management/ui/cklst-artcl-form-dialog.tsx new file mode 100644 index 000000000..0ce48cd1a --- /dev/null +++ b/apps/as/src/pages/checklist-management/ui/cklst-artcl-form-dialog.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; + +import type { CklstArtcl } from "@/entities/cklst"; +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@common/components"; + +export interface CklstArtclFormValues { + cklstArtclNm: string; + useYn: "Y" | "N"; +} + +interface CklstArtclFormDialogProps { + mode: "create" | "edit"; + open: boolean; + onOpenChange: (open: boolean) => void; + initial?: CklstArtcl | null; + isSubmitting?: boolean; + onSubmit: (values: CklstArtclFormValues) => void; +} + +function CklstArtclFormDialog({ + mode, + open, + onOpenChange, + initial, + isSubmitting, + onSubmit, +}: CklstArtclFormDialogProps) { + const { register, handleSubmit, reset, setValue, watch } = useForm({ + defaultValues: { + cklstArtclNm: "", + useYn: "Y", + }, + }); + + const useYn = watch("useYn"); + + useEffect(() => { + if (!open) return; + + if (mode === "edit" && initial) { + reset({ + cklstArtclNm: initial.cklstArtclNm ?? "", + useYn: initial.useYn ?? "Y", + }); + return; + } + + reset({ + cklstArtclNm: "", + useYn: "Y", + }); + }, [open, mode, initial, reset]); + + return ( + + + + {mode === "create" ? "점검항목 추가" : "점검항목 수정"} + +
+
+ + +
+
+ + +
+ + + + + +
+
+ ); +} + +export { CklstArtclFormDialog }; diff --git a/apps/as/src/pages/checklist-management/ui/cklst-artcl-list.tsx b/apps/as/src/pages/checklist-management/ui/cklst-artcl-list.tsx new file mode 100644 index 000000000..480ab9508 --- /dev/null +++ b/apps/as/src/pages/checklist-management/ui/cklst-artcl-list.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useMemo, useState } from "react"; + +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { getCoreRowModel, useReactTable } from "@tanstack/react-table"; + +import { matchQueryStatus } from "@/shared/lib"; +import { NoData } from "@/shared/ui/no-data"; +import { + type CklstArtcl, + useCklstArtclList, + useCreateCklstArtcl, + useDeleteCklstArtcl, + useUpdateCklstArtcl, +} from "@/entities/cklst"; +import { Button, DataTable, Skeleton } from "@common/components"; + +import { createCklstArtclColumns } from "../model/cklst-artcl-columns"; + +import { CklstArtclFormDialog, type CklstArtclFormValues } from "./cklst-artcl-form-dialog"; + +interface CklstArtclListProps { + cklstSn?: number; +} + +function CklstArtclList({ cklstSn }: CklstArtclListProps) { + const { confirm } = useConfirm(); + const [dialogMode, setDialogMode] = useState<"create" | "edit" | null>(null); + const [editingArtcl, setEditingArtcl] = useState(null); + + const artclQuery = useCklstArtclList(cklstSn); + const createMutation = useCreateCklstArtcl(); + const updateMutation = useUpdateCklstArtcl(); + const deleteMutation = useDeleteCklstArtcl(); + + const columns = useMemo( + () => + createCklstArtclColumns({ + onEdit: (row) => { + setEditingArtcl(row); + setDialogMode("edit"); + }, + onDelete: async (row) => { + if (!(await confirm("선택한 점검항목을 삭제하시겠습니까?"))) return; + await deleteMutation.mutateAsync({ + cklstArtclSn: row.cklstArtclSn, + cklstSn: row.cklstSn, + }); + }, + }), + [confirm, deleteMutation], + ); + + const table = useReactTable({ + data: artclQuery.data ?? [], + columns, + getCoreRowModel: getCoreRowModel(), + getRowId: (row) => `${row.cklstArtclSn}-${row.cklstSn}`, + }); + + const tableComponent = matchQueryStatus(artclQuery, { + Pending: ( +
+ + +
+ ), + Empty: , + Success: () => , + }); + + const handleFormSubmit = async (values: CklstArtclFormValues) => { + if (!cklstSn) return; + + if (dialogMode === "create") { + await createMutation.mutateAsync({ + cklstSn, + cklstArtclNm: values.cklstArtclNm, + useYn: values.useYn, + }); + } else if (dialogMode === "edit" && editingArtcl) { + await updateMutation.mutateAsync({ + cklstArtclSn: editingArtcl.cklstArtclSn, + cklstSn: editingArtcl.cklstSn, + body: { + cklstArtclNm: values.cklstArtclNm, + useYn: values.useYn, + }, + }); + } + + setDialogMode(null); + setEditingArtcl(null); + }; + + const isDisabled = !cklstSn; + + return ( +
+
+

점검항목 관리

+ +
+ {isDisabled ? ( +

+ 점검표를 선택하면 점검항목을 관리할 수 있습니다. +

+ ) : ( + tableComponent + )} + {dialogMode && ( + { + if (!open) { + setDialogMode(null); + setEditingArtcl(null); + } + }} + initial={dialogMode === "edit" ? editingArtcl : null} + isSubmitting={createMutation.isPending || updateMutation.isPending} + onSubmit={handleFormSubmit} + /> + )} +
+ ); +} + +export { CklstArtclList }; diff --git a/apps/as/src/pages/checklist-management/ui/cklst-mng-form-dialog.tsx b/apps/as/src/pages/checklist-management/ui/cklst-mng-form-dialog.tsx new file mode 100644 index 000000000..8ff919256 --- /dev/null +++ b/apps/as/src/pages/checklist-management/ui/cklst-mng-form-dialog.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; + +import type { CklstMng } from "@/entities/cklst"; +import { CodeSelect } from "@/entities/code"; +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@common/components"; + +export interface CklstMngFormValues { + cklstNm: string; + docSeComCd: string; + taskSeComCd: string; + useYn: "Y" | "N"; +} + +interface CklstMngFormDialogProps { + mode: "create" | "edit"; + open: boolean; + onOpenChange: (open: boolean) => void; + initial?: CklstMng | null; + isSubmitting?: boolean; + onSubmit: (values: CklstMngFormValues) => void; +} + +function CklstMngFormDialog({ + mode, + open, + onOpenChange, + initial, + isSubmitting, + onSubmit, +}: CklstMngFormDialogProps) { + const { register, handleSubmit, reset, setValue, watch } = useForm({ + defaultValues: { + cklstNm: "", + docSeComCd: "", + taskSeComCd: "", + useYn: "Y", + }, + }); + + const docSeComCd = watch("docSeComCd"); + const useYn = watch("useYn"); + + useEffect(() => { + if (!open) return; + + if (mode === "edit" && initial) { + reset({ + cklstNm: initial.cklstNm, + docSeComCd: initial.docSeComCd ?? "", + taskSeComCd: initial.taskSeComCd, + useYn: initial.useYn ?? "Y", + }); + return; + } + + reset({ + cklstNm: "", + docSeComCd: "", + taskSeComCd: "", + useYn: "Y", + }); + }, [open, mode, initial, reset]); + + useEffect(() => { + if (mode === "create" && docSeComCd) { + setValue("taskSeComCd", ""); + } + }, [docSeComCd, mode, setValue]); + + return ( + + + + {mode === "create" ? "점검표 추가" : "점검표 수정"} + +
+
+ + +
+
+ + setValue("docSeComCd", v)} + placeholder="문서구분 선택" + className="w-full" + /> +
+
+ + setValue("taskSeComCd", v)} + placeholder="업무구분 선택" + className="w-full" + disabled={!docSeComCd} + /> +
+
+ + +
+ + + + + +
+
+ ); +} + +export { CklstMngFormDialog }; diff --git a/apps/as/src/pages/checklist-management/ui/cklst-mng-list.tsx b/apps/as/src/pages/checklist-management/ui/cklst-mng-list.tsx new file mode 100644 index 000000000..921295ac2 --- /dev/null +++ b/apps/as/src/pages/checklist-management/ui/cklst-mng-list.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useMemo, useState } from "react"; + +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { getCoreRowModel, type RowSelectionState } from "@tanstack/react-table"; + +import { useDataTable } from "@/shared/lib"; +import { + type CklstMng, + useCklstMngPage, + useCreateCklstMng, + useDeleteCklstMng, + useUpdateCklstMng, +} from "@/entities/cklst"; +import { Button } from "@common/components"; + +import { createCklstMngColumns } from "../model/cklst-mng-columns"; + +import { CklstMngFormDialog, type CklstMngFormValues } from "./cklst-mng-form-dialog"; + +interface CklstMngListProps { + selectedCklstSn: number | null; + onSelectCklst: (cklst: CklstMng | null) => void; +} + +function CklstMngList({ selectedCklstSn, onSelectCklst }: CklstMngListProps) { + const { confirm } = useConfirm(); + const [dialogMode, setDialogMode] = useState<"create" | "edit" | null>(null); + const [editingCklst, setEditingCklst] = useState(null); + const [rowSelection, setRowSelection] = useState(() => + selectedCklstSn != null ? { [String(selectedCklstSn)]: true } : {}, + ); + + const mngQuery = useCklstMngPage({ + page: 0, + size: 50, + sort: "regDt,desc", + }); + + const createMutation = useCreateCklstMng(); + const updateMutation = useUpdateCklstMng(); + const deleteMutation = useDeleteCklstMng(); + + const columns = useMemo( + () => + createCklstMngColumns({ + onEdit: (row) => { + setEditingCklst(row); + setDialogMode("edit"); + }, + onDelete: async (row) => { + if (!(await confirm("선택한 점검표를 삭제하시겠습니까?"))) return; + await deleteMutation.mutateAsync(row.cklstSn); + if (selectedCklstSn === row.cklstSn) { + onSelectCklst(null); + setRowSelection({}); + } + }, + }), + [confirm, deleteMutation, onSelectCklst, selectedCklstSn], + ); + + const { tableComponent } = useDataTable({ + query: mngQuery, + tableOptions: { + columns, + state: { rowSelection }, + enableRowSelection: true, + enableMultiRowSelection: false, + getRowId: (row) => String(row.cklstSn), + onRowSelectionChange: (updater) => { + const next = typeof updater === "function" ? updater(rowSelection) : updater; + setRowSelection(next); + const selectedId = Object.keys(next)[0]; + if (!selectedId) { + onSelectCklst(null); + return; + } + const selected = mngQuery.data?.content.find((c) => String(c.cklstSn) === selectedId); + onSelectCklst(selected ?? null); + }, + getCoreRowModel: getCoreRowModel(), + }, + dataTableOptions: { + onRowClick: (row) => { + setRowSelection({ [String(row.cklstSn)]: true }); + onSelectCklst(row); + }, + }, + }); + + const handleFormSubmit = async (values: CklstMngFormValues) => { + const body = { + cklstNm: values.cklstNm, + taskSeComCd: values.taskSeComCd, + useYn: values.useYn, + docSeComCd: values.docSeComCd || undefined, + }; + + if (dialogMode === "create") { + const cklstSn = await createMutation.mutateAsync(body); + const { data } = await mngQuery.refetch(); + const created = data?.content.find((c) => c.cklstSn === cklstSn); + if (created) { + onSelectCklst(created); + setRowSelection({ [String(created.cklstSn)]: true }); + } + } else if (dialogMode === "edit" && editingCklst) { + await updateMutation.mutateAsync({ cklstSn: editingCklst.cklstSn, body }); + const { data } = await mngQuery.refetch(); + const updated = data?.content.find((c) => c.cklstSn === editingCklst.cklstSn); + if (updated && selectedCklstSn === editingCklst.cklstSn) { + onSelectCklst(updated); + } + } + + setDialogMode(null); + setEditingCklst(null); + }; + + return ( +
+
+

점검표 관리

+ +
+ {tableComponent} + {dialogMode && ( + { + if (!open) { + setDialogMode(null); + setEditingCklst(null); + } + }} + initial={dialogMode === "edit" ? editingCklst : null} + isSubmitting={createMutation.isPending || updateMutation.isPending} + onSubmit={handleFormSubmit} + /> + )} +
+ ); +} + +export { CklstMngList }; diff --git a/apps/as/src/pages/checklist-management/ui/cklst-preview-dialog.tsx b/apps/as/src/pages/checklist-management/ui/cklst-preview-dialog.tsx new file mode 100644 index 000000000..42f2edcf8 --- /dev/null +++ b/apps/as/src/pages/checklist-management/ui/cklst-preview-dialog.tsx @@ -0,0 +1,107 @@ +"use client"; + +import type { CklstArtcl } from "@/entities/cklst"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + Label, + RadioGroup, + RadioGroupItem, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@common/components"; + +import { buildRvwDisplayRows, formatTreeBranchPrefix } from "../model/build-rvw-display-rows"; +import type { CklstRvwRow } from "../model/cklst-rvw-row-types"; + +interface CklstPreviewDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + cklstNm: string; + rows: CklstRvwRow[]; + artclOptions: CklstArtcl[]; +} + +function CklstPreviewDialog({ + open, + onOpenChange, + cklstNm, + rows, + artclOptions, +}: CklstPreviewDialogProps) { + const displayRows = buildRvwDisplayRows(rows); + + function getArtclLabel(cklstArtclSn: number): string { + const artcl = artclOptions.find((item) => item.cklstArtclSn === cklstArtclSn); + return artcl?.cklstArtclNm ?? String(cklstArtclSn); + } + + return ( + + + + 체크리스트 미리보기 — {cklstNm} + + {displayRows.length === 0 ? ( +

표시할 항목이 없습니다.

+ ) : ( +
+ + + + + + + + 점검항목 + 주요 검토내용 + 검토 여부 + + + + {displayRows.map(({ row, depth }) => { + const branchPrefix = formatTreeBranchPrefix(depth); + + if (row.useYn === "N") return null; + + return ( + + + + {branchPrefix ? ( + {branchPrefix} + ) : null} + {getArtclLabel(row.cklstArtclSn)} + + + {row.rvwCn ?? "—"} + + +
+ + +
+
+ + +
+
+
+
+ ); + })} +
+
+ )} + +
+ ); +} + +export { CklstPreviewDialog }; diff --git a/apps/as/src/pages/checklist-management/ui/cklst-rvw-grid.tsx b/apps/as/src/pages/checklist-management/ui/cklst-rvw-grid.tsx new file mode 100644 index 000000000..8eca3ce21 --- /dev/null +++ b/apps/as/src/pages/checklist-management/ui/cklst-rvw-grid.tsx @@ -0,0 +1,324 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { useConfirm } from "@frontend-opensource/use-react-hooks"; + +import { useAlertStore } from "@/shared/lib"; +import type { CklstMng, CklstRvw } from "@/entities/cklst"; +import { + useCklstArtclList, + useCklstRvwPage, + useCreateCklstRvw, + useDeleteCklstRvw, + useUpdateCklstRvw, +} from "@/entities/cklst"; +import { + Button, + Skeleton, + Table, + TableBody, + TableHead, + TableHeader, + TableRow, +} from "@common/components"; + +import { buildRvwDisplayRows, MAX_RVW_CN_LV_NO } from "../model/build-rvw-display-rows"; +import type { CklstRvwRow } from "../model/cklst-rvw-row-types"; + +import { CklstPreviewDialog } from "./cklst-preview-dialog"; +import { CklstRvwRowComponent } from "./cklst-rvw-row"; + +interface CklstRvwGridProps { + selectedCklst: CklstMng | null; +} + +function toRow(item: CklstRvw): CklstRvwRow { + return { + ...item, + rowKey: `${item.cklstRvwSn}-${item.cklstArtclSn}-${item.cklstSn}`, + isNew: false, + isDirty: false, + }; +} + +function createDraftRowKey(): string { + return `draft-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; +} + +function validateRvwRow(row: CklstRvwRow): string | null { + if (!row.cklstArtclSn) return "점검항목을 선택하세요."; + if (!row.rvwCn?.trim()) return "주요 검토내용을 입력하세요."; + return null; +} + +function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) { + const { confirm } = useConfirm(); + const [rows, setRows] = useState([]); + const [previewOpen, setPreviewOpen] = useState(false); + + const alert = useAlertStore((state) => state.setMessage); + const cklstSn = selectedCklst?.cklstSn ?? null; + + const rvwQuery = useCklstRvwPage({ + cklstSn: cklstSn ?? undefined, + page: 0, + size: 500, + sort: "sortSeq,asc", + }); + + const artclQuery = useCklstArtclList(cklstSn); + const createMutation = useCreateCklstRvw(); + const updateMutation = useUpdateCklstRvw(); + const deleteMutation = useDeleteCklstRvw(); + + const artclOptions = useMemo( + () => (artclQuery.data ?? []).filter((artcl) => artcl.useYn !== "N"), + [artclQuery.data], + ); + + useEffect(() => { + if (!cklstSn) { + setRows([]); + return; + } + setRows((rvwQuery.data?.content ?? []).map(toRow)); + }, [cklstSn, rvwQuery.data?.content]); + + const displayRows = useMemo(() => buildRvwDisplayRows(rows), [rows]); + + const getDirtyRows = useCallback(() => rows.filter((row) => row.isDirty || row.isNew), [rows]); + + const updateRow = useCallback((rowKey: string, patch: Partial) => { + setRows((prev) => + prev.map((row) => (row.rowKey === rowKey ? { ...row, ...patch, isDirty: true } : row)), + ); + }, []); + + const removeRowLocal = useCallback((rowKey: string) => { + setRows((prev) => prev.filter((row) => row.rowKey !== rowKey)); + }, []); + + const addRootRow = useCallback(() => { + if (!cklstSn) return; + + const maxSort = rows.reduce((max, row) => Math.max(max, row.sortSeq ?? 0), 0); + + setRows((prev) => [ + ...prev, + { + rowKey: createDraftRowKey(), + cklstSn, + cklstArtclSn: artclOptions[0]?.cklstArtclSn ?? 0, + rvwCnLvNo: 1, + rvwCn: "", + useYn: "Y", + sortSeq: maxSort + 1, + isNew: true, + isDirty: true, + }, + ]); + }, [artclOptions, cklstSn, rows]); + + const addChildRow = useCallback( + (parent: CklstRvwRow) => { + if (!cklstSn || parent.isNew || parent.cklstRvwSn == null) { + alert("상위 행을 먼저 저장한 후 하위 항목을 추가할 수 있습니다."); + return; + } + + const parentLv = parent.rvwCnLvNo ?? 1; + if (parentLv >= MAX_RVW_CN_LV_NO) { + alert("최대 3단계까지 추가할 수 있습니다."); + return; + } + + const maxSort = rows.reduce((max, row) => Math.max(max, row.sortSeq ?? 0), 0); + + setRows((prev) => [ + ...prev, + { + rowKey: createDraftRowKey(), + cklstSn, + cklstArtclSn: parent.cklstArtclSn, + upCklstRvwSn: parent.cklstRvwSn, + rvwCnLvNo: parentLv + 1, + rvwCn: "", + useYn: "Y", + sortSeq: maxSort + 1, + isNew: true, + isDirty: true, + }, + ]); + }, + [alert, cklstSn, rows], + ); + + const handleDelete = async (row: CklstRvwRow) => { + if ( + !(await confirm( + "선택한 항목을 삭제하시겠습니까?\n삭제한 항목은 되돌릴 수 없으며, 하위 항목까지 모두 삭제됩니다.", + )) + ) + return; + + if (row.isNew) { + removeRowLocal(row.rowKey); + return; + } + + const children = rows.filter((r) => r.upCklstRvwSn === row.cklstRvwSn); + if (children.length > 0) { + for (const child of children) { + await deleteMutation.mutateAsync({ + cklstRvwSn: child.cklstRvwSn!, + cklstArtclSn: child.cklstArtclSn, + cklstSn: child.cklstSn, + }); + } + } + + await deleteMutation.mutateAsync({ + cklstRvwSn: row.cklstRvwSn!, + cklstArtclSn: row.cklstArtclSn, + cklstSn: row.cklstSn, + }); + await rvwQuery.refetch(); + }; + + const handleSave = async () => { + if (!cklstSn) return; + + const dirty = getDirtyRows().sort((a, b) => (a.rvwCnLvNo ?? 1) - (b.rvwCnLvNo ?? 1)); + + for (const row of dirty) { + const fieldError = validateRvwRow(row); + if (fieldError) { + alert(fieldError); + return; + } + + if (!(await confirm("체크리스트 항목을 저장하시겠습니까?"))) return; + + if (row.isNew) { + await createMutation.mutateAsync({ + cklstSn: row.cklstSn, + cklstArtclSn: row.cklstArtclSn, + upCklstRvwSn: row.upCklstRvwSn, + rvwCnLvNo: row.rvwCnLvNo, + rvwCn: row.rvwCn, + useYn: row.useYn, + sortSeq: row.sortSeq, + bchckUseYn: "Y", + }); + } else if (row.cklstRvwSn != null) { + await updateMutation.mutateAsync({ + cklstRvwSn: row.cklstRvwSn, + cklstArtclSn: row.cklstArtclSn, + cklstSn: row.cklstSn, + body: { + upCklstRvwSn: row.upCklstRvwSn, + rvwCnLvNo: row.rvwCnLvNo, + rvwCn: row.rvwCn, + useYn: row.useYn, + sortSeq: row.sortSeq, + bchckUseYn: row.bchckUseYn, + }, + }); + } + } + + alert("체크리스트 항목이 저장되었습니다."); + await rvwQuery.refetch(); + }; + + const isDisabled = !selectedCklst; + const isSaving = createMutation.isPending || updateMutation.isPending || deleteMutation.isPending; + + return ( +
+
+

체크리스트 항목

+
+ + + +
+
+ + {rvwQuery.isPending && cklstSn ? ( +
+ + +
+ ) : isDisabled ? ( +

+ 점검표를 선택하면 체크리스트 항목을 편집할 수 있습니다. +

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

+ 등록된 항목이 없습니다. [추가]로 1단계 항목을 등록하세요. +

+ ) : ( + + + + + + + + + + + 점검항목 + 주요 검토내용 + 사용 여부 + 구분 + 관리 + + + + {displayRows.map(({ row, depth }) => ( + updateRow(row.rowKey, patch)} + onAddChild={() => addChildRow(row)} + onDelete={() => handleDelete(row)} + /> + ))} + +
+ )} + + {selectedCklst && ( + + )} +
+ ); +} + +export { CklstRvwGrid }; diff --git a/apps/as/src/pages/checklist-management/ui/cklst-rvw-row.tsx b/apps/as/src/pages/checklist-management/ui/cklst-rvw-row.tsx new file mode 100644 index 000000000..9ea9094ce --- /dev/null +++ b/apps/as/src/pages/checklist-management/ui/cklst-rvw-row.tsx @@ -0,0 +1,115 @@ +"use client"; + +import type { CklstArtcl } from "@/entities/cklst"; +import { + Button, + Input, + Label, + RadioGroup, + RadioGroupItem, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + TableCell, + TableRow, +} from "@common/components"; + +import { + canAddChildRow, + formatRvwCnLvNo, + formatTreeBranchPrefix, +} from "../model/build-rvw-display-rows"; +import type { CklstRvwRow } from "../model/cklst-rvw-row-types"; + +interface CklstRvwRowProps { + row: CklstRvwRow; + depth: number; + artclOptions: CklstArtcl[]; + onChange: (patch: Partial) => void; + onAddChild: () => void; + onDelete: () => void; +} + +function CklstRvwRowComponent({ + row, + depth, + artclOptions, + onChange, + onAddChild, + onDelete, +}: CklstRvwRowProps) { + const levelLabel = formatRvwCnLvNo(row.rvwCnLvNo); + const branchPrefix = formatTreeBranchPrefix(depth); + + return ( + + +
+ {branchPrefix ? ( + {branchPrefix} + ) : null} + +
+
+ + onChange({ rvwCn: e.target.value, isDirty: true })} + placeholder="주요 검토내용" + /> + + + onChange({ useYn: v as "Y" | "N", isDirty: true })} + className="flex justify-center gap-4" + > +
+ + +
+
+ + +
+
+
+ {levelLabel} + +
+ + +
+
+
+ ); +} + +export { CklstRvwRowComponent }; diff --git a/apps/as/src/pages/cnstp-code-management/index.ts b/apps/as/src/pages/cnstp-code-management/index.ts new file mode 100644 index 000000000..2ed1cb4ba --- /dev/null +++ b/apps/as/src/pages/cnstp-code-management/index.ts @@ -0,0 +1 @@ +export { CnstpCodeManagementPage } from "./ui/cnstp-code-management-page"; diff --git a/apps/as/src/pages/cnstp-code-management/lib/build-save-multi-payload.ts b/apps/as/src/pages/cnstp-code-management/lib/build-save-multi-payload.ts new file mode 100644 index 000000000..20ec66853 --- /dev/null +++ b/apps/as/src/pages/cnstp-code-management/lib/build-save-multi-payload.ts @@ -0,0 +1,62 @@ +import { CNSTP_SE_SAVE_MODE, type CnstpSeMngSaveRequestDto } from "@/entities/cnstp"; + +import type { CnstpSeRow } from "../model/cnstp-se-row-types"; + +import { getVisibleRows } from "./row-state"; + +function compareCdLvNoDesc(a: CnstpSeRow, b: CnstpSeRow): number { + return (b.cdLvNo ?? 1) - (a.cdLvNo ?? 1); +} + +function compareCdLvNoAsc(a: CnstpSeRow, b: CnstpSeRow): number { + return (a.cdLvNo ?? 1) - (b.cdLvNo ?? 1); +} + +export function buildSaveMultiPayload(rows: CnstpSeRow[]): CnstpSeMngSaveRequestDto[] { + const payload: CnstpSeMngSaveRequestDto[] = []; + + const deletedRows = rows.filter((row) => row.isDeleted && !row.isNew).sort(compareCdLvNoDesc); + + for (const row of deletedRows) { + payload.push({ + mode: CNSTP_SE_SAVE_MODE.DELETE, + cnstpSeCd: row.cnstpSeCd, + cnstpSeCdNm: row.cnstpSeCdNm?.trim() || undefined, + upCnstpCd: row.upCnstpCd, + cdLvNo: row.cdLvNo, + useYn: row.useYn, + expln: row.expln?.trim() || undefined, + }); + } + + const visibleRows = getVisibleRows(rows).sort(compareCdLvNoAsc); + + for (const row of visibleRows) { + if (row.isNew) { + payload.push({ + mode: CNSTP_SE_SAVE_MODE.CREATE, + cnstpSeCd: row.cnstpSeCd.trim(), + cnstpSeCdNm: row.cnstpSeCdNm?.trim() || undefined, + upCnstpCd: row.upCnstpCd, + cdLvNo: row.cdLvNo, + useYn: row.useYn, + expln: row.expln?.trim() || undefined, + }); + continue; + } + + if (row.isDirty) { + payload.push({ + mode: CNSTP_SE_SAVE_MODE.UPDATE, + cnstpSeCd: row.cnstpSeCd.trim(), + cnstpSeCdNm: row.cnstpSeCdNm?.trim() || undefined, + upCnstpCd: row.upCnstpCd, + cdLvNo: row.cdLvNo, + useYn: row.useYn, + expln: row.expln?.trim() || undefined, + }); + } + } + + return payload; +} diff --git a/apps/as/src/pages/cnstp-code-management/lib/cnstp-se-grid.ts b/apps/as/src/pages/cnstp-code-management/lib/cnstp-se-grid.ts new file mode 100644 index 000000000..6b1bd99f9 --- /dev/null +++ b/apps/as/src/pages/cnstp-code-management/lib/cnstp-se-grid.ts @@ -0,0 +1,245 @@ +import type { CnstpSeMngResponseDto } from "@/entities/cnstp"; + +import { buildCnstpDisplayRows } from "../model/build-cnstp-display-rows"; +import type { + CnstpDisplaySlot, + CnstpSeBaseEntry, + CnstpSeGridState, +} from "../model/cnstp-se-grid-types"; +import type { CnstpSeRow } from "../model/cnstp-se-row-types"; + +export function createLoadedRowKey(index: number, cnstpSeCd: string): string { + return `loaded-${index}-${cnstpSeCd}`; +} + +export function createDraftRowKey(): string { + return `draft-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; +} + +export function createEmptyGridState(): CnstpSeGridState { + return { + baseByKey: {}, + structure: [], + drafts: {}, + patches: {}, + deletedKeys: new Set(), + }; +} + +function toInitialRow(entry: CnstpSeBaseEntry): CnstpSeRow { + return { + ...entry, + isNew: false, + isDirty: false, + isDeleted: false, + }; +} + +export function buildStructureFromBaseRows( + baseByKey: Record, +): CnstpDisplaySlot[] { + const rows = Object.values(baseByKey).map(toInitialRow); + const display = buildCnstpDisplayRows(rows); + const cdToRowKey = new Map(rows.map((row) => [row.cnstpSeCd, row.rowKey])); + + return display.map(({ row, depth }) => ({ + rowKey: row.rowKey, + depth, + parentRowKey: row.upCnstpCd?.trim() ? (cdToRowKey.get(row.upCnstpCd.trim()) ?? null) : null, + })); +} + +export function hydrateGridFromServer(content: CnstpSeMngResponseDto[]): CnstpSeGridState { + const baseByKey: Record = {}; + + content.forEach((item, index) => { + const rowKey = createLoadedRowKey(index, item.cnstpSeCd); + baseByKey[rowKey] = { ...item, rowKey }; + }); + + return { + baseByKey, + structure: buildStructureFromBaseRows(baseByKey), + drafts: {}, + patches: {}, + deletedKeys: new Set(), + }; +} + +export function resolveParentCnstpSeCd( + state: CnstpSeGridState, + parentRowKey: string | null, +): string | undefined { + if (!parentRowKey) return undefined; + const parent = mergeCnstpRow(state, parentRowKey); + const cd = parent?.cnstpSeCd?.trim(); + return cd || undefined; +} + +export function mergeCnstpRow(state: CnstpSeGridState, rowKey: string): CnstpSeRow | null { + const draft = state.drafts[rowKey]; + if (draft) { + return { + rowKey, + cnstpSeCd: draft.cnstpSeCd, + cnstpSeCdNm: draft.cnstpSeCdNm, + expln: draft.expln, + useYn: draft.useYn, + cdLvNo: draft.cdLvNo, + upCnstpCd: resolveParentCnstpSeCd(state, draft.parentRowKey), + isNew: true, + isDirty: true, + isDeleted: false, + }; + } + + const base = state.baseByKey[rowKey]; + if (!base) return null; + + const patch = state.patches[rowKey] ?? {}; + const hasPatch = Object.keys(patch).length > 0; + + return { + ...base, + ...patch, + rowKey, + isNew: false, + isDirty: hasPatch, + isDeleted: state.deletedKeys.has(rowKey), + }; +} + +export function mergeAllCnstpRows(state: CnstpSeGridState): CnstpSeRow[] { + const keys = new Set([...Object.keys(state.baseByKey), ...Object.keys(state.drafts)]); + + return [...keys] + .map((rowKey) => mergeCnstpRow(state, rowKey)) + .filter((row): row is CnstpSeRow => row != null); +} + +export function hasCnstpGridPendingChanges(state: CnstpSeGridState): boolean { + return ( + state.deletedKeys.size > 0 || + Object.keys(state.drafts).length > 0 || + Object.keys(state.patches).length > 0 + ); +} + +export function collectDescendantRowKeys( + structure: CnstpDisplaySlot[], + rootRowKey: string, +): Set { + const result = new Set([rootRowKey]); + const queue = [rootRowKey]; + + while (queue.length > 0) { + const parentKey = queue.shift()!; + for (const slot of structure) { + if (slot.parentRowKey === parentKey && !result.has(slot.rowKey)) { + result.add(slot.rowKey); + queue.push(slot.rowKey); + } + } + } + + return result; +} + +export function insertRootSlot( + structure: CnstpDisplaySlot[], + slot: CnstpDisplaySlot, +): CnstpDisplaySlot[] { + const firstRootIndex = structure.findIndex((item) => item.parentRowKey === null); + if (firstRootIndex === -1) return [slot, ...structure]; + return [...structure.slice(0, firstRootIndex), slot, ...structure.slice(firstRootIndex)]; +} + +export function insertChildSlot( + structure: CnstpDisplaySlot[], + parentRowKey: string, + slot: CnstpDisplaySlot, +): CnstpDisplaySlot[] { + const parentIndex = structure.findIndex((item) => item.rowKey === parentRowKey); + if (parentIndex === -1) return [...structure, slot]; + + const insertAt = parentIndex + 1; + return [...structure.slice(0, insertAt), slot, ...structure.slice(insertAt)]; +} + +export function removeSlotsFromStructure( + structure: CnstpDisplaySlot[], + rowKeysToRemove: Set, +): CnstpDisplaySlot[] { + return structure.filter((slot) => !rowKeysToRemove.has(slot.rowKey)); +} + +export function patchCnstpGridRow( + state: CnstpSeGridState, + rowKey: string, + patch: Partial, +): CnstpSeGridState { + const draft = state.drafts[rowKey]; + if (draft) { + return { + ...state, + drafts: { + ...state.drafts, + [rowKey]: { + ...draft, + ...(patch.cnstpSeCd !== undefined ? { cnstpSeCd: patch.cnstpSeCd } : {}), + ...(patch.cnstpSeCdNm !== undefined ? { cnstpSeCdNm: patch.cnstpSeCdNm ?? "" } : {}), + ...(patch.expln !== undefined ? { expln: patch.expln ?? "" } : {}), + ...(patch.useYn !== undefined ? { useYn: patch.useYn } : {}), + }, + }, + }; + } + + const { rowKey: _rk, isNew: _n, isDirty: _d, isDeleted: _del, ...dtoPatch } = patch; + + return { + ...state, + patches: { + ...state.patches, + [rowKey]: { ...state.patches[rowKey], ...dtoPatch }, + }, + }; +} + +export function markCnstpRowsDeleted( + state: CnstpSeGridState, + rootRowKey: string, +): CnstpSeGridState { + const keysToDelete = collectDescendantRowKeys(state.structure, rootRowKey); + const deletedKeys = new Set(state.deletedKeys); + for (const key of keysToDelete) { + deletedKeys.add(key); + } + return { ...state, deletedKeys }; +} + +export function validateDraftParentCnstpSeCd( + state: CnstpSeGridState, + rowKey: string, +): string | null { + const draft = state.drafts[rowKey]; + if (!draft?.parentRowKey) return null; + + const parent = mergeCnstpRow(state, draft.parentRowKey); + if (!parent?.cnstpSeCd?.trim()) { + return "하위 항목을 추가하려면 상위 코드를 먼저 입력하세요."; + } + + return null; +} + +export function removeDraftFromGrid(state: CnstpSeGridState, rowKey: string): CnstpSeGridState { + const { [rowKey]: _removed, ...restDrafts } = state.drafts; + const keysToRemove = collectDescendantRowKeys(state.structure, rowKey); + + return { + ...state, + drafts: restDrafts, + structure: removeSlotsFromStructure(state.structure, keysToRemove), + }; +} diff --git a/apps/as/src/pages/cnstp-code-management/lib/row-state.ts b/apps/as/src/pages/cnstp-code-management/lib/row-state.ts new file mode 100644 index 000000000..b7a98aca7 --- /dev/null +++ b/apps/as/src/pages/cnstp-code-management/lib/row-state.ts @@ -0,0 +1,5 @@ +import type { CnstpSeRow } from "../model/cnstp-se-row-types"; + +export function getVisibleRows(rows: CnstpSeRow[]): CnstpSeRow[] { + return rows.filter((row) => !row.isDeleted); +} diff --git a/apps/as/src/pages/cnstp-code-management/lib/use-cnstp-se-rows.ts b/apps/as/src/pages/cnstp-code-management/lib/use-cnstp-se-rows.ts new file mode 100644 index 000000000..7e4320c8f --- /dev/null +++ b/apps/as/src/pages/cnstp-code-management/lib/use-cnstp-se-rows.ts @@ -0,0 +1,167 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import type { CnstpSeMngResponseDto } from "@/entities/cnstp"; + +import type { CnstpSeGridState } from "../model/cnstp-se-grid-types"; +import type { CnstpSeRow } from "../model/cnstp-se-row-types"; + +import { buildSaveMultiPayload } from "./build-save-multi-payload"; +import { + createDraftRowKey, + createEmptyGridState, + hasCnstpGridPendingChanges, + hydrateGridFromServer, + insertChildSlot, + insertRootSlot, + markCnstpRowsDeleted, + mergeAllCnstpRows, + mergeCnstpRow, + patchCnstpGridRow, + removeDraftFromGrid, + validateDraftParentCnstpSeCd, +} from "./cnstp-se-grid"; + +function validateCnstpSeRow(row: CnstpSeRow): string | null { + if (!row.cnstpSeCd.trim()) return "코드를 입력하세요."; + const parentCd = row.upCnstpCd?.trim(); + if (parentCd && row.cnstpSeCd.trim() === parentCd) { + return "상위 코드와 동일한 코드는 사용할 수 없습니다."; + } + return null; +} + +interface UseCnstpSeRowsOptions { + serverContent: CnstpSeMngResponseDto[] | undefined; + onAlert: (message: string) => void; + onSave: (payload: ReturnType) => Promise; +} + +export function useCnstpSeRows({ serverContent, onAlert, onSave }: UseCnstpSeRowsOptions) { + const [grid, setGrid] = useState(createEmptyGridState); + const hasPendingRef = useRef(false); + + hasPendingRef.current = hasCnstpGridPendingChanges(grid); + + useEffect(() => { + if (serverContent == null) return; + if (hasPendingRef.current) return; + setGrid(hydrateGridFromServer(serverContent)); + }, [serverContent]); + + const displayRows = useMemo(() => { + return grid.structure + .filter((slot) => !grid.deletedKeys.has(slot.rowKey)) + .map((slot) => { + const row = mergeCnstpRow(grid, slot.rowKey); + return row ? { row, depth: slot.depth } : null; + }) + .filter((item): item is { row: CnstpSeRow; depth: number } => item != null); + }, [grid]); + + const canSave = hasCnstpGridPendingChanges(grid); + + const updateRow = useCallback((rowKey: string, patch: Partial) => { + setGrid((prev) => patchCnstpGridRow(prev, rowKey, patch)); + }, []); + + const addRootRow = useCallback(() => { + const rowKey = createDraftRowKey(); + setGrid((prev) => ({ + ...prev, + drafts: { + ...prev.drafts, + [rowKey]: { + rowKey, + parentRowKey: null, + cdLvNo: 1, + cnstpSeCd: "", + cnstpSeCdNm: "", + expln: "", + useYn: "Y", + }, + }, + structure: insertRootSlot(prev.structure, { rowKey, depth: 0, parentRowKey: null }), + })); + }, []); + + const addChildRow = useCallback((parent: CnstpSeRow) => { + const rowKey = createDraftRowKey(); + + setGrid((prev) => { + const parentSlot = prev.structure.find((item) => item.rowKey === parent.rowKey); + const depth = (parentSlot?.depth ?? 0) + 1; + + return { + ...prev, + drafts: { + ...prev.drafts, + [rowKey]: { + rowKey, + parentRowKey: parent.rowKey, + cdLvNo: (parent.cdLvNo ?? 1) + 1, + cnstpSeCd: "", + cnstpSeCdNm: "", + expln: "", + useYn: "Y", + }, + }, + structure: insertChildSlot(prev.structure, parent.rowKey, { + rowKey, + depth, + parentRowKey: parent.rowKey, + }), + }; + }); + }, []); + + const removeDraft = useCallback((rowKey: string) => { + setGrid((prev) => removeDraftFromGrid(prev, rowKey)); + }, []); + + const markDeleted = useCallback((rowKey: string) => { + setGrid((prev) => markCnstpRowsDeleted(prev, rowKey)); + }, []); + + const resetFromServer = useCallback((content: CnstpSeMngResponseDto[]) => { + setGrid(hydrateGridFromServer(content)); + }, []); + + const save = useCallback(async () => { + const mergedRows = mergeAllCnstpRows(grid); + const payload = buildSaveMultiPayload(mergedRows); + if (payload.length === 0) return false; + + for (const row of mergedRows) { + if (!row.isDeleted && (row.isNew || row.isDirty)) { + const parentCodeError = validateDraftParentCnstpSeCd(grid, row.rowKey); + if (parentCodeError) { + onAlert(parentCodeError); + return false; + } + + const fieldError = validateCnstpSeRow(row); + if (fieldError) { + onAlert(fieldError); + return false; + } + } + } + + await onSave(payload); + return true; + }, [grid, onAlert, onSave]); + + return { + displayRows, + canSave, + updateRow, + addRootRow, + addChildRow, + removeDraft, + markDeleted, + resetFromServer, + save, + }; +} diff --git a/apps/as/src/pages/cnstp-code-management/model/build-cnstp-display-rows.ts b/apps/as/src/pages/cnstp-code-management/model/build-cnstp-display-rows.ts new file mode 100644 index 000000000..1667b2aa4 --- /dev/null +++ b/apps/as/src/pages/cnstp-code-management/model/build-cnstp-display-rows.ts @@ -0,0 +1,77 @@ +import type { CnstpSeRow } from "./cnstp-se-row-types"; + +export interface CnstpSeDisplayRow { + row: CnstpSeRow; + depth: number; +} + +export function formatCdLvNo(lvNo?: number): string { + return `${lvNo ?? 1}단계`; +} + +export function canAddChildRow(): boolean { + return true; +} + +export function formatTreeBranchPrefix(depth: number): string { + if (depth <= 0) return ""; + const vertical = "\u2502 "; + const branch = "\u251C\u2500"; + return vertical.repeat(depth - 1) + branch; +} + +function isRootRow(row: CnstpSeRow): boolean { + return (row.cdLvNo ?? 1) === 1 && !row.upCnstpCd?.trim(); +} + +function rowOrderIndex(rows: CnstpSeRow[], row: CnstpSeRow): number { + return rows.findIndex((candidate) => candidate.rowKey === row.rowKey); +} + +function pushDisplayRow( + result: CnstpSeDisplayRow[], + displayedRowKeys: Set, + row: CnstpSeRow, + depth: number, +) { + if (displayedRowKeys.has(row.rowKey)) return; + displayedRowKeys.add(row.rowKey); + result.push({ row, depth }); +} + +export function buildCnstpDisplayRows(rows: CnstpSeRow[]): CnstpSeDisplayRow[] { + const result: CnstpSeDisplayRow[] = []; + const displayedRowKeys = new Set(); + + const roots = rows.filter(isRootRow); + + function appendSubtree(parent: CnstpSeRow, depth: number) { + const children = rows + .filter( + (row) => + row.rowKey !== parent.rowKey && + row.upCnstpCd != null && + row.upCnstpCd === parent.cnstpSeCd && + row.cnstpSeCd !== parent.cnstpSeCd, + ) + .sort((a, b) => rowOrderIndex(rows, a) - rowOrderIndex(rows, b)); + + for (const child of children) { + pushDisplayRow(result, displayedRowKeys, child, depth); + appendSubtree(child, depth + 1); + } + } + + for (const root of roots) { + pushDisplayRow(result, displayedRowKeys, root, 0); + appendSubtree(root, 1); + } + + const orphans = rows.filter((row) => !displayedRowKeys.has(row.rowKey)); + + for (const orphan of orphans) { + pushDisplayRow(result, displayedRowKeys, orphan, Math.max(0, (orphan.cdLvNo ?? 1) - 1)); + } + + return result; +} diff --git a/apps/as/src/pages/cnstp-code-management/model/cnstp-se-grid-types.ts b/apps/as/src/pages/cnstp-code-management/model/cnstp-se-grid-types.ts new file mode 100644 index 000000000..7cff8922a --- /dev/null +++ b/apps/as/src/pages/cnstp-code-management/model/cnstp-se-grid-types.ts @@ -0,0 +1,29 @@ +import type { CnstpSeMngResponseDto } from "@/entities/cnstp"; + +export interface CnstpDisplaySlot { + rowKey: string; + depth: number; + parentRowKey: string | null; +} + +export interface CnstpSeDraft { + rowKey: string; + parentRowKey: string | null; + cdLvNo: number; + cnstpSeCd: string; + cnstpSeCdNm: string; + expln: string; + useYn: string; +} + +export interface CnstpSeBaseEntry extends CnstpSeMngResponseDto { + rowKey: string; +} + +export interface CnstpSeGridState { + baseByKey: Record; + structure: CnstpDisplaySlot[]; + drafts: Record; + patches: Record>; + deletedKeys: Set; +} diff --git a/apps/as/src/pages/cnstp-code-management/model/cnstp-se-row-types.ts b/apps/as/src/pages/cnstp-code-management/model/cnstp-se-row-types.ts new file mode 100644 index 000000000..509662c27 --- /dev/null +++ b/apps/as/src/pages/cnstp-code-management/model/cnstp-se-row-types.ts @@ -0,0 +1,8 @@ +import type { CnstpSeMngResponseDto } from "@/entities/cnstp"; + +export interface CnstpSeRow extends CnstpSeMngResponseDto { + rowKey: string; + isNew: boolean; + isDirty: boolean; + isDeleted: boolean; +} diff --git a/apps/as/src/pages/cnstp-code-management/ui/cnstp-code-management-page.tsx b/apps/as/src/pages/cnstp-code-management/ui/cnstp-code-management-page.tsx new file mode 100644 index 000000000..25ad711cb --- /dev/null +++ b/apps/as/src/pages/cnstp-code-management/ui/cnstp-code-management-page.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { useCallback } from "react"; + +import { useConfirm } from "@frontend-opensource/use-react-hooks"; + +import { useAlertStore } from "@/shared/lib"; +import { useCnstpSeMngPage, useSaveMultiCnstpSeMng } from "@/entities/cnstp"; +import { + Button, + Skeleton, + Table, + TableBody, + TableHead, + TableHeader, + TableRow, +} from "@common/components"; + +import { useCnstpSeRows } from "../lib/use-cnstp-se-rows"; +import type { CnstpSeRow } from "../model/cnstp-se-row-types"; + +import { CnstpSeRowComponent } from "./cnstp-se-row"; + +function CnstpCodeManagementPage() { + const { confirm } = useConfirm(); + const alert = useAlertStore((state) => state.setMessage); + + const pageQuery = useCnstpSeMngPage({ + page: 0, + size: 500, + sort: "cdLvNo,asc", + }); + + const saveMultiMutation = useSaveMultiCnstpSeMng(); + + const handlePersist = useCallback( + async (payload: Parameters[0]) => { + await saveMultiMutation.mutateAsync(payload); + }, + [saveMultiMutation], + ); + + const { + displayRows, + canSave, + updateRow, + addRootRow, + addChildRow, + removeDraft, + markDeleted, + resetFromServer, + save, + } = useCnstpSeRows({ + serverContent: pageQuery.data?.content, + onAlert: alert, + onSave: handlePersist, + }); + + const handleDelete = async (row: CnstpSeRow) => { + if (!(await confirm("삭제하시겠습니까?"))) return; + + if (row.isNew) { + removeDraft(row.rowKey); + return; + } + + markDeleted(row.rowKey); + }; + + const handleSave = async () => { + if (!(await confirm("저장하시겠습니까?"))) return; + + const saved = await save(); + if (!saved) return; + + alert("저장되었습니다."); + const result = await pageQuery.refetch(); + resetFromServer(result.data?.content ?? []); + }; + + const isSaving = saveMultiMutation.isPending; + + return ( +
+
+
+

공종코드 관리

+
+ + +
+
+ + {pageQuery.isPending ? ( +
+ + +
+ ) : displayRows.length === 0 ? ( +

+ 등록된 코드가 없습니다. [코드 추가]로 1단계 코드를 등록하세요. +

+ ) : ( +
+ + + + + + + + + + + + 코드 + 코드명 + 설명 + 사용 여부 + 구분 + 관리 + + + + {displayRows.map(({ row, depth }) => ( + updateRow(row.rowKey, patch)} + onAddChild={() => addChildRow(row)} + onDelete={() => handleDelete(row)} + /> + ))} + +
+
+ )} +
+
+ ); +} + +export { CnstpCodeManagementPage }; diff --git a/apps/as/src/pages/cnstp-code-management/ui/cnstp-se-row.tsx b/apps/as/src/pages/cnstp-code-management/ui/cnstp-se-row.tsx new file mode 100644 index 000000000..090e7d2ba --- /dev/null +++ b/apps/as/src/pages/cnstp-code-management/ui/cnstp-se-row.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { + Button, + Input, + Label, + RadioGroup, + RadioGroupItem, + TableCell, + TableRow, +} from "@common/components"; + +import { + canAddChildRow, + formatCdLvNo, + formatTreeBranchPrefix, +} from "../model/build-cnstp-display-rows"; +import type { CnstpSeRow } from "../model/cnstp-se-row-types"; + +interface CnstpSeRowProps { + row: CnstpSeRow; + depth: number; + onChange: (patch: Partial) => void; + onAddChild: () => void; + onDelete: () => void; +} + +function CnstpSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: CnstpSeRowProps) { + const levelLabel = formatCdLvNo(row.cdLvNo); + const branchPrefix = formatTreeBranchPrefix(depth); + + return ( + + +
+ {branchPrefix ? ( + {branchPrefix} + ) : null} + onChange({ cnstpSeCd: e.target.value })} + placeholder="코드" + maxLength={10} + disabled={!row.isNew} + className="flex-1" + /> +
+
+ + onChange({ cnstpSeCdNm: e.target.value })} + placeholder="코드명" + maxLength={100} + /> + + + onChange({ expln: e.target.value })} + placeholder="설명" + maxLength={1000} + /> + + + onChange({ useYn: v })} + className="flex justify-center gap-4" + > +
+ + +
+
+ + +
+
+
+ {levelLabel} + +
+ + +
+
+
+ ); +} + +export { CnstpSeRowComponent }; diff --git a/apps/as/src/pages/esntl-atch-file-mng/index.ts b/apps/as/src/pages/esntl-atch-file-mng/index.ts new file mode 100644 index 000000000..73333e63c --- /dev/null +++ b/apps/as/src/pages/esntl-atch-file-mng/index.ts @@ -0,0 +1 @@ +export { EsntlAtchFileMngPage } from "./ui/esntl-atch-file-mng-page"; diff --git a/apps/as/src/pages/esntl-atch-file-mng/model/esntl-atch-file-columns.tsx b/apps/as/src/pages/esntl-atch-file-mng/model/esntl-atch-file-columns.tsx new file mode 100644 index 000000000..ad5f32c62 --- /dev/null +++ b/apps/as/src/pages/esntl-atch-file-mng/model/esntl-atch-file-columns.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { createColumnHelper } from "@tanstack/react-table"; + +import type { Code } from "@/entities/code"; +import { findCodeLabel } from "@/entities/code"; +import type { EsntlAtchFileMng } from "@/entities/esntl"; +import { Button } from "@common/components"; + +const columnHelper = createColumnHelper(); + +interface CreateEsntlAtchFileColumnsOptions { + docSeCodes: Code[] | undefined; + onEdit: (row: EsntlAtchFileMng) => void; + onDelete: (row: EsntlAtchFileMng) => void; +} + +function formatYn(value: string | undefined) { + if (value === "Y") return "예"; + if (value === "N") return "아니오"; + return "—"; +} + +function formatOptional(value: string | undefined) { + return value?.trim() ? value : "—"; +} + +export function createEsntlAtchFileColumns({ + docSeCodes, + onEdit, + onDelete, +}: CreateEsntlAtchFileColumnsOptions) { + return [ + columnHelper.accessor("esntlAtchFileId", { + header: "필수첨부파일아이디", + meta: { align: "center" }, + }), + columnHelper.accessor("docSeComCd", { + header: "문서구분", + cell: ({ getValue }) => findCodeLabel(docSeCodes, getValue()) || getValue() || "—", + }), + columnHelper.accessor("esntlAtchFileNm", { + header: "필수첨부파일명", + meta: { align: "left" }, + }), + columnHelper.accessor("esntlAtchYn", { + header: "필수첨부여부", + meta: { align: "center" }, + cell: ({ getValue }) => formatYn(getValue()), + }), + columnHelper.accessor("esntlAtchFileRmrk", { + header: "비고", + meta: { align: "left" }, + cell: ({ getValue }) => formatOptional(getValue()), + }), + columnHelper.accessor("regDt", { + header: "등록일시", + meta: { align: "center" }, + cell: ({ getValue }) => formatOptional(getValue()), + }), + columnHelper.accessor("mdfcnDt", { + header: "수정일시", + meta: { align: "center" }, + cell: ({ getValue }) => formatOptional(getValue()), + }), + columnHelper.display({ + id: "actions", + header: "관리", + cell: ({ row }) => ( +
e.stopPropagation()}> + + +
+ ), + }), + ]; +} diff --git a/apps/as/src/pages/esntl-atch-file-mng/ui/esntl-atch-file-form-dialog.tsx b/apps/as/src/pages/esntl-atch-file-mng/ui/esntl-atch-file-form-dialog.tsx new file mode 100644 index 000000000..0f1faf8a1 --- /dev/null +++ b/apps/as/src/pages/esntl-atch-file-mng/ui/esntl-atch-file-form-dialog.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; + +import { CodeSelect } from "@/entities/code"; +import type { EsntlAtchFileMng } from "@/entities/esntl"; +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, + RadioGroup, + RadioGroupItem, + Textarea, +} from "@common/components"; + +export interface EsntlAtchFileFormValues { + docSeComCd: string; + esntlAtchFileNm: string; + esntlAtchYn: "Y" | "N"; + esntlAtchFileRmrk: string; +} + +interface EsntlAtchFileFormDialogProps { + mode: "create" | "edit"; + open: boolean; + onOpenChange: (open: boolean) => void; + initial?: EsntlAtchFileMng | null; + isSubmitting?: boolean; + onSubmit: (values: EsntlAtchFileFormValues) => void; +} + +function EsntlAtchFileFormDialog({ + mode, + open, + onOpenChange, + initial, + isSubmitting, + onSubmit, +}: EsntlAtchFileFormDialogProps) { + const { register, handleSubmit, reset, setValue, watch } = useForm({ + defaultValues: { + docSeComCd: "", + esntlAtchFileNm: "", + esntlAtchYn: "Y", + esntlAtchFileRmrk: "", + }, + }); + + const docSeComCd = watch("docSeComCd"); + const esntlAtchYn = watch("esntlAtchYn"); + + useEffect(() => { + if (!open) return; + + if (mode === "edit" && initial) { + reset({ + docSeComCd: initial.docSeComCd ?? "", + esntlAtchFileNm: initial.esntlAtchFileNm ?? "", + esntlAtchYn: initial.esntlAtchYn ?? "Y", + esntlAtchFileRmrk: initial.esntlAtchFileRmrk ?? "", + }); + return; + } + + reset({ + docSeComCd: "", + esntlAtchFileNm: "", + esntlAtchYn: "Y", + esntlAtchFileRmrk: "", + }); + }, [open, mode, initial, reset]); + + return ( + + + + {mode === "create" ? "필수첨부파일 등록" : "필수첨부파일 수정"} + +
+ {mode === "edit" && initial ? ( +
+ +

+ {initial.esntlAtchFileId} +

+
+ ) : null} +
+ + setValue("docSeComCd", v, { shouldValidate: true })} + placeholder="문서구분 선택" + className="w-full" + /> +
+
+ + +
+
+ + + setValue("esntlAtchYn", v as "Y" | "N", { shouldValidate: true }) + } + className="flex gap-6" + > +
+ + +
+
+ + +
+
+
+
+ +