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/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/package.json b/apps/as/package.json index 389b5bc05..b30e4e45e 100644 --- a/apps/as/package.json +++ b/apps/as/package.json @@ -17,6 +17,7 @@ "@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", @@ -27,6 +28,7 @@ "lucide-react": "^0.474.0", "next": "15.3.8", "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..60690a0b9 --- /dev/null +++ b/apps/as/src/entities/acntg/api/acntg-service.ts @@ -0,0 +1,15 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; + +import type { AcntgSeMngTreeDto, AcntgService } from "../model/acntg-interface"; + +async function findTree(): Promise { + const response = await client.get( + `${ENDPOINT.AS_COMMON_SERVICE.ACNTG_SE}/tree`, + ); + return response.data; +} + +export const acntgService = { + findTree, +} satisfies AcntgService; diff --git a/apps/as/src/entities/acntg/index.ts b/apps/as/src/entities/acntg/index.ts new file mode 100644 index 000000000..7a1562423 --- /dev/null +++ b/apps/as/src/entities/acntg/index.ts @@ -0,0 +1,2 @@ +export type { AcntgSeMngTreeDto, AcntgService } from "./model/acntg-interface"; +export { acntgService } from "./api/acntg-service"; 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..52bae8010 --- /dev/null +++ b/apps/as/src/entities/acntg/model/acntg-interface.ts @@ -0,0 +1,23 @@ +/** 회계구분 트리 노드 DTO */ +export interface AcntgSeMngTreeDto { + /** 들여쓰기 포함 표시명 (앞 공백 수 = 레벨 인디케이터) */ + indAcntgSeCdNm: string; + /** 회계구분코드 */ + acntgSeCd: string; + /** 회계구분코드명 */ + acntgSeCdNm: string; + /** 상위 회계구분코드 (null = 최상위) */ + upAcntgSeCd: string | null; + /** 사용여부 */ + useYn: string; + /** 트리 레벨 (1 = 총사업비, 2 = 1.공사비/2.보상비/3.부대경비, …) */ + treeLevel: number; + /** 최상위~현재 노드까지 "|" 구분 경로 */ + treePath: string; +} + +/** 회계구분 서비스 인터페이스 */ +export interface AcntgService { + /** 회계구분 트리 전체 조회 */ + findTree(): 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..d3b6713e8 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}/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..7e3187ce7 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,24 @@ 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; +} + +export interface CklstTreeSearchParams { + docSeComCd?: string; + taskSeComCd?: string; +} + export interface CklstMngSearchParams extends PageableParams { cklstNm?: string; taskSeComCd?: string; @@ -30,6 +48,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..00eb80371 --- /dev/null +++ b/apps/as/src/entities/cnstp/api/cnstp-service.ts @@ -0,0 +1,15 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; + +import type { CnstpSeMngTreeDto, CnstpService } from "../model/cnstp-interface"; + +async function findTree(): Promise { + const response = await client.get( + `${ENDPOINT.AS_COMMON_SERVICE.CNSTP_SE}/tree`, + ); + return response.data; +} + +export const cnstpService = { + findTree, +} satisfies CnstpService; diff --git a/apps/as/src/entities/cnstp/index.ts b/apps/as/src/entities/cnstp/index.ts new file mode 100644 index 000000000..1415c59bd --- /dev/null +++ b/apps/as/src/entities/cnstp/index.ts @@ -0,0 +1,2 @@ +export type { CnstpSeMngTreeDto, CnstpService } from "./model/cnstp-interface"; +export { cnstpService } from "./api/cnstp-service"; 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..d68f0625b --- /dev/null +++ b/apps/as/src/entities/cnstp/model/cnstp-interface.ts @@ -0,0 +1,23 @@ +/** 공종구분 트리 노드 DTO */ +export interface CnstpSeMngTreeDto { + /** 들여쓰기 포함 표시명 */ + indCnstpSeCdNm: string; + /** 공종구분코드 */ + cnstpSeCd: string; + /** 공종구분코드명 */ + cnstpSeCdNm: string; + /** 상위 공종구분코드 (null = 최상위) */ + upCnstpCd: string | null; + /** 사용여부 */ + useYn: string; + /** 트리 레벨 */ + treeLevel: number; + /** 최상위~현재 노드까지 "|" 구분 경로 */ + treePath: string; +} + +/** 공종구분 서비스 인터페이스 */ +export interface CnstpService { + /** 공종구분 트리 전체 조회 */ + findTree(): 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/instl-sncn/api/instl-sncn-service.ts b/apps/as/src/entities/instl-sncn/api/instl-sncn-service.ts index 29d15fcf0..dd7dccd7a 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,6 +1,7 @@ import { client } from "@/shared/api"; import { ENDPOINT } from "@/shared/config"; import type { + InstlSncnApplySaveRequestDto, InstlSncnMng, InstlSncnMngSearchParams, InstlSncnMngService, @@ -19,6 +20,14 @@ export class InstlSncnApiService implements InstlSncnMngService { 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; + } } export const InstlSncnApi = new InstlSncnApiService(); 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..63d280d86 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; @@ -52,7 +54,57 @@ 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; + selected: "Y" | "N" | null; +} + +/** 설치인가 신청 - 필수첨부파일 저장 DTO(배열 원소) */ +export interface InstlSncnEsntlAtchFileSaveDto { + esntlAtchFileId: string; + atchFileGroupId: string; +} + +/** 설치인가 신청 - 통합 저장 요청 DTO */ +export interface InstlSncnApplySaveRequestDto { + instlSncnMng: InstlSncnMng; + bzScaleList: InstlSncnBzScaleSaveDto[]; + bizCstList: InstlSncnBizCstSaveRequestDto[]; + cklstResultList: InstlSncnCklstResultSaveDto[]; + esntlAtchFileList: InstlSncnEsntlAtchFileSaveDto[]; +} + export interface InstlSncnMngService { getPage: (params: InstlSncnMngSearchParams) => Promise>; getById: (id: string) => Promise; + saveApply: (payload: InstlSncnApplySaveRequestDto) => 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..71dcc5833 --- /dev/null +++ b/apps/as/src/entities/user-organization/model/ognz-user-tree-interface.ts @@ -0,0 +1,23 @@ +/** OgnzUserTreeResponseDto.UserDto */ +export interface OgnzUserTreeUserDto { + userUnqId: string; + userNm: string; + jbpsSeCd: string; + jbgdSeCd: 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/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/filter-ognz-user-tree.ts b/apps/as/src/pages/approval-line-management/lib/filter-ognz-user-tree.ts new file mode 100644 index 000000000..cf8be566e --- /dev/null +++ b/apps/as/src/pages/approval-line-management/lib/filter-ognz-user-tree.ts @@ -0,0 +1,44 @@ +import type { OgnzUserTreeResponseDto } from "@/entities/user-organization"; + +export function filterOgnzUserTree( + nodes: OgnzUserTreeResponseDto[], + keyword: string, +): OgnzUserTreeResponseDto[] { + const q = keyword.trim().toLowerCase(); + if (!q) return nodes; + + return nodes + .map((node) => { + const ognzMatches = node.ognzNm.toLowerCase().includes(q); + const filteredUsers = ognzMatches + ? node.users + : node.users.filter((user) => user.userNm.toLowerCase().includes(q)); + const filteredChildren = filterOgnzUserTree(node.children ?? [], keyword); + + if (ognzMatches || filteredUsers.length > 0 || filteredChildren.length > 0) { + return { + ...node, + users: filteredUsers, + children: filteredChildren, + }; + } + + return null; + }) + .filter((node): node is OgnzUserTreeResponseDto => node !== null); +} + +export function collectExpandedOgnzSnIds(nodes: OgnzUserTreeResponseDto[]): Set { + const ids = new Set(); + + function walk(list: OgnzUserTreeResponseDto[]) { + for (const node of list) { + const hasContent = (node.users?.length ?? 0) > 0 || (node.children?.length ?? 0) > 0; + if (hasContent) ids.add(node.ognzSn); + walk(node.children ?? []); + } + } + + walk(nodes); + return ids; +} 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..c5fbe0b71 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,43 @@ import type { ElaprAplnRow } from "../model/apln-row-types"; -export const ATRZ_REVIEWER_CD = "ATRZ01"; -export const ATRZ_FINAL_CD = "ATRZ03"; +export const ATRZ_RECEIVER_CD = "ATRZ00"; +export const ATRZ_REVIEWER_1_CD = "ATRZ01"; +export const ATRZ_REVIEWER_2_CD = "ATRZ02"; +export const ATRZ_APPROVER_CD = "ATRZ03"; +export 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 +54,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/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..569646744 --- /dev/null +++ b/apps/as/src/pages/approval-line-management/model/reviewer-option.ts @@ -0,0 +1,15 @@ +import type { OgnzUserTreeUserDto } from "@/entities/user-organization"; + +export interface ReviewerOption { + uuid: string; + name: string; + deptNm: string; +} + +export function toReviewerOption(user: OgnzUserTreeUserDto, ognzNm: string): ReviewerOption { + return { + uuid: user.userUnqId, + name: user.userNm, + deptNm: 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..8b6b7aa1d 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,12 +1,10 @@ "use client"; -import { useMemo, useState } from "react"; - -import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table"; +import { useCallback, useEffect, useState } from "react"; +import { useOgnzUserTree } from "@/entities/user-organization"; import { Button, - DataTable, Dialog, DialogContent, DialogFooter, @@ -15,8 +13,10 @@ import { Input, } from "@common/components"; -import type { ReviewerOption } from "../model/mock-reviewers"; -import { createMockReviewers } from "../model/mock-reviewers"; +import { collectExpandedOgnzSnIds, filterOgnzUserTree } from "../lib/filter-ognz-user-tree"; +import type { ReviewerOption } from "../model/reviewer-option"; + +import { OgnzUserTree } from "./ognz-user-tree"; interface AprvrPickerDialogProps { open: boolean; @@ -24,67 +24,100 @@ interface AprvrPickerDialogProps { onSelect: (reviewer: ReviewerOption) => void; } -const columnHelper = createColumnHelper(); - function AprvrPickerDialog({ open, onOpenChange, onSelect }: AprvrPickerDialogProps) { const [keyword, setKeyword] = useState(""); - const reviewers = useMemo(() => createMockReviewers(), []); + const [displayTree, setDisplayTree] = useState>([]); + const [expandedOgnzSnIds, setExpandedOgnzSnIds] = useState>(new Set()); + const [selectedUserId, setSelectedUserId] = useState(); - 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 { data, isPending, isError } = useOgnzUserTree({ enabled: open }); - const columns = useMemo( - () => [ - columnHelper.accessor("name", { header: "성명" }), - columnHelper.accessor("deptNm", { header: "부서" }), - columnHelper.display({ - id: "actions", - header: "선택", - cell: ({ row }) => ( - - ), - }), - ], - [onOpenChange, onSelect], - ); + const resetState = useCallback(() => { + setKeyword(""); + setSelectedUserId(undefined); + setExpandedOgnzSnIds(new Set()); + setDisplayTree(data ?? []); + }, [data]); - const table = useReactTable({ - data: filtered, - columns, - getCoreRowModel: getCoreRowModel(), - }); + useEffect(() => { + if (!open) { + setKeyword(""); + setSelectedUserId(undefined); + setExpandedOgnzSnIds(new Set()); + return; + } + + setDisplayTree(data ?? []); + }, [open, data]); + + const handleToggle = (ognzSn: number, isExpanded: boolean) => { + setExpandedOgnzSnIds((prev) => { + const next = new Set(prev); + if (isExpanded) next.add(ognzSn); + else next.delete(ognzSn); + return next; + }); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const filtered = filterOgnzUserTree(data ?? [], keyword); + setDisplayTree(filtered); + setExpandedOgnzSnIds(collectExpandedOgnzSnIds(filtered)); + }; + + const handleReset = () => { + resetState(); + }; + + const handleSelect = (reviewer: ReviewerOption) => { + setSelectedUserId(reviewer.uuid); + onSelect(reviewer); + onOpenChange(false); + }; return ( - + 검토인력 선택 -
- setKeyword(e.target.value)} - /> - + +
+
+
+ setKeyword(e.target.value)} + /> + + +
+
+ +
+ +
+ - 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..1dd757ff9 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,64 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) { const sensors = useDndSensors(); const isDisabled = !elaprTmpltId; + const serverRows = aplnQuery.data?.content ?? []; + const canSave = hasUnsavedChanges(rows, serverRows); 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(); }; @@ -205,9 +155,13 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) { disabled={isDisabled || isEditingLocked} onClick={addRow} > - + 행 추가 + + 결재선 추가 -
@@ -222,13 +176,22 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {

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

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

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

) : ( + + + + + + + + + @@ -242,10 +205,10 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) { r.rowKey)} + items={visibleRows.map((r) => r.rowKey)} strategy={verticalListSortingStrategy} > - {rows.map((row, index) => ( + {visibleRows.map((row, index) => ( (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({ diff --git a/apps/as/src/pages/approval-line-management/ui/ognz-user-tree-node.tsx b/apps/as/src/pages/approval-line-management/ui/ognz-user-tree-node.tsx new file mode 100644 index 000000000..193cc24f2 --- /dev/null +++ b/apps/as/src/pages/approval-line-management/ui/ognz-user-tree-node.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { ChevronRight } from "lucide-react"; + +import type { OgnzUserTreeResponseDto } from "@/entities/user-organization"; +import { + Button, + cn, + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@common/components"; + +import type { ReviewerOption } from "../model/reviewer-option"; +import { toReviewerOption } from "../model/reviewer-option"; + +interface OgnzUserTreeNodeProps { + node: OgnzUserTreeResponseDto; + depth?: number; + expandedOgnzSnIds: Set; + selectedUserId?: string; + onToggle: (ognzSn: number, open: boolean) => void; + onSelect: (reviewer: ReviewerOption) => void; +} + +function OgnzUserTreeNode({ + node, + depth = 0, + expandedOgnzSnIds, + selectedUserId, + onToggle, + onSelect, +}: OgnzUserTreeNodeProps) { + const hasUsers = (node.users?.length ?? 0) > 0; + const hasChildren = (node.children?.length ?? 0) > 0; + const isExpandable = hasUsers || hasChildren; + const isOpen = expandedOgnzSnIds.has(node.ognzSn); + const indent = depth * 16 + 8; + + if (!isExpandable) { + return ( +
+ {node.ognzNm} +
+ ); + } + + return ( + onToggle(node.ognzSn, open)} + className="w-full" + > +
+ + + +
+ + + {node.users.map((user) => { + const isSelected = selectedUserId === user.userUnqId; + + return ( +
onSelect(toReviewerOption(user, node.ognzNm))} + > + {user.userNm} + +
+ ); + })} + + {node.children.map((child) => ( + + ))} +
+
+ ); +} + +export { OgnzUserTreeNode }; diff --git a/apps/as/src/pages/approval-line-management/ui/ognz-user-tree.tsx b/apps/as/src/pages/approval-line-management/ui/ognz-user-tree.tsx new file mode 100644 index 000000000..226e878f5 --- /dev/null +++ b/apps/as/src/pages/approval-line-management/ui/ognz-user-tree.tsx @@ -0,0 +1,71 @@ +"use client"; + +import type { OgnzUserTreeResponseDto } from "@/entities/user-organization"; +import { Skeleton } from "@common/components"; + +import type { ReviewerOption } from "../model/reviewer-option"; + +import { OgnzUserTreeNode } from "./ognz-user-tree-node"; + +interface OgnzUserTreeProps { + nodes: OgnzUserTreeResponseDto[]; + isPending?: boolean; + isError?: boolean; + expandedOgnzSnIds: Set; + selectedUserId?: string; + onToggle: (ognzSn: number, open: boolean) => void; + onSelect: (reviewer: ReviewerOption) => void; +} + +function OgnzUserTree({ + nodes, + isPending, + isError, + expandedOgnzSnIds, + selectedUserId, + onToggle, + onSelect, +}: OgnzUserTreeProps) { + if (isPending) { + return ( +
+ + + +
+ ); + } + + if (isError) { + return ( +

+ 조직 사용자 목록을 불러오지 못했습니다. +

+ ); + } + + if (nodes.length === 0) { + return ( +

+ 등록된 조직/사용자가 없습니다. +

+ ); + } + + return ( +
+ {nodes.map((node) => ( + + ))} +
+ ); +} + +export { OgnzUserTree }; 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..5539d2347 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 @@ -103,7 +103,7 @@ function SortableAplnRow({ {isEditing ? ( <> + + + ), + }), + ]; +} 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..fe76ec2ce --- /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..3f4bdd3f5 --- /dev/null +++ b/apps/as/src/pages/checklist-management/ui/cklst-artcl-list.tsx @@ -0,0 +1,133 @@ +"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..a6a3eaa0e --- /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..1397d79ff --- /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..d248de9a1 --- /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/instl-sncn/ui/cnfm-list-page.tsx b/apps/as/src/pages/instl-sncn/ui/cnfm-list-page.tsx index 8b0bb9f38..a5ff158dd 100644 --- a/apps/as/src/pages/instl-sncn/ui/cnfm-list-page.tsx +++ b/apps/as/src/pages/instl-sncn/ui/cnfm-list-page.tsx @@ -1,12 +1,17 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; import { dayjs } from "@/shared/lib"; -import { InstlSncnApi, type InstlSncnMngSearchParams } from "@/entities/instl-sncn"; +import { useCodeList } from "@/entities/code"; +import { + INSTL_SNCN_REGISTER_SELECTION_KEY, + InstlSncnApi, + type InstlSncnMngSearchParams, +} from "@/entities/instl-sncn"; import "./cnfm-page.css"; @@ -65,6 +70,17 @@ function CnfmListPage() { }); const [pageIndex, setPageIndex] = useState(0); const [pageSize, setPageSize] = useState(10); + const [taskDialogOpen, setTaskDialogOpen] = useState(false); + const [selectedTaskSeComCd, setSelectedTaskSeComCd] = useState(""); + + const { data: fnrsCodeList = [] } = useCodeList("INSTL"); + + useEffect(() => { + if (!taskDialogOpen) return; + if (selectedTaskSeComCd) return; + if (fnrsCodeList.length === 0) return; + setSelectedTaskSeComCd(fnrsCodeList[0].lwpoCd); + }, [taskDialogOpen, selectedTaskSeComCd, fnrsCodeList]); const query = useQuery({ queryKey: ["instl-sncn-list", search, pageIndex, pageSize], @@ -91,6 +107,26 @@ function CnfmListPage() { setSearch({ bizNm, taskSeComCd, sidoComCd, sggComCd }); }; + const handleOpenTaskDialog = () => { + setSelectedTaskSeComCd((prev) => prev || fnrsCodeList[0]?.lwpoCd || ""); + setTaskDialogOpen(true); + }; + + const handleConfirmTaskDialog = () => { + const selected = fnrsCodeList.find((item) => item.lwpoCd === selectedTaskSeComCd); + if (!selected) return; + + sessionStorage.setItem( + INSTL_SNCN_REGISTER_SELECTION_KEY, + JSON.stringify({ + taskSeComCd: selected.lwpoCd, + taskSeComCdNm: selected.lwpoCdNm, + }), + ); + setTaskDialogOpen(false); + router.push("/instl-sncn/register"); + }; + return (
{/* 검색 영역 */} @@ -160,7 +196,7 @@ function CnfmListPage() { @@ -311,6 +347,58 @@ function CnfmListPage() {
+ + {taskDialogOpen && ( +
+
+
+

업무구분 선택

+ +
+ +
+
+ {fnrsCodeList.map((item) => ( + + ))} +
+
+ +
+ + +
+
+
+ )} ); } diff --git a/apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx b/apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx index 0b2af414a..e7f2e1261 100644 --- a/apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx +++ b/apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx @@ -1,86 +1,1255 @@ "use client"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; + +import { useQuery } from "@tanstack/react-query"; +import { type HttpResponse,Upload } from "tus-js-client"; + +import { AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "@/shared/config"; +import { getCookie, setCookie } from "@/shared/lib"; +import type { AcntgSeMngTreeDto } from "@/entities/acntg"; +import { acntgService } from "@/entities/acntg"; +import type { CklstTreeNode } from "@/entities/cklst"; +import { cklstMngService } from "@/entities/cklst"; +import type { CnstpSeMngTreeDto } from "@/entities/cnstp"; +import { cnstpService } from "@/entities/cnstp"; +import { + INSTL_SNCN_REGISTER_SELECTION_KEY, + InstlSncnApi, + type InstlSncnApplySaveRequestDto, + type InstlSncnBizCstSaveRequestDto, + type InstlSncnBzScaleSaveDto, + type InstlSncnMng, +} from "@/entities/instl-sncn"; import { Button, Input } from "@common/components"; import "./cnfm-page.css"; +/* ------------------------------------------------------------------ */ +/* 타입 */ +/* ------------------------------------------------------------------ */ +type BzScaleRow = { + bscPl: string; // 예산반영(A) + budgetReflct: string; // 표준사업비(B) + thtiDsgn: string; // 금회설계시(C) +}; + +type BzAmtRow = { + bscPl: string; + budgetReflct: string; + thtiDsgn: string; +}; + +type RequiredAttachKey = + | "applyForm" + | "planGoalYear" + | "adequacyReview" + | "supervisionCost" + | "costBreakdown" + | "designDrawing" + | "etc"; + +type RequiredAttachItem = { + key: RequiredAttachKey; + label: string; + atflCd: string; + atflId: string; + fileName: string; + uploading: boolean; +}; + +const REQUIRED_ATTACHMENTS: Array<{ key: RequiredAttachKey; label: string }> = [ + { key: "applyForm", label: "인가신청서" }, + { key: "planGoalYear", label: "계획수립 기준 및 목표연도 계획" }, + { key: "adequacyReview", label: "하수도 시설계획 적정성 검토 결과" }, + { key: "supervisionCost", label: "관로설계시설물 감리비용 산출 계획서" }, + { key: "costBreakdown", label: "사업비 내역서" }, + { key: "designDrawing", label: "설계도면" }, + { key: "etc", label: "기타 첨부자료" }, +]; + +const emptyScale = (): BzScaleRow => ({ bscPl: "", budgetReflct: "", thtiDsgn: "" }); +const emptyAmt = (): BzAmtRow => ({ bscPl: "", budgetReflct: "", thtiDsgn: "" }); + +const num = (v: string) => parseFloat(v.replace(/,/g, "") || "0"); +const fmt = (v: number) => (isNaN(v) ? "" : v.toLocaleString()); + +/* ------------------------------------------------------------------ */ +/* 공통 셀 컴포넌트 (컴포넌트 외부에 선언해야 re-mount 방지) */ +/* ------------------------------------------------------------------ */ +const NumCell = ({ value, onChange }: { value: string; onChange: (v: string) => void }) => ( + + onChange(e.target.value)} + /> + +); + +const ReadCell = ({ value }: { value: string | number }) => ( + {value} +); + +/* ------------------------------------------------------------------ */ +/* 메인 컴포넌트 */ +/* ------------------------------------------------------------------ */ function CnfmRegistPage() { const router = useRouter(); + const [taskSeComCdNm, setTaskSeComCdNm] = useState("재이용수"); + const [taskSeComCd, setTaskSeComCd] = useState("RE"); + useEffect(() => { + const raw = sessionStorage.getItem(INSTL_SNCN_REGISTER_SELECTION_KEY); + if (!raw) return; + + try { + const parsed = JSON.parse(raw) as { taskSeComCd?: string; taskSeComCdNm?: string }; + setTaskSeComCd(parsed.taskSeComCd || "RE"); + setTaskSeComCdNm(parsed.taskSeComCdNm || "재이용수"); + } catch { + // noop + } finally { + sessionStorage.removeItem(INSTL_SNCN_REGISTER_SELECTION_KEY); + } + }, []); + + /* ── 기본 정보 ── */ + const [bzNm, setBzNm] = useState(""); + const [bzBgngYr, setBzBgngYr] = useState(""); + const [bzEndYr, setBzEndYr] = useState(""); + const [bzCn, setBzCn] = useState(""); + const [instlCnfmApr, setInstlCnfmApr] = useState("CNFM"); + + /* ── 설계자 정보 ── */ + const [dsgnrNm, setDsgnrNm] = useState(""); + const [dsgnrPsitn, setDsgnrPsitn] = useState(""); + const [dsgnrTelno, setDsgnrTelno] = useState(""); + const [dsgnrEmlAddr, setDsgnrEmlAddr] = useState(""); + + /* ── 개인정보 동의 ── */ + const [privacyAgree, setPrivacyAgree] = useState<"Y" | "N">("N"); + + /* ── 기타 첨부파일 ── */ + type EtcFile = { id: number; name: string; ext: string; size: string; uploader: string }; + const [etcFiles, setEtcFiles] = useState([]); + const [etcNoticeOpen, setEtcNoticeOpen] = useState(true); + + /* ── 필수 첨부파일 ── */ + const [requiredFiles, setRequiredFiles] = useState>(() => + REQUIRED_ATTACHMENTS.reduce( + (acc, item) => { + acc[item.key] = { + key: item.key, + label: item.label, + atflCd: "", + atflId: "", + fileName: "", + uploading: false, + }; + return acc; + }, + {} as Record, + ), + ); + const requiredFileInputRefs = useRef>({ + applyForm: null, + planGoalYear: null, + adequacyReview: null, + supervisionCost: null, + costBreakdown: null, + designDrawing: null, + etc: null, + }); + + const uploadRequiredFile = (file: File, key: RequiredAttachKey) => { + setRequiredFiles((prev) => ({ + ...prev, + [key]: { ...prev[key], uploading: true }, + })); + + setCookie("atfl-cd-key", ""); + + const upload = new Upload(file, { + endpoint: ENDPOINT.ATTACH_SERVICE.TUS_UPLOAD, + chunkSize: 10 * 1024 * 1024, + headers: { + [CLAIM_NAME]: getCookie(CLAIM_NAME) ?? "", + [AUTH_USER_ID]: getCookie(AUTH_USER_ID) ?? "", + }, + metadata: { + filename: file.name, + filetype: file.type, + }, + onError(error: Error) { + console.error("필수 첨부파일 업로드 실패", error); + setRequiredFiles((prev) => ({ + ...prev, + [key]: { ...prev[key], uploading: false }, + })); + alert("필수 첨부파일 업로드 중 오류가 발생했습니다."); + }, + onAfterResponse(_req: unknown, res: HttpResponse) { + const fileInfo = res.getHeader("X-File-Info"); + if (typeof fileInfo !== "string") return; + + const parsed = JSON.parse(fileInfo) as { atflCd?: string; atflId?: string }; + const atflCd = parsed.atflCd ?? ""; + const atflId = parsed.atflId ?? ""; + if (atflCd) setCookie("atfl-cd-key", atflCd); + + setRequiredFiles((prev) => ({ + ...prev, + [key]: { + ...prev[key], + atflCd, + atflId, + fileName: file.name, + uploading: false, + }, + })); + }, + onSuccess() { + setRequiredFiles((prev) => ({ + ...prev, + [key]: { ...prev[key], uploading: false }, + })); + }, + }); + + upload.start(); + }; + + const handleRequiredFileChange = (key: RequiredAttachKey, e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + uploadRequiredFile(file, key); + e.target.value = ""; + }; + + /* ── 체크리스트 ── */ + type CheckResultVal = "Y" | "N" | null; + const [checkResults, setCheckResults] = useState>({}); + const cklstRowRefs = useRef>({}); + + const { data: cklstTree = [] } = useQuery({ + queryKey: ["cklstTree", "INSTL", taskSeComCd], + queryFn: () => cklstMngService.findTree({ docSeComCd: "INSTL", taskSeComCd }), + enabled: Boolean(taskSeComCd), + staleTime: Infinity, + }); + + const cklstRows = useMemo(() => { + // bchckUseYn 필터 제거: 부모가 있는 자식 노드(bchckUseYn=N)도 트리에 포함 + const useRows = cklstTree; + if (useRows.length === 0) return [] as CklstTreeNode[]; + + // artcl 단위로 분리하지 않고 전체 rows에서 단일 childMap을 구성한다. + // upCklstRvwSn이 다른 artcl 소속 노드를 부모로 가질 수 있으므로 + // artcl 경계를 넘는 부모-자식 관계도 처리해야 한다. + const childMap = new Map(); + for (const row of useRows) { + const parent = row.upCklstRvwSn ?? null; + if (!childMap.has(parent)) childMap.set(parent, []); + childMap.get(parent)!.push(row); + } + + for (const list of childMap.values()) { + list.sort((a, b) => + a.cklstArtclSn !== b.cklstArtclSn + ? a.cklstArtclSn - b.cklstArtclSn + : a.sortSeq !== b.sortSeq + ? a.sortSeq - b.sortSeq + : a.cklstRvwSn - b.cklstRvwSn, + ); + } + + const ordered: CklstTreeNode[] = []; + const walk = (parent: number | null) => { + const children = childMap.get(parent) ?? []; + for (const child of children) { + ordered.push(child); + walk(child.cklstRvwSn); + } + }; + walk(null); + + return ordered; + }, [cklstTree]); + + const cklstKey = (row: CklstTreeNode) => + `${row.cklstSn}_${row.cklstArtclSn}_${row.cklstRvwSn}`; + + useEffect(() => { + setCheckResults((prev) => { + const next: Record = {}; + let changed = false; + + cklstRows.forEach((row) => { + const key = cklstKey(row); + if (Object.prototype.hasOwnProperty.call(prev, key)) { + next[key] = prev[key]; + } else { + next[key] = null; + changed = true; + } + }); + + // 기존 key가 사라졌거나 값이 달라진 경우만 상태 업데이트 + if (!changed) { + const prevKeys = Object.keys(prev); + if (prevKeys.length !== Object.keys(next).length) { + changed = true; + } else { + for (const key of prevKeys) { + if (!(key in next) || prev[key] !== next[key]) { + changed = true; + break; + } + } + } + } + + return changed ? next : prev; + }); + }, [cklstRows]); + + const cklstStats = useMemo(() => { + const total = cklstRows.length; + const completed = cklstRows.reduce((acc, row) => { + const val = checkResults[cklstKey(row)] ?? ""; + return acc + (val ? 1 : 0); + }, 0); + const percent = total > 0 ? Math.round((completed / total) * 100) : 0; + return { total, completed, percent }; + }, [cklstRows, checkResults]); + + const toCklstSaveDtoList = () => + cklstRows + .map((row) => ({ + cklstSn: row.cklstSn, + cklstArtclSn: row.cklstArtclSn, + cklstRvwSn: row.cklstRvwSn, + selected: checkResults[cklstKey(row)] ?? null, + })); + + const toEsntlAtchFileSaveDtoList = () => + Object.values(requiredFiles) + .filter((item) => Boolean(item.atflCd) && Boolean(item.atflId)) + .map((item) => ({ + esntlAtchFileId: item.key, + atchFileGroupId: item.atflCd, + })); + + /* ── 사업규모 (공종구분 트리 연동) ── */ + // cnstpSeCd → { bscPl, budgetReflct, thtiDsgn } 단일 map + const [bzScaleMap, setBzScaleMap] = useState>({}); + + /* ── 공종구분 트리 로드 ── */ + const { data: cnstpTree = [] } = useQuery({ + queryKey: ["cnstpTree"], + queryFn: cnstpService.findTree, + staleTime: Infinity, + }); + + /* ── 공종구분 리프 노드 집합 ── */ + const cnstpLeafSet = useMemo(() => { + const parentCodes = new Set(cnstpTree.map((n) => n.upCnstpCd).filter(Boolean) as string[]); + return new Set(cnstpTree.filter((n) => !parentCodes.has(n.cnstpSeCd)).map((n) => n.cnstpSeCd)); + }, [cnstpTree]); + + const subtotalScale = (node: CnstpSeMngTreeDto, col: keyof BzScaleRow): number => + cnstpTree + .filter((n) => cnstpLeafSet.has(n.cnstpSeCd) && n.treePath.split("|").includes(node.cnstpSeCd)) + .reduce((sum, n) => sum + num((bzScaleMap[n.cnstpSeCd] ?? emptyScale())[col]), 0); + + const scaleNodeVal = (node: CnstpSeMngTreeDto, col: keyof BzScaleRow): number => + cnstpLeafSet.has(node.cnstpSeCd) + ? num((bzScaleMap[node.cnstpSeCd] ?? emptyScale())[col]) + : subtotalScale(node, col); + + const handleScaleInput = (cnstpSeCd: string, col: keyof BzScaleRow, val: string) => + setBzScaleMap((prev) => ({ + ...prev, + [cnstpSeCd]: { ...(prev[cnstpSeCd] ?? emptyScale()), [col]: val }, + })); + + /** 사업규모 맵 -> 백엔드 저장용 배열 DTO 변환 */ + const toBzScaleSaveDtoList = (): InstlSncnBzScaleSaveDto[] => + cnstpTree + .filter((node) => cnstpLeafSet.has(node.cnstpSeCd)) + .map((node) => { + const row = bzScaleMap[node.cnstpSeCd] ?? emptyScale(); + const bscPl = num(row.bscPl); + const budgetReflct = num(row.budgetReflct); + const thtiDsgn = num(row.thtiDsgn); + + return { + cnstpSeCd: node.cnstpSeCd, + bscPl, + budgetReflct, + thtiDsgn, + diff: thtiDsgn - budgetReflct, + }; + }); + + /* ── 총사업비 (회계구분 트리 연동) ── */ + // acntgSeCd → { bscPl, budgetReflct, thtiDsgn } 단일 map + const [amtMap, setAmtMap] = useState>({}); + + /* ── 회계구분 트리 로드 ── */ + const { data: acntgTree = [] } = useQuery({ + queryKey: ["acntgTree"], + queryFn: acntgService.findTree, + staleTime: Infinity, // 코드 데이터 — 앱 수명 동안 재요청 불필요 + }); + + /* ── 리프 노드 집합 (자식이 없는 노드 = 직접 입력 가능) ── */ + const leafSet = useMemo(() => { + const parentCodes = new Set(acntgTree.map((n) => n.upAcntgSeCd).filter(Boolean) as string[]); + return new Set(acntgTree.filter((n) => !parentCodes.has(n.acntgSeCd)).map((n) => n.acntgSeCd)); + }, [acntgTree]); + + /** + * treePath 기반 소계 계산 + * - 이 노드의 acntgSeCd 가 treePath 안에 포함된 리프 노드들의 합산 + * - 새로운 하위 레벨이 추가돼도 자동으로 포함됨 + */ + const subtotal = (node: AcntgSeMngTreeDto, col: keyof BzAmtRow): number => + acntgTree + .filter((n) => leafSet.has(n.acntgSeCd) && n.treePath.split("|").includes(node.acntgSeCd)) + .reduce((sum, n) => sum + num((amtMap[n.acntgSeCd] ?? emptyAmt())[col]), 0); + + /** 노드 값 반환 — 리프이면 입력값, 부모이면 소계 */ + const nodeVal = (node: AcntgSeMngTreeDto, col: keyof BzAmtRow): number => + leafSet.has(node.acntgSeCd) + ? num((amtMap[node.acntgSeCd] ?? emptyAmt())[col]) + : subtotal(node, col); + + /** 단일 셀 입력 핸들러 */ + const handleAmtInput = (acntgSeCd: string, col: keyof BzAmtRow, val: string) => + setAmtMap((prev) => ({ + ...prev, + [acntgSeCd]: { ...(prev[acntgSeCd] ?? emptyAmt()), [col]: val }, + })); + + /** 총사업비 맵 -> 백엔드 저장용 배열 DTO 변환 */ + const toBizCstSaveDtoList = (): InstlSncnBizCstSaveRequestDto[] => + acntgTree + .filter((node) => leafSet.has(node.acntgSeCd)) + .map((node) => { + const row = amtMap[node.acntgSeCd] ?? emptyAmt(); + return { + acntgSeCd: node.acntgSeCd, + bscPlanAmt: num(row.bscPl), + bgtRfltAmt: num(row.budgetReflct), + tstmDesignAmt: num(row.thtiDsgn), + }; + }); + + /** + * indAcntgSeCdNm 의 앞 공백 수를 파싱해 padding px 값으로 반환 + * (공백 4칸 = 1 depth = 1rem) + */ + const indentPx = (indNm: string): number => { + const spaces = indNm.length - indNm.trimStart().length; + return (spaces / 4) * 16; // 4공백 = 16px + }; + + /** 트리 행 스타일 분류 */ + const rowStyle = (node: AcntgSeMngTreeDto) => { + if (node.treeLevel === 1) return "bg-blue-50 font-bold"; + if (node.treeLevel === 2) return "bg-gray-100 font-semibold"; + if (!leafSet.has(node.acntgSeCd)) return "font-semibold"; // 중간 부모 (예: 하수관로) + return ""; + }; + + /** 공종 트리 행 스타일 분류 */ + const scaleRowStyle = (node: CnstpSeMngTreeDto) => { + if (node.treeLevel === 1) return "bg-gray-50 font-semibold"; + if (!cnstpLeafSet.has(node.cnstpSeCd)) return "font-semibold"; + return ""; + }; + + /* ── 증감 라벨 ── */ + const diffLabel = (v: number) => (v > 0 ? `+${fmt(v)}` : fmt(v)); + + /* ── 검증 ── */ + function fnValidation(mode: "SAVE" | "PRESENTN") { + /* + if (!bzNm.trim()) { + alert("사업명을 입력하세요."); + return false; + } + if (!bzBgngYr || !bzEndYr) { + alert("사업기간을 입력하세요."); + return false; + } + if (!bzCn.trim()) { + alert("사업내용을 입력하세요."); + return false; + } + if (mode === "PRESENTN" && privacyAgree !== "Y") { + alert("개인정보 제공에 동의하셔야 제출이 가능합니다."); + return false; + } + + + // 체크리스트 사용 항목(Y) 중 미선택 항목이 있으면 진행 불가 + const hasUncheckedChecklist = cklstRows.some( + (row) => !checkResults[cklstKey(row)], + ); + if (hasUncheckedChecklist) { + alert("체크리스트를 모두 체크하세요."); + return false; + } + // 저장 시: 사용 항목(Y)은 검토 결과가 반드시 예(Y)여야 함 + */ + if (mode === "SAVE") { + const invalidYesRow = cklstRows.find( + (row) => row.bchckUseYn === "Y" && checkResults[cklstKey(row)] !== "Y", + ); + if (invalidYesRow) { + const invalidKey = cklstKey(invalidYesRow); + const invalidRowEl = cklstRowRefs.current[invalidKey]; + invalidRowEl?.scrollIntoView({ behavior: "smooth", block: "center" }); + const yesRadio = invalidRowEl?.querySelector( + `input[name="chk_${invalidKey}"][value="Y"]`, + ); + yesRadio?.focus(); + alert(`${invalidYesRow.rvwCn} 검토내용은 예(Y)여야 합니다.`); + return false; + } + } + + return true; + } + + const toInstlSncnMngPayload = (prgrsSttsComCd: "SV" | "ST"): InstlSncnMng => ({ + instlSncnDocNo: "", + dongId: "", + sidoNm: "", + sggNm: "", + chrgrNm: "", + instNm: "", + envoSeComCd: instlCnfmApr, + envoSeCdNm: "", + taskSeComCd, + taskSeCdNm: taskSeComCdNm, + bgtBizCd: "", + bgtAplyYr: "", + bizNm: bzNm, + bizBgngYr: bzBgngYr, + bizEndYr: bzEndYr, + bizCn: bzCn, + aplcntNm: "", + aplcntMblTelno: "", + aplcntOgdpNm: "", + aplcntEmlAddr: "", + dsgnrNm, + dsgnrTelno, + dsgnrOgdpNm: dsgnrPsitn, + dsgnrEmlAddr, + atchFileGroupId: "", + prgrsSttsComCd, + prgrsSttsCdNm: "", + regDt: "", + mdfcnDt: "", + rgtrNm: "", + }); + + async function handleSave() { + if (!fnValidation("SAVE")) return; + const payload: InstlSncnApplySaveRequestDto = { + instlSncnMng: toInstlSncnMngPayload("SV"), // 저장 시 진행상태는 SV (임시저장) + bzScaleList: toBzScaleSaveDtoList(), + bizCstList: toBizCstSaveDtoList(), + cklstResultList: toCklstSaveDtoList(), + esntlAtchFileList: toEsntlAtchFileSaveDtoList(), + }; + + try { + const issuedDocNo = await InstlSncnApi.saveApply(payload); + alert(`저장되었습니다. 문서번호: ${issuedDocNo}`); + } catch (error) { + console.error("저장 API 호출 실패", error); + alert("저장 중 오류가 발생했습니다."); + } + } + + function handleSubmit() { + if (!fnValidation("PRESENTN")) return; + const payload = { + instlSncnMng: toInstlSncnMngPayload("ST"), + bzScaleList: toBzScaleSaveDtoList(), + bizCstList: toBizCstSaveDtoList(), + cklstRvwRsltList: toCklstSaveDtoList(), + }; + void payload; + alert("제출 API 연동 후 progrsSttus=ST 전송"); + } + + /* ---------------------------------------------------------------- */ return (
+ {/* ── 프로세스 스텝 바 ── */}
-

설치인가 신청

-

- AS-IS 화면 설계(신청 화면)를 반영한 입력 레이아웃입니다. -

+
+ {["설치인가 신청", "기술검토/조치계획 검토", "기술검토 회신", "인가승인"].map( + (label, i) => ( + + + + {label} + + {i < 3 && ›} + + ), + )} +
+ {/* ── 타이틀 + 액션 버튼 ── */} +
+

■ 설치인가 신청/수정

+
+ + +
+
+ + {/* ── 기본 정보 ── */}
-

기본 정보

- +

기본 정보

+
- - + + + + + + + + +
사업명 - + + * 사업명 + +
+ setBzNm(e.target.value)} + /> + +
+ * 사업기간 + +
+ setBzBgngYr(e.target.value)} + /> + ~ + setBzEndYr(e.target.value)} + /> +
+
+ * 사업내용 + +