diff --git a/apps/_template/package.json b/apps/_template/package.json index 9227dcf64..7f1f98585 100644 --- a/apps/_template/package.json +++ b/apps/_template/package.json @@ -19,7 +19,7 @@ "@tanstack/react-table": "^8.21.2", "axios": "^1.8.1", "dayjs": "^1.11.13", - "next": "15.3.8", + "next": "15.5.18", "react-hook-form": "^7.54.2", "zod": "^3.24.2" }, @@ -33,4 +33,4 @@ "tailwindcss": "^3.4.14", "typescript": "^5.7.2" } -} \ No newline at end of file +} diff --git a/apps/as/app/(with-base-layout)/acntg-code-management/page.tsx b/apps/as/app/(with-base-layout)/acntg-code-management/page.tsx new file mode 100644 index 000000000..c22d0f0b4 --- /dev/null +++ b/apps/as/app/(with-base-layout)/acntg-code-management/page.tsx @@ -0,0 +1 @@ +export { AcntgCodeManagementPage as default } from "@/pages/acntg-code-management"; diff --git a/apps/as/app/(with-base-layout)/cnstp-code-management/page.tsx b/apps/as/app/(with-base-layout)/cnstp-code-management/page.tsx new file mode 100644 index 000000000..d20a49e4f --- /dev/null +++ b/apps/as/app/(with-base-layout)/cnstp-code-management/page.tsx @@ -0,0 +1 @@ +export { CnstpCodeManagementPage as default } from "@/pages/cnstp-code-management"; diff --git a/apps/as/app/(with-base-layout)/code-management/page.tsx b/apps/as/app/(with-base-layout)/code-management/page.tsx deleted file mode 100644 index 7a4daf43f..000000000 --- a/apps/as/app/(with-base-layout)/code-management/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { CodeManagementPage as default } from "@/pages/code-management"; diff --git a/apps/as/app/(with-base-layout)/instl-sncn-old/[id]/modify/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn-old/[id]/modify/page.tsx new file mode 100644 index 000000000..baf4de7af --- /dev/null +++ b/apps/as/app/(with-base-layout)/instl-sncn-old/[id]/modify/page.tsx @@ -0,0 +1,6 @@ +import { CnfmModifyPage } from "@/pages/instl-sncn-old"; + +export default async function InstlSncnModifyPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return ; +} diff --git a/apps/as/app/(with-base-layout)/instl-sncn-old/[id]/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn-old/[id]/page.tsx new file mode 100644 index 000000000..92769d3ef --- /dev/null +++ b/apps/as/app/(with-base-layout)/instl-sncn-old/[id]/page.tsx @@ -0,0 +1,6 @@ +import { CnfmViewPage } from "@/pages/instl-sncn-old"; + +export default async function InstlSncnViewPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return ; +} diff --git a/apps/as/app/(with-base-layout)/instl-sncn-old/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn-old/page.tsx new file mode 100644 index 000000000..6c1e37f16 --- /dev/null +++ b/apps/as/app/(with-base-layout)/instl-sncn-old/page.tsx @@ -0,0 +1,3 @@ +import { CnfmListPage } from "@/pages/instl-sncn-old"; + +export default CnfmListPage; diff --git a/apps/as/app/(with-base-layout)/instl-sncn-old/register/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn-old/register/page.tsx new file mode 100644 index 000000000..82a838f8a --- /dev/null +++ b/apps/as/app/(with-base-layout)/instl-sncn-old/register/page.tsx @@ -0,0 +1,5 @@ +import { CnfmRegistPage } from "@/pages/instl-sncn-old"; + +export default function Page(props: any) { + return ; +} diff --git a/apps/as/app/(with-base-layout)/instl-sncn/[id]/edit/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn/[id]/edit/page.tsx new file mode 100644 index 000000000..35d336bd3 --- /dev/null +++ b/apps/as/app/(with-base-layout)/instl-sncn/[id]/edit/page.tsx @@ -0,0 +1,10 @@ +import { InstlEditPage } from "@/pages/instl-sncn"; + +interface InstlSncnEditRouteProps { + params: Promise<{ id: string }>; +} + +export default async function Page({ params }: InstlSncnEditRouteProps) { + const { id } = await params; + return ; +} diff --git a/apps/as/app/(with-base-layout)/instl-sncn/[id]/modify/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn/[id]/modify/page.tsx deleted file mode 100644 index e4718eceb..000000000 --- a/apps/as/app/(with-base-layout)/instl-sncn/[id]/modify/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { CnfmModifyPage } from "@/pages/instl-sncn"; - -export default async function InstlSncnModifyPage({ - params, -}: { - params: Promise<{ id: string }>; -}) { - const { id } = await params; - return ; -} diff --git a/apps/as/app/(with-base-layout)/instl-sncn/[id]/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn/[id]/page.tsx index 3f25c4a3e..89cd7a329 100644 --- a/apps/as/app/(with-base-layout)/instl-sncn/[id]/page.tsx +++ b/apps/as/app/(with-base-layout)/instl-sncn/[id]/page.tsx @@ -1,10 +1,6 @@ -import { CnfmViewPage } from "@/pages/instl-sncn"; - -export default async function InstlSncnViewPage({ - params, -}: { - params: Promise<{ id: string }>; -}) { - const { id } = await params; - return ; -} +import { InstlViewPage } from "@/pages/instl-sncn"; + +export default async function InstlSncnDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return ; +} diff --git a/apps/as/app/(with-base-layout)/instl-sncn/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn/page.tsx index 7cd7bf82e..a3d6e05ac 100644 --- a/apps/as/app/(with-base-layout)/instl-sncn/page.tsx +++ b/apps/as/app/(with-base-layout)/instl-sncn/page.tsx @@ -1,3 +1,3 @@ -import { CnfmListPage } from "@/pages/instl-sncn"; - -export default CnfmListPage; +import { InstlListPage } from "@/pages/instl-sncn"; + +export default InstlListPage; diff --git a/apps/as/app/(with-base-layout)/instl-sncn/regist/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn/regist/page.tsx new file mode 100644 index 000000000..b19f7abb6 --- /dev/null +++ b/apps/as/app/(with-base-layout)/instl-sncn/regist/page.tsx @@ -0,0 +1,3 @@ +import { InstlRegistPage } from "@/pages/instl-sncn"; + +export default InstlRegistPage; diff --git a/apps/as/app/(with-base-layout)/instl-sncn/register/page.tsx b/apps/as/app/(with-base-layout)/instl-sncn/register/page.tsx deleted file mode 100644 index ffe6a71a4..000000000 --- a/apps/as/app/(with-base-layout)/instl-sncn/register/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { CnfmRegistPage } from "@/pages/instl-sncn"; - -export default function Page(props: any) { - return ; -} diff --git a/apps/as/app/(with-base-layout)/rvw-mnpw-mng/page.tsx b/apps/as/app/(with-base-layout)/rvw-mnpw-mng/page.tsx new file mode 100644 index 000000000..01cf17fb5 --- /dev/null +++ b/apps/as/app/(with-base-layout)/rvw-mnpw-mng/page.tsx @@ -0,0 +1 @@ +export { RvwMnpwMngPage as default } from "@/pages/rvw-mnpw-mng"; diff --git a/apps/as/docs/review-manpower-management/SPEC.md b/apps/as/docs/review-manpower-management/SPEC.md new file mode 100644 index 000000000..be0211783 --- /dev/null +++ b/apps/as/docs/review-manpower-management/SPEC.md @@ -0,0 +1,370 @@ +# 화면설계서: 검토인력관리 + +## 1. 기본 정보 + +| 항목 | 내용 | +| ---------- | -------------------------------------------- | +| 버전 | 0.2 | +| 프로세스명 | UI설계 | +| 화면명 | 검토인력관리 | +| 화면 유형 | [v] 조회 [v] 입력 [ ] 수정 [v] 삭제 [ ] 출력 | +| 메뉴 경로 | 디지털행정지원 > 사이트관리 > 검토인력관리 | + +--- + +## 2. 화면 목적 + +조직도에서 **한 명의 사용자**를 선택한 뒤, 결재 단계(접수·검토·승인·최종승인)별 **인원 배치** 영역에 즉시 등록한다. 등록·목록 갱신은 별도 저장 버튼 없이 **[선택 인원 배치]** 클릭 시 `rvw-mnpw` API로 바로 반영한다. 좌측 조직도는 CMS 조직·사용자 트리 API를, 우측 배치 목록은 검토인력관리 API를 사용한다. + +--- + +## 3. 화면 구성 + +### 3-1. 전체 레이아웃 + +``` +┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ 페이지 타이틀: 검토인력관리 브레드크럼: 디지털행정지원 > 사이트관리 > 검토인력관리 │ +├─────────────────────────────────┬──────────────────────────────────────────────────────────────────┤ +│ [영역 A] 조직도 │ [영역 B] 인원 배치 (결재구분별 4블록, 세로 스택) │ +│ 제목: 조직도 │ │ +│ [A-1] 검색: │ ┌─ [B-1] 접수 ───────────── count ─ [선택 인원 배치] ─┐ │ +│ [성명·부서 검색] [조회][초기화]│ │ (배치 목록 또는 빈 상태 문구) │ │ +│ 선택 안내: │ └──────────────────────────────────────────────────────┘ │ +│ "선택된 인원: {이름} │ ┌─ [B-2] 검토 ───────────── count ─ [선택 인원 배치] ──┐ │ +│ ({부서} / {직급})" │ └──────────────────────────────────────────────────────┘ │ +│ [A-2] 조직 트리 + 사용자 행 │ ┌─ [B-3] 승인 ───────────── count ─ [선택 인원 배치] ──┐ │ +│ ┌────────────────────┐ │ └──────────────────────────────────────────────────────┘ │ +│ │ 조직 트리 + 사용자 │ │ ┌─ [B-4] 최종승인 ───────── count ─ [선택 인원 배치] ──┐ │ +│ │ (단일 선택 강조) │ │ └──────────────────────────────────────────────────────┘ │ +│ └────────────────────┘ │ │ +└─────────────────────────────────┴──────────────────────────────────────────────────────────────────┘ +``` + +### 3-2. 화면 데이터 흐름 + +``` +BEGIN + + // 최초 진입 + CALL userOgnzService.findOgnzUserTree() + DISPLAY 조직도 트리 (영역 A) + + CALL rvwMnpwMngService.findPage({ size: 충분히 큰 값 }) // 또는 atrzSeComCd별 분할 조회 + GROUP BY atrzSeComCd + DISPLAY 인원 배치 4블록 (영역 B-1 ~ B-4) + 건수 뱃지 + + // 영역 A-1: 조직도 검색 (클라이언트 필터 — 서버 API 없음) + ON [조회] 버튼 CLICK / 검색 폼 SUBMIT DO + SET displayTree = filterOgnzUserTree(원본트리, keyword) + SET expandedOgnzSnIds = collectExpandedOgnzSnIds(displayTree) // 매칭 노드 자동 펼침 + DISPLAY 필터된 트리 (영역 A-2) + END ON + + ON [초기화] 버튼 CLICK DO + RESET keyword = "" + RESET displayTree = 원본트리 + RESET expandedOgnzSnIds = 빈 Set (또는 기본 접힘) + DISPLAY 전체 트리 (영역 A-2) + END ON + + // 영역 A-2: 사용자 단일 선택 + ON 조직도 사용자 행 CLICK DO + VALIDATE 동일 행 재클릭이 아닌 경우만 변경 + SET selectedPerson = { userUuid, userNm, ognzSn, ognzNm, jbgdSeCd, ... } + DISPLAY "선택된 인원: {userNm} ({ognzNm} / {직급명})" + END ON + + // 영역 B-k: 선택 인원 즉시 배치 + ON [B-k] [선택 인원 배치] 버튼 CLICK DO + VALIDATE selectedPerson 존재 + SET targetAtrzSeComCd = 블록 k 에 매핑된 결재구분 코드 (§4-2 표) + CALL rvwMnpwMngService.save({ + atrzSeComCd: targetAtrzSeComCd, + ognzSn: selectedPerson.ognzSn, + userUuid: selectedPerson.userUuid + }) + ON 성공 DO + CALL rvwMnpwMngService.findPage (또는 해당 블록 목록만 갱신) + DISPLAY [B-k] 배치 목록·건수 뱃지 갱신 + TOAST "배치되었습니다." + END ON + ON 실패(이미 존재) DO + TOAST (백엔드 메시지 연동) + END ON + END ON + + // 영역 B-k: 배치 인원 제거 (UI 상 삭제 UX — 스크린샷 미표기, API는 제공됨) + ON 배치 인원 [삭제] / [제거] CLICK DO + CONFIRM "해당 인원을 배치에서 제거하시겠습니까?" + CALL rvwMnpwMngService.delete(atrzSeComCd, ognzSn, userUuid) + CALL rvwMnpwMngService.findPage + DISPLAY [B-k] 목록·뱃지 갱신 + END ON + +END +``` + +--- + +## 4. 영역 A — 조직도 + +### 4-1. 표시·상호작용 + +| 요소 | 내용 | +| ---------------- | --------------------------------------------------------------------- | +| 섹션 제목 | 조직도 | +| 검색 폼 (A-1) | 성명·부서 키워드 입력 + **[조회]** + **[초기화]** — §4-3 | +| 선택 안내 문구 | `선택된 인원: {userNm} ({ognzNm} / {직급표시})` — 미선택 시 안내 문구 | +| 트리 (A-2) | 본부 → 팀 계층 + 팀 소속 사용자 목록 (`displayTree` 기준 표시) | +| 선택 규칙 | **사용자 1명만** 선택 가능 (라디오형 단일 선택) | +| 선택 시각 피드백 | 선택된 사용자 행 강조(배경·테두리) | +| 로딩/오류/빈값 | Skeleton / 오류 문구 / "등록된 조직/사용자가 없습니다." | +| 검색 결과 없음 | 필터 후 매칭 노드·사용자 0건. "조회된 인원이 없습니다." 문구 노출 | + +### 4-2. 조직도 데이터 매핑 + +| UI 표시 | 트리 DTO 필드 (`OgnzUserTreeResponseDto`) | 비고 | +| --------------- | ----------------------------------------- | ----------------------------------------- | +| 조직명 | `ognzNm` | 접기/펼치기 노드 | +| 사용자명 | `users[].userNm` | 클릭 시 선택 | +| 부서(안내 문구) | `ognzNm` (소속 팀 노드) | 선택 시 `selectedPerson.ognzNm` | +| 직급(안내 문구) | `users[].jbgdSeCd` 또는 `jbpsSeCd` | 공통코드 라벨 변환 | +| 저장용 조직 SN | `ognzSn` | 사용자가 속한 **리프/팀 노드**의 `ognzSn` | +| 저장용 사용자 | `users[].userUnqId` | API body `userUuid`로 전달 (§6-3) | + +### 4-3. 조직도 검색 (영역 A-1) + +결재선 관리 `AprvrPickerDialog`와 동일한 **클라이언트 사이드 필터** 패턴을 사용한다. 별도 검색 API는 없으며, `useOgnzUserTree`로 받은 **원본 트리**를 `filterOgnzUserTree`로 가공해 `displayTree`에 반영한다. + +#### 필드 정의 + +| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 | +| -------------- | --------- | ---- | ------ | ----------------- | ----------------------------------------- | +| 성명·부서 검색 | Text | N | (빈값) | — (클라이언트만) | `placeholder="성명·부서 검색"` | +| 조회 | Button | — | — | — | `type="submit"` — 폼 제출 시 필터 적용 | +| 초기화 | Button | — | — | — | `type="button"` — 키워드·트리·펼침 초기화 | + +#### 필터 규칙 (`filterOgnzUserTree`) + +| 대상 | 매칭 조건 | +| ---------------- | ----------------------------------------------------- | +| 조직명 | `ognzNm`에 키워드 포함 (대소문자 무시) | +| 사용자명 | `userNm`에 키워드 포함 (대소문자 무시) | +| 조직 매칭 시 | 해당 노드 **소속 사용자 전원** 유지 | +| 사용자만 매칭 시 | 매칭 사용자만 남기고 상위 조직 노드는 유지(경로 보존) | +| 하위 조직 | 재귀 필터 — 자식·손자 노드 매칭 시 상위도 표시 | +| 키워드 빈값 | 원본 트리 전체 반환 | + +#### 조회 후 트리 펼침 (`collectExpandedOgnzSnIds`) + +- **[조회]** 성공 후 필터 결과 트리의 **모든 노드** `ognzSn`을 `expandedOgnzSnIds`에 넣어 자동 펼침 +- 검색 결과에서 사용자·하위 조직을 바로 확인할 수 있도록 함 + +#### 초기화 동작 + +| 상태 | 초기화 후 값 | +| ------------------- | --------------------------------------- | +| `keyword` | `""` | +| `displayTree` | `useOgnzUserTree` 원본 `data` | +| `expandedOgnzSnIds` | `new Set()` (전체 접힘) | +| `selectedPerson` | **유지** — 검색 초기화와 선택 해제 분리 | + +#### 버튼 + +| 버튼명 | 동작 | +| ------ | --------------------------------------------------------- | +| 조회 | `filterOgnzUserTree(data, keyword)` → `displayTree` 갱신 | +| 초기화 | 키워드·표시 트리·펼침 상태 리셋 (§3-2 `ON [초기화]` 블록) | + +--- + +## 5. 영역 B — 인원 배치 + +### 5-1. 블록 공통 + +| 요소 | 내용 | +| ------------------ | ------------------------------------------------- | +| 블록 제목 | 접수 / 검토 / 승인 / 최종승인 | +| 건수 뱃지 | 해당 `atrzSeComCd`에 배치된 인원 수 | +| 액션 버튼 | **[선택 인원 배치]** — 클릭 시 즉시 `save` (§3-2) | +| 빈 상태 | "배치된 인원이 없습니다." | +| 배치 목록(있을 때) | 인원명·부서·직급 등 | + +### 5-2. 결재구분 ↔ 블록 매핑 + +| 블록 | UI 제목 | `atrzSeComCd` | 비고 | +| ---- | -------- | ------------- | -------------------------------------------------------------- | +| B-1 | 접수 | `ATRZ00` | 결재선 관리 `ATRZ_RECEIVER_CD`와 동일 | +| B-2 | 검토 | `ATRZ01` | 결재선 `ATRZ_REVIEWER_1_CD`. `ATRZ02` 별도 블록 여부 — **TBD** | +| B-3 | 승인 | `ATRZ03` | 결재선 `ATRZ_APPROVER_CD` | +| B-4 | 최종승인 | `ATRZ99` | 결재선 `ATRZ_FINAL_CD` | + +공통코드 마스터 `ATRZSE` / 화면 셀렉트용 `ATRZSECD`와 정합성은 배포 환경 코드표로 최종 확인. + +### 5-3. 배치 목록 항목 (표시) + +| 표시명 | Field (응답 DTO) | 비고 | +| -------- | ---------------- | ---------------------------- | +| 사용자명 | `userUuidNm` | `@CodeName` 미적용 시 TBD | +| 결재구분 | `atrzSeComCdNm` | 블록 제목과 중복 — 생략 가능 | +| 조직 SN | `ognzSn` | 삭제 API path에 사용 | +| 사용자 | `userUuid` | 삭제 API path에 사용 | + +### 5-4. 버튼 + +| 버튼명 | 위치 | 동작 | +| -------------- | ----------- | --------------------------------- | +| 선택 인원 배치 | 각 B-k 헤더 | `save` 후 목록·뱃지 갱신 (§3-2) | +| 제거 | 목록 항목 | `delete` 후 목록·뱃지 갱신 (§3-2) | + +### 5-5. 검증·제약 + +| 규칙 | 내용 | +| ------------- | ------------------------------------------------------------------- | +| 선택 필수 | `[선택 인원 배치]` 시 `selectedPerson` 없으면 버튼 비활성 또는 안내 | +| 단일 선택 | 조직도에서 사용자 1명만 `selectedPerson` 유지 | +| 중복 등록 | 동일 PK `(atrzSeComCd, ognzSn, userUuid)` 재등록 시 서버 오류 | +| 다건 배치 | 동일 블록에 서로 다른 사용자·조직은 **복수 행** 가능 (PK가 다름) | +| 결재구분 변경 | 본 화면 주 흐름은 `save`만 | + +--- + +## 6. API/DTO 명세 + +### 6-1. 공통 + +| 항목 | 내용 | +| ------------- | ------------------------------------------------------------- | +| 인증 | JWT (`Authorization` 헤더) | +| 페이징 | Spring `Pageable`: `page`, `size`, `sort` → `PageResponse` | +| 프론트 엔티티 | `@/entities/user-organization`, `@/entities/rvw-mnpw` | + +### 6-2. 조직·사용자 트리 (영역 A) — `user-ognz` + +| 항목 | 내용 | +| ----------- | ------------------------------- | +| 서비스 | `cms-service` | +| Base prefix | `/cms-service/api/v1/user-ognz` | + +| 기능 | Method | URL | Request DTO | Response DTO | API | +| ---------------- | ------ | ----------- | ----------- | --------------------------- | ---------------------------------- | +| 조직·사용자 트리 | GET | `/treeuser` | — | `OgnzUserTreeResponseDto[]` | `userOgnzService.findOgnzUserTree` | + +**`OgnzUserTreeResponseDto`**: `ognzSn`, `upOgnzSn`, `ognzNm`, `users[]`, `children[]` + +**`OgnzUserTreeUserDto`**: `userUnqId`, `userNm`, `jbpsSeCd`, `jbgdSeCd` + +**React Query**: `useOgnzUserTree` + +### 6-3. 검토인력관리 (영역 B) — `rvw-mnpw` + +| 항목 | 내용 | +| ----------- | ------------------------------------- | +| 서비스 | `as-service` | +| Base prefix | `/as-service/api/v1/rvw-mnpw` | +| ENDPOINT | `ENDPOINT.AS_COMMON_SERVICE.RVW_MNPW` | + +| 기능 | Method | URL | Request DTO / Query | Response DTO | API | +| --------------- | ------ | ------------------------------------ | -------------------------------- | ----------------------------- | ---------------------------- | +| 목록 조회 | GET | `/` | `RvwMnpwMngSearchDto` + Pageable | `Page` | `rvwMnpwMngService.findPage` | +| 상세 조회 | GET | `/{atrzSeComCd}/{ognzSn}/{userUuid}` | Path | `RvwMnpwMngResponseDto` | `rvwMnpwMngService.findById` | +| 등록(즉시 배치) | POST | `/` | `RvwMnpwMngSaveRequestDto` | `RvwMnpwMngResponseDto` (201) | `rvwMnpwMngService.save` | +| 결재구분 변경 | PUT | `/{atrzSeComCd}/{ognzSn}/{userUuid}` | `RvwMnpwMngUpdateRequestDto` | `RvwMnpwMngResponseDto` | `rvwMnpwMngService.update` | +| 삭제 | DELETE | `/{atrzSeComCd}/{ognzSn}/{userUuid}` | Path | `string` | `rvwMnpwMngService.delete` | + +**`RvwMnpwMngSearchDto`**: `atrzSeComCd?`, `ognzSn?`, `userUuid?` + `page`, `size`, `sort` + +**`RvwMnpwMngSaveRequestDto`**: `atrzSeComCd*`, `ognzSn*`, `userUuid*` + +**`RvwMnpwMngUpdateRequestDto`**: `newAtrzSeComCd*` — 본 화면 기본 흐름에서는 미사용 + +**`RvwMnpwMngResponseDto`**: `atrzSeComCd`, `atrzSeComCdNm?`, `ognzSn`, `userUuid`, `userUuidNm?`, `rgtr?`, `regDt?` + +### 6-4. 조직도 ↔ 검토인력 필드 매핑 (저장 시) + +| 조직도 (`OgnzUserTreeUserDto` + 노드) | `RvwMnpwMngSaveRequestDto` | 비고 | +| ------------------------------------- | -------------------------- | --------------------- | +| (블록 k의 `atrzSeComCd`) | `atrzSeComCd` | §5-2 표 | +| 소속 팀 `ognzSn` | `ognzSn` | | +| `userUnqId` | `userUuid` | 문자열(UUID)로 직렬화 | + +--- + +## 7. UI 구현 가이드 + +### 7-1. 기술 스택·원칙 + +- **공통 컴포넌트**: `@common/components` (`Button`, `Badge`, `Card` 또는 섹션 레이아웃) +- **데이터**: React Query — `useOgnzUserTree`, `useRvwMnpwMngPage`, `useSaveRvwMnpwMng`, `useDeleteRvwMnpwMng` +- **상태**: `selectedPerson`(단일), `keyword`, `displayTree`, `expandedOgnzSnIds`, 블록별 배치 목록(서버 데이터 파생) +- **조직도 검색**: `filterOgnzUserTree`, `collectExpandedOgnzSnIds` (`widgets/ognz-user-tree/lib` 또는 페이지 `lib`) +- **저장 UX**: 별도 화면 저장 없음 — **[선택 인원 배치]** = `save` mutation 즉시 호출 +- **조직도 UI**: 결재선 관리 `ognz-user-tree.tsx`를 **widgets**로 승격해 공통화 + +### 7-2. 모듈·파일 배치(제안) + +| 구분 | 경로 | +| -------- | ---------------------------------------------------------------------------------- | +| 라우트 | `apps/as/app/(with-base-layout)/rvw-mnpw-mng/page.tsx` | +| 페이지 | `apps/as/src/pages/rvw-mnpw-mng/` | +| UI | `.../ui/rvw-mnpw-mng-page.tsx`, `rvw-mnpw-assignment-panel.tsx` 등 | +| model | `.../model/selected-ognz-user.ts`, `rvw-mnpw-block-config.ts` (`atrzSeComCd` 상수) | +| lib | `.../lib/group-rvw-mnpw-by-atrz-se.ts` (목록 → B-1~B-4 분배) | +| **위젯** | `apps/as/src/widgets/ognz-user-tree/` — 아래 이전 대상 | +| 엔티티 | `entities/user-organization/`, `entities/rvw-mnpw/` (구현 완료) | + +#### widgets/ognz-user-tree 이전 (결재선 → 공통) + +| 현재 (결재선 관리) | 이전 후 (widgets) | +| ------------------------------------------------------ | ------------------------------------------------------------------------------------ | +| `pages/approval-line-management/ui/ognz-user-tree.tsx` | `widgets/ognz-user-tree/ui/ognz-user-tree.tsx` | +| `pages/.../ui/ognz-user-tree-node.tsx` | `widgets/ognz-user-tree/ui/ognz-user-tree-node.tsx` | +| `pages/.../model/reviewer-option.ts` | `widgets/ognz-user-tree/model/selected-ognz-user.ts` (이름·필드 확장: `ognzSn` 필수) | +| `pages/.../lib/filter-ognz-user-tree.ts` | `widgets/ognz-user-tree/lib/filter-ognz-user-tree.ts` (**필수** — 검색 공통화) | +| `widgets/ognz-user-tree/index.ts` | `OgnzUserTree`, `OgnzUserTreeNode`, 타입 export | + +결재선 `AprvrPickerDialog`는 `@/widgets/ognz-user-tree` import로 교체. 검토인력관리 페이지는 **인라인 조직도**(다이얼로그 없음)로 동일 위젯 사용. + +**`SelectedOgnzUser`** — `ReviewerOption` 확장: + +```typescript +export interface SelectedOgnzUser { + userUuid: string; // OgnzUserTreeUserDto.userUnqId + userNm: string; + ognzSn: number; + ognzNm: string; + jbgdSeCd?: string; + jbpsSeCd?: string; +} +``` + +### 7-3. 영역별 UI 매핑 + +| 영역 | 주요 UI | +| ---- | -------------------------------------------------------------------- | +| A-1 | 검색 폼 (`Input` + 조회/초기화) — `AprvrPickerDialog` 90~103행 패턴 | +| A-2 | `OgnzUserTree(nodes={displayTree})` + 선택 안내 + `useOgnzUserTree` | +| B-k | 제목·Badge(count)·`선택 인원 배치`·배치 목록(또는 empty) | +| 공통 | 페이지 헤더·브레드크럼·2열 레이아웃 (`grid` / `flex`, 모바일은 스택) | + +### 7-4. 다이얼로그·라우팅 + +| 항목 | 내용 | +| ---------- | ----------------------------------- | +| 다이얼로그 | **없음** (조직도 인라인, 즉시 저장) | +| 라우팅 | 메뉴 `검토인력관리` 단일 페이지 | + +### 7-5. 데이터 흐름(구현 요약) + +- **진입**: `useOgnzUserTree` → `displayTree = data` + `useRvwMnpwMngPage({ page: 0, size: N })` → 4패널. +- **검색**: `keyword` 로컬 상태 → **[조회]** 시 `filterOgnzUserTree` + `collectExpandedOgnzSnIds` → `OgnzUserTree`에 `displayTree` 전달. +- **초기화**: `keyword`·`displayTree`·`expandedOgnzSnIds` 리셋 (`selectedPerson` 유지). +- **선택**: 트리 `onSelect` → `selectedPerson` (반드시 `ognzSn` 포함). +- **배치**: `useSaveRvwMnpwMng().mutate({ atrzSeComCd, ognzSn, userUuid })` → `onSuccess`에서 `rvw-mnpw` query invalidate. +- **삭제**: 목록 항목에서 `useDeleteRvwMnpwMng` — UI 확정 후 연결. + +### 7-6. 미확정 / 검증 필요 + +- [ ] `ATRZ02`(검토2) 별도 블록 필요 여부 +- [ ] 권한(역할별 CRUD) diff --git a/apps/as/next-env.d.ts b/apps/as/next-env.d.ts deleted file mode 100644 index 3cd7048ed..000000000 --- a/apps/as/next-env.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// -/// -/// - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/as/package.json b/apps/as/package.json index cf2d547ba..ea9e0545a 100644 --- a/apps/as/package.json +++ b/apps/as/package.json @@ -24,10 +24,11 @@ "@tanstack/react-query-devtools": "^5.75.5", "@tanstack/react-table": "^8.21.2", "axios": "^1.8.1", + "class-variance-authority": "^0.7.1", "dayjs": "^1.11.13", "js-cookie": "^3.0.5", "lucide-react": "^0.474.0", - "next": "15.3.8", + "next": "15.5.18", "react-hook-form": "^7.54.2", "tus-js-client": "^4.3.1", "zod": "^3.24.2" diff --git a/apps/as/src/entities/acntg/api/acntg-service.ts b/apps/as/src/entities/acntg/api/acntg-service.ts index 354ad35f5..fd620009e 100644 --- a/apps/as/src/entities/acntg/api/acntg-service.ts +++ b/apps/as/src/entities/acntg/api/acntg-service.ts @@ -2,15 +2,17 @@ import { client } from "@/shared/api"; import { ENDPOINT } from "@/shared/config"; import type { PageResponse } from "@/shared/model"; -import type { - AcntgSeMngResponseDto, - AcntgSeMngSaveRequestDto, - AcntgSeMngSearchDto, - AcntgSeMngService, - AcntgSeMngTreeDto, +import { + ACNTG_SE_SAVE_MODE, + type AcntgSeMngPutRequestDto, + type AcntgSeMngResponseDto, + type AcntgSeMngSaveRequestDto, + type AcntgSeMngSearchDto, + type AcntgSeMngService, + type AcntgSeMngTreeDto, } from "../model/acntg-interface"; -const BASE = ENDPOINT.AS_COMMON_SERVICE.ACNTG_SE; +const BASE = ENDPOINT.AS_SERVICE.ACNTG_SE; async function findAll(params: AcntgSeMngSearchDto): Promise> { const response = await client.get>(BASE, { params }); @@ -27,13 +29,17 @@ async function findById(acntgSeCd: string): Promise { return response.data; } -async function save(body: AcntgSeMngSaveRequestDto): Promise { - const response = await client.post(BASE, body); +async function saveMulti(body: AcntgSeMngSaveRequestDto[]): Promise { + const response = await client.post(BASE, body); return response.data; } -async function update(acntgSeCd: string, body: AcntgSeMngSaveRequestDto): Promise { - const response = await client.put(`${BASE}/${acntgSeCd}`, body); +async function update(acntgSeCd: string, body: AcntgSeMngPutRequestDto): Promise { + const response = await client.put(`${BASE}/${acntgSeCd}`, { + ...body, + acntgSeCd, + mode: ACNTG_SE_SAVE_MODE.UPDATE, + }); return response.data; } @@ -46,7 +52,7 @@ export const acntgSeMngService = { findAll, findTree, findById, - save, + saveMulti, update, delete: deleteAcntgSeCd, } satisfies AcntgSeMngService; diff --git a/apps/as/src/entities/acntg/api/queries.ts b/apps/as/src/entities/acntg/api/queries.ts index ad1527b71..db00d2317 100644 --- a/apps/as/src/entities/acntg/api/queries.ts +++ b/apps/as/src/entities/acntg/api/queries.ts @@ -2,11 +2,15 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ENDPOINT } from "@/shared/config"; -import type { AcntgSeMngSaveRequestDto, AcntgSeMngSearchDto } from "../model/acntg-interface"; +import type { + AcntgSeMngPutRequestDto, + AcntgSeMngSaveRequestDto, + AcntgSeMngSearchDto, +} from "../model/acntg-interface"; import { acntgSeMngService } from "./acntg-service"; -const acntgSeMngBaseKey = [ENDPOINT.AS_COMMON_SERVICE.ACNTG_SE] as const; +const acntgSeMngBaseKey = [ENDPOINT.AS_SERVICE.ACNTG_SE] as const; export function useAcntgSeMngPage(params: AcntgSeMngSearchDto) { return useQuery({ @@ -31,11 +35,11 @@ export function useAcntgSeMng(acntgSeCd: string | null | undefined) { }); } -export function useSaveAcntgSeMng() { +export function useSaveMultiAcntgSeMng() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (body: AcntgSeMngSaveRequestDto) => acntgSeMngService.save(body), + mutationFn: (body: AcntgSeMngSaveRequestDto[]) => acntgSeMngService.saveMulti(body), onSuccess: () => { queryClient.invalidateQueries({ queryKey: acntgSeMngBaseKey }); }, @@ -46,7 +50,7 @@ export function useUpdateAcntgSeMng() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ acntgSeCd, body }: { acntgSeCd: string; body: AcntgSeMngSaveRequestDto }) => + mutationFn: ({ acntgSeCd, body }: { acntgSeCd: string; body: AcntgSeMngPutRequestDto }) => acntgSeMngService.update(acntgSeCd, body), onSuccess: (_data, { acntgSeCd }) => { queryClient.invalidateQueries({ queryKey: acntgSeMngBaseKey }); diff --git a/apps/as/src/entities/acntg/index.ts b/apps/as/src/entities/acntg/index.ts index 48f14aa1d..bd5083917 100644 --- a/apps/as/src/entities/acntg/index.ts +++ b/apps/as/src/entities/acntg/index.ts @@ -4,7 +4,7 @@ export { useAcntgSeMngPage, useAcntgSeMngTree, useDeleteAcntgSeMng, - useSaveAcntgSeMng, + useSaveMultiAcntgSeMng, useUpdateAcntgSeMng, } from "./api/queries"; export type { @@ -13,5 +13,7 @@ export type { AcntgSeMngSearchDto, AcntgSeMngService, AcntgSeMngTreeDto, + AcntgSeSaveMode, BizCstTreeDto, } from "./model/acntg-interface"; +export { ACNTG_SE_SAVE_MODE } from "./model/acntg-interface"; diff --git a/apps/as/src/entities/acntg/model/acntg-interface.ts b/apps/as/src/entities/acntg/model/acntg-interface.ts index 006472152..c1457208d 100644 --- a/apps/as/src/entities/acntg/model/acntg-interface.ts +++ b/apps/as/src/entities/acntg/model/acntg-interface.ts @@ -1,5 +1,13 @@ import type { PageableParams, PageResponse } from "@/shared/model"; +export const ACNTG_SE_SAVE_MODE = { + CREATE: "C", + UPDATE: "U", + DELETE: "D", +} as const; + +export type AcntgSeSaveMode = (typeof ACNTG_SE_SAVE_MODE)[keyof typeof ACNTG_SE_SAVE_MODE]; + /** 백엔드 AcntgSeMngResponseDto */ export interface AcntgSeMngResponseDto { acntgSeCd: string; @@ -24,6 +32,17 @@ export interface AcntgSeMngSearchDto extends PageableParams { /** 백엔드 AcntgSeMngSaveRequestDto */ export interface AcntgSeMngSaveRequestDto { + acntgSeCd: string; + mode: AcntgSeSaveMode; + acntgSeCdNm: string; + upAcntgSeCd?: string; + cdLvNo: number; + useYn?: string; + expln?: string; +} + +/** PUT /{acntgSeCd} 요청 (mode는 서비스에서 U로 설정) */ +export interface AcntgSeMngPutRequestDto { acntgSeCd: string; acntgSeCdNm: string; upAcntgSeCd?: string; @@ -61,7 +80,7 @@ export interface AcntgSeMngService { findAll(params: AcntgSeMngSearchDto): Promise>; findTree(): Promise; findById(acntgSeCd: string): Promise; - save(body: AcntgSeMngSaveRequestDto): Promise; - update(acntgSeCd: string, body: AcntgSeMngSaveRequestDto): Promise; + saveMulti(body: AcntgSeMngSaveRequestDto[]): Promise; + update(acntgSeCd: string, body: AcntgSeMngPutRequestDto): Promise; delete(acntgSeCd: string): Promise; } diff --git a/apps/as/src/entities/adstdstr/api/adstdstr-service.ts b/apps/as/src/entities/adstdstr/api/adstdstr-service.ts new file mode 100644 index 000000000..b4bb11fae --- /dev/null +++ b/apps/as/src/entities/adstdstr/api/adstdstr-service.ts @@ -0,0 +1,26 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; + +import type { + CmCdAdstDstrComboResponseDto, + CmCdAdstDstrService, +} from "../model/adstdstr-interface"; + +const BASE = ENDPOINT.AS_SERVICE.ADSTDSTR; + +async function findSidoCombo(): Promise { + const response = await client.get(`${BASE}/sido`); + return response.data; +} + +async function findGugunCombo(sido: string): Promise { + const response = await client.get(`${BASE}/gugun`, { + params: { sido }, + }); + return response.data; +} + +export const cmCdAdstDstrService = { + findSidoCombo, + findGugunCombo, +} satisfies CmCdAdstDstrService; diff --git a/apps/as/src/entities/adstdstr/api/queries.ts b/apps/as/src/entities/adstdstr/api/queries.ts new file mode 100644 index 000000000..353e25cfe --- /dev/null +++ b/apps/as/src/entities/adstdstr/api/queries.ts @@ -0,0 +1,22 @@ +import { useQuery } from "@tanstack/react-query"; + +import { ENDPOINT } from "@/shared/config"; + +import { cmCdAdstDstrService } from "./adstdstr-service"; + +const cmCdAdstDstrBaseKey = [ENDPOINT.AS_SERVICE.ADSTDSTR] as const; + +export function useFindSidoCombo() { + return useQuery({ + queryKey: [...cmCdAdstDstrBaseKey, "findSidoCombo"], + queryFn: () => cmCdAdstDstrService.findSidoCombo(), + }); +} + +export function useFindGugunCombo(sido: string) { + return useQuery({ + queryKey: [...cmCdAdstDstrBaseKey, "findGugunCombo", sido], + queryFn: () => cmCdAdstDstrService.findGugunCombo(sido), + enabled: Boolean(sido), + }); +} diff --git a/apps/as/src/entities/adstdstr/index.ts b/apps/as/src/entities/adstdstr/index.ts new file mode 100644 index 000000000..da1dbd132 --- /dev/null +++ b/apps/as/src/entities/adstdstr/index.ts @@ -0,0 +1,6 @@ +export { cmCdAdstDstrService } from "./api/adstdstr-service"; +export { useFindGugunCombo, useFindSidoCombo } from "./api/queries"; +export type { + CmCdAdstDstrComboResponseDto, + CmCdAdstDstrService, +} from "./model/adstdstr-interface"; diff --git a/apps/as/src/entities/adstdstr/model/adstdstr-interface.ts b/apps/as/src/entities/adstdstr/model/adstdstr-interface.ts new file mode 100644 index 000000000..72cb673dc --- /dev/null +++ b/apps/as/src/entities/adstdstr/model/adstdstr-interface.ts @@ -0,0 +1,11 @@ +/** 백엔드 CmCdAdstDstrComboResponseDto */ +export interface CmCdAdstDstrComboResponseDto { + adstDstrCd: string; + adstDstrNm: string; +} + +/** Controller public 메서드명·시그니처와 동일 */ +export interface CmCdAdstDstrService { + findSidoCombo(): Promise; + findGugunCombo(sido: string): Promise; +} diff --git a/apps/as/src/entities/bns-if/api/bns-if-service.ts b/apps/as/src/entities/bns-if/api/bns-if-service.ts new file mode 100644 index 000000000..320a01efc --- /dev/null +++ b/apps/as/src/entities/bns-if/api/bns-if-service.ts @@ -0,0 +1,47 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; + +import { buildBnsSearchDto } from "../lib/build-bns-search-dto"; +import type { + BnsDetailResponseWrapperDto, + BnsIfService, + BnsListResponseWrapperDto, + BnsSearchDto, + EmCodeListResponseDto, +} from "../model/bns-if-interface"; + +const BASE = ENDPOINT.AS_SERVICE.BNS_IF; + +async function getBnsList(params: BnsSearchDto): Promise { + const response = await client.get(`${BASE}/bns`, { + params: buildBnsSearchDto(params), + }); + return response.data; +} + +async function getBnsDetail( + gbn: string, + yr: string, + seqNo: string, +): Promise { + const response = await client.get(`${BASE}/bns/detail`, { + params: { gbn, yr, seqNo }, + }); + return response.data; +} + +async function getEmCodeList( + dpmCd?: string, + lclgvrnmntCd?: string, +): Promise { + const response = await client.get(`${BASE}/emCodeList`, { + params: { dpmCd, lclgvrnmntCd }, + }); + return response.data; +} + +export const bnsIfService = { + getBnsList, + getBnsDetail, + getEmCodeList, +} satisfies BnsIfService; diff --git a/apps/as/src/entities/bns-if/api/queries.ts b/apps/as/src/entities/bns-if/api/queries.ts new file mode 100644 index 000000000..7ab0b46a3 --- /dev/null +++ b/apps/as/src/entities/bns-if/api/queries.ts @@ -0,0 +1,33 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query"; + +import { ENDPOINT } from "@/shared/config"; + +import type { BnsSearchDto } from "../model/bns-if-interface"; + +import { bnsIfService } from "./bns-if-service"; + +const bnsIfBaseKey = [ENDPOINT.AS_SERVICE.BNS_IF] as const; + +export function useBnsList(params: BnsSearchDto, options?: { enabled?: boolean }) { + return useQuery({ + queryKey: [...bnsIfBaseKey, "getBnsList", params], + queryFn: () => bnsIfService.getBnsList(params), + enabled: options?.enabled, + placeholderData: keepPreviousData, + }); +} + +export function useBnsDetail(gbn: string, yr: string, seqNo: string) { + return useQuery({ + queryKey: [...bnsIfBaseKey, "getBnsDetail", gbn, yr, seqNo], + queryFn: () => bnsIfService.getBnsDetail(gbn, yr, seqNo), + enabled: Boolean(gbn && yr && seqNo), + }); +} + +export function useEmCodeList(dpmCd?: string, lclgvrnmntCd?: string) { + return useQuery({ + queryKey: [...bnsIfBaseKey, "getEmCodeList", dpmCd, lclgvrnmntCd], + queryFn: () => bnsIfService.getEmCodeList(dpmCd, lclgvrnmntCd), + }); +} diff --git a/apps/as/src/entities/bns-if/index.ts b/apps/as/src/entities/bns-if/index.ts new file mode 100644 index 000000000..430f4a611 --- /dev/null +++ b/apps/as/src/entities/bns-if/index.ts @@ -0,0 +1,13 @@ +export { bnsIfService } from "./api/bns-if-service"; +export { useBnsDetail, useBnsList, useEmCodeList } from "./api/queries"; +export { buildBnsSearchDto } from "./lib/build-bns-search-dto"; +export type { + BnsDetailResponseDto, + BnsDetailResponseWrapperDto, + BnsIfService, + BnsListResponseDto, + BnsListResponseWrapperDto, + BnsSearchDto, + EmCodeItemDto, + EmCodeListResponseDto, +} from "./model/bns-if-interface"; diff --git a/apps/as/src/entities/bns-if/lib/build-bns-search-dto.ts b/apps/as/src/entities/bns-if/lib/build-bns-search-dto.ts new file mode 100644 index 000000000..8e95b27dd --- /dev/null +++ b/apps/as/src/entities/bns-if/lib/build-bns-search-dto.ts @@ -0,0 +1,20 @@ +import type { BnsSearchDto } from "../model/bns-if-interface"; + +export function buildBnsSearchDto(input: { + cPage: number; + searchStartYear?: string; + searchSido?: string; + searchGugun?: string; +}): BnsSearchDto { + const dto: BnsSearchDto = { cPage: input.cPage }; + + const year = input.searchStartYear?.trim(); + const sido = input.searchSido?.trim(); + const gugun = input.searchGugun?.trim(); + + if (year) dto.searchStartYear = year; + if (sido) dto.searchSido = sido; + if (gugun) dto.searchGugun = gugun; + + return dto; +} diff --git a/apps/as/src/entities/bns-if/model/bns-if-interface.ts b/apps/as/src/entities/bns-if/model/bns-if-interface.ts new file mode 100644 index 000000000..e13ab8500 --- /dev/null +++ b/apps/as/src/entities/bns-if/model/bns-if-interface.ts @@ -0,0 +1,125 @@ +/** 백엔드 BnsSearchDto */ +export interface BnsSearchDto { + searchStartYear?: string; + cPage: number; + searchSido?: string; + searchGugun?: string; +} + +/** 백엔드 BnsListResponseWrapperDto */ +export interface BnsListResponseWrapperDto { + list: BnsListResponseDto[]; + cnt: number; +} + +/** 백엔드 BnsListResponseDto */ +export interface BnsListResponseDto { + cPage: number; + gbn: number; + chk: number; + seqNo: string; + rgnNm: string; + sido: string; + gugun: string; + bzCd: string; + bzNm: string; + yr: string; + strtYr: string; + edYr: string; + totBzamt: string; +} + +/** 백엔드 BnsDetailResponseWrapperDto */ +export interface BnsDetailResponseWrapperDto { + saup: unknown; + detail: BnsDetailResponseDto; +} + +/** 백엔드 BnsDetailResponseDto */ +export interface BnsDetailResponseDto { + cPage: number; + seqNo: string; + confBzCd: string; + chngDgr: string; + yr: string; + sido: string; + gugun: string; + rvbsnNm: string; + lgbsnNm: string; + mdmbsnNm: string; + lclgvrnmntNm: string; + accKdNm: string; + bzKdNm: string; + untBzNm: string; + dtlBzNm: string; + mevBzNm: string; + ddtlBzNm: string; + strtYr: string; + edYr: string; + aidRt: string; + bzStatus: string; + bzCd: string; + bzNm: string; + bzLoc: string; + efflWqltStndrdNm: string; + goalYear: string; + bssProm: string; + uppPlan: string; + techInsp: string; + bssCont: string; + rmk: string; + inflBscFctNm: string; + inflBscFctCd: string; + trtMtd: string; + swrExtns: string; + swrChng: string; + swrRnvt: string; + totFamNum: string; + pumpCnt: string; + rvWbsn: string; + rvText: string; + imprvExpctFlw: string; + imprvExpctWqlt: string; + trtplntPlEfflWqlt: string; + wqltAvg: string; + wqltHgh: string; + wqltLow: string; + stdCost: string; + bzEtc: string; + bscFctCd: string; + bscFctNm: string; + fctCpc: string; + fctCpcAfr: string; + trtDstrArea: string; + trtDstrAreaAfr: string; + swrTrtPopl: string; + swrTrtPoplAfr: string; + adstPopl: string; +} + +/** 백엔드 EmCodeItemDto */ +export interface EmCodeItemDto { + code: string; + data: string; + upCode: string; +} + +/** 백엔드 EmCodeListResponseDto */ +export interface EmCodeListResponseDto { + rvbsnList: EmCodeItemDto[]; + lgbsnList: EmCodeItemDto[]; + mdmbsnList: EmCodeItemDto[]; + accList: EmCodeItemDto[]; + bzList: EmCodeItemDto[]; + untList: EmCodeItemDto[]; + dtlList: EmCodeItemDto[]; + mevList: EmCodeItemDto[]; + ddtlList: EmCodeItemDto[]; +} + +/** Controller public 메서드명·시그니처와 동일 */ +export interface BnsIfService { + getBnsList(params: BnsSearchDto): Promise; + getBnsDetail(gbn: string, yr: string, seqNo: string): Promise; + getEmCodeList(dpmCd?: string, lclgvrnmntCd?: string): Promise; +} diff --git a/apps/as/src/entities/cklst/api/cklst-artcl-service.ts b/apps/as/src/entities/cklst/api/cklst-artcl-service.ts index dda8defd5..9561a659c 100644 --- a/apps/as/src/entities/cklst/api/cklst-artcl-service.ts +++ b/apps/as/src/entities/cklst/api/cklst-artcl-service.ts @@ -11,33 +11,25 @@ import type { CklstArtclUpdateRequest, } from "../model/cklst-artcl-interface"; +const BASE = ENDPOINT.AS_SERVICE.CKLST_ARTCL; + async function findPage(params: CklstArtclSearchParams): Promise> { - const response = await client.get>( - ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL, - { params }, - ); + const response = await client.get>(BASE, { params }); return response.data; } async function findByCklstSn(cklstSn: number): Promise { - const response = await client.get( - `${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL}/by-cklst/${cklstSn}`, - ); + const response = await client.get(`${BASE}/by-cklst/${cklstSn}`); return response.data; } async function findById(cklstArtclSn: number, cklstSn: number): Promise { - const response = await client.get( - `${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL}/${cklstArtclSn}/${cklstSn}`, - ); + const response = await client.get(`${BASE}/${cklstArtclSn}/${cklstSn}`); return response.data; } async function create(body: CklstArtclSaveRequest): Promise { - const response = await client.post( - ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL, - body, - ); + const response = await client.post(BASE, body); return response.data; } @@ -46,17 +38,12 @@ async function update( cklstSn: number, body: CklstArtclUpdateRequest, ): Promise { - const response = await client.put( - `${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL}/${cklstArtclSn}/${cklstSn}`, - body, - ); + const response = await client.put(`${BASE}/${cklstArtclSn}/${cklstSn}`, body); return response.data; } async function remove(cklstArtclSn: number, cklstSn: number): Promise { - const response = await client.delete( - `${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL}/${cklstArtclSn}/${cklstSn}`, - ); + const response = await client.delete(`${BASE}/${cklstArtclSn}/${cklstSn}`); return response.data; } 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 fdae4e8ff..8ad2e0556 100644 --- a/apps/as/src/entities/cklst/api/cklst-mng-service.ts +++ b/apps/as/src/entities/cklst/api/cklst-mng-service.ts @@ -11,40 +11,39 @@ import type { CklstTreeSearchParams, } from "../model/cklst-mng-interface"; +const BASE = ENDPOINT.AS_SERVICE.CKLST; + async function findPage(params: CklstMngSearchParams): Promise> { - const response = await client.get>(ENDPOINT.AS_CHECKLIST_SERVICE.CKLST, { + const response = await client.get>(BASE, { params, }); return response.data; } async function findById(cklstSn: number): Promise { - const response = await client.get(`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST}/${cklstSn}`); + const response = await client.get(`${BASE}/${cklstSn}`); return response.data; } async function findTree(params: CklstTreeSearchParams): Promise { - const response = await client.get(`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST}/list/tree`, { + const response = await client.get(`${BASE}/list/tree`, { params, }); return response.data; } async function create(body: CklstMngSaveRequest): Promise { - const response = await client.post(ENDPOINT.AS_CHECKLIST_SERVICE.CKLST, body); + const response = await client.post(BASE, body); return response.data; } async function update(cklstSn: number, body: CklstMngSaveRequest): Promise { - const response = await client.put( - `${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST}/${cklstSn}`, - body, - ); + const response = await client.put(`${BASE}/${cklstSn}`, body); return response.data; } async function remove(cklstSn: number): Promise { - const response = await client.delete(`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST}/${cklstSn}`); + const response = await client.delete(`${BASE}/${cklstSn}`); return response.data; } diff --git a/apps/as/src/entities/cklst/api/cklst-rvw-service.ts b/apps/as/src/entities/cklst/api/cklst-rvw-service.ts index 0039bfbc1..e184d45aa 100644 --- a/apps/as/src/entities/cklst/api/cklst-rvw-service.ts +++ b/apps/as/src/entities/cklst/api/cklst-rvw-service.ts @@ -11,18 +11,15 @@ import type { CklstRvwUpdateRequest, } from "../model/cklst-rvw-interface"; +const BASE = ENDPOINT.AS_SERVICE.CKLST_RVW; + async function findPage(params: CklstRvwSearchParams): Promise> { - const response = await client.get>( - ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW, - { params }, - ); + const response = await client.get>(BASE, { 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}`, - ); + const response = await client.get(`${BASE}/by-artcl/${cklstSn}/${cklstArtclSn}`); return response.data; } @@ -31,17 +28,12 @@ async function findById( cklstArtclSn: number, cklstSn: number, ): Promise { - const response = await client.get( - `${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW}/${cklstRvwSn}/${cklstArtclSn}/${cklstSn}`, - ); + const response = await client.get(`${BASE}/${cklstRvwSn}/${cklstArtclSn}/${cklstSn}`); return response.data; } async function create(body: CklstRvwSaveRequest): Promise { - const response = await client.post( - ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW, - body, - ); + const response = await client.post(BASE, body); return response.data; } @@ -52,7 +44,7 @@ async function update( body: CklstRvwUpdateRequest, ): Promise { const response = await client.put( - `${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW}/${cklstRvwSn}/${cklstArtclSn}/${cklstSn}`, + `${BASE}/${cklstRvwSn}/${cklstArtclSn}/${cklstSn}`, body, ); return response.data; @@ -64,7 +56,7 @@ async function remove( cklstSn: number, ): Promise { const response = await client.delete( - `${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW}/${cklstRvwSn}/${cklstArtclSn}/${cklstSn}`, + `${BASE}/${cklstRvwSn}/${cklstArtclSn}/${cklstSn}`, ); return response.data; } diff --git a/apps/as/src/entities/cklst/api/queries.ts b/apps/as/src/entities/cklst/api/queries.ts index 33280c41a..afa3e02e3 100644 --- a/apps/as/src/entities/cklst/api/queries.ts +++ b/apps/as/src/entities/cklst/api/queries.ts @@ -18,9 +18,9 @@ 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; +const cklstMngBaseKey = [ENDPOINT.AS_SERVICE.CKLST] as const; +const cklstArtclBaseKey = [ENDPOINT.AS_SERVICE.CKLST_ARTCL] as const; +const cklstRvwBaseKey = [ENDPOINT.AS_SERVICE.CKLST_RVW] as const; export function useCklstMngPage(params: CklstMngSearchParams) { return useQuery({ 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 7e3187ce7..f6c80e630 100644 --- a/apps/as/src/entities/cklst/model/cklst-mng-interface.ts +++ b/apps/as/src/entities/cklst/model/cklst-mng-interface.ts @@ -24,6 +24,8 @@ export interface CklstTreeNode { rvwCn: string; bchckUseYn: "Y" | "N"; sortSeq: number; + // optional: backend may include the taskSeComCd per node + taskSeComCd?: string; } export interface CklstTreeSearchParams { diff --git a/apps/as/src/entities/cnstp/api/cnstp-service.ts b/apps/as/src/entities/cnstp/api/cnstp-service.ts index 1d40eee1a..292ac7069 100644 --- a/apps/as/src/entities/cnstp/api/cnstp-service.ts +++ b/apps/as/src/entities/cnstp/api/cnstp-service.ts @@ -2,15 +2,17 @@ import { client } from "@/shared/api"; import { ENDPOINT } from "@/shared/config"; import type { PageResponse } from "@/shared/model"; -import type { - CnstpSeMngResponseDto, - CnstpSeMngSaveRequestDto, - CnstpSeMngSearchDto, - CnstpSeMngService, - CnstpSeMngTreeDto, +import { + CNSTP_SE_SAVE_MODE, + type CnstpSeMngPutRequestDto, + type CnstpSeMngResponseDto, + type CnstpSeMngSaveRequestDto, + type CnstpSeMngSearchDto, + type CnstpSeMngService, + type CnstpSeMngTreeDto, } from "../model/cnstp-interface"; -const BASE = ENDPOINT.AS_COMMON_SERVICE.CNSTP_SE; +const BASE = ENDPOINT.AS_SERVICE.CNSTP_SE; async function findAll(params: CnstpSeMngSearchDto): Promise> { const response = await client.get>(BASE, { params }); @@ -27,13 +29,17 @@ async function findById(cnstpSeCd: string): Promise { return response.data; } -async function save(body: CnstpSeMngSaveRequestDto): Promise { - const response = await client.post(BASE, body); +async function saveMulti(body: CnstpSeMngSaveRequestDto[]): Promise { + const response = await client.post(BASE, body); return response.data; } -async function update(cnstpSeCd: string, body: CnstpSeMngSaveRequestDto): Promise { - const response = await client.put(`${BASE}/${cnstpSeCd}`, body); +async function update(cnstpSeCd: string, body: CnstpSeMngPutRequestDto): Promise { + const response = await client.put(`${BASE}/${cnstpSeCd}`, { + ...body, + cnstpSeCd, + mode: CNSTP_SE_SAVE_MODE.UPDATE, + }); return response.data; } @@ -46,7 +52,7 @@ export const cnstpSeMngService = { findAll, findTree, findById, - save, + saveMulti, update, delete: deleteCnstpSeCd, } satisfies CnstpSeMngService; diff --git a/apps/as/src/entities/cnstp/api/queries.ts b/apps/as/src/entities/cnstp/api/queries.ts index 703f82679..a419af10c 100644 --- a/apps/as/src/entities/cnstp/api/queries.ts +++ b/apps/as/src/entities/cnstp/api/queries.ts @@ -2,11 +2,15 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ENDPOINT } from "@/shared/config"; -import type { CnstpSeMngSaveRequestDto, CnstpSeMngSearchDto } from "../model/cnstp-interface"; +import type { + CnstpSeMngPutRequestDto, + CnstpSeMngSaveRequestDto, + CnstpSeMngSearchDto, +} from "../model/cnstp-interface"; import { cnstpSeMngService } from "./cnstp-service"; -const cnstpSeMngBaseKey = [ENDPOINT.AS_COMMON_SERVICE.CNSTP_SE] as const; +const cnstpSeMngBaseKey = [ENDPOINT.AS_SERVICE.CNSTP_SE] as const; export function useCnstpSeMngPage(params: CnstpSeMngSearchDto) { return useQuery({ @@ -31,11 +35,11 @@ export function useCnstpSeMng(cnstpSeCd: string | null | undefined) { }); } -export function useSaveCnstpSeMng() { +export function useSaveMultiCnstpSeMng() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (body: CnstpSeMngSaveRequestDto) => cnstpSeMngService.save(body), + mutationFn: (body: CnstpSeMngSaveRequestDto[]) => cnstpSeMngService.saveMulti(body), onSuccess: () => { queryClient.invalidateQueries({ queryKey: cnstpSeMngBaseKey }); }, @@ -46,7 +50,7 @@ export function useUpdateCnstpSeMng() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ cnstpSeCd, body }: { cnstpSeCd: string; body: CnstpSeMngSaveRequestDto }) => + mutationFn: ({ cnstpSeCd, body }: { cnstpSeCd: string; body: CnstpSeMngPutRequestDto }) => cnstpSeMngService.update(cnstpSeCd, body), onSuccess: (_data, { cnstpSeCd }) => { queryClient.invalidateQueries({ queryKey: cnstpSeMngBaseKey }); diff --git a/apps/as/src/entities/cnstp/index.ts b/apps/as/src/entities/cnstp/index.ts index 3f585c1d6..ea76468b3 100644 --- a/apps/as/src/entities/cnstp/index.ts +++ b/apps/as/src/entities/cnstp/index.ts @@ -4,7 +4,7 @@ export { useCnstpSeMngPage, useCnstpSeMngTree, useDeleteCnstpSeMng, - useSaveCnstpSeMng, + useSaveMultiCnstpSeMng, useUpdateCnstpSeMng, } from "./api/queries"; export type { @@ -14,4 +14,6 @@ export type { CnstpSeMngSearchDto, CnstpSeMngService, CnstpSeMngTreeDto, + CnstpSeSaveMode, } from "./model/cnstp-interface"; +export { CNSTP_SE_SAVE_MODE } from "./model/cnstp-interface"; diff --git a/apps/as/src/entities/cnstp/model/cnstp-interface.ts b/apps/as/src/entities/cnstp/model/cnstp-interface.ts index fad6d983d..def16901a 100644 --- a/apps/as/src/entities/cnstp/model/cnstp-interface.ts +++ b/apps/as/src/entities/cnstp/model/cnstp-interface.ts @@ -1,5 +1,13 @@ import type { PageableParams, PageResponse } from "@/shared/model"; +export const CNSTP_SE_SAVE_MODE = { + CREATE: "C", + UPDATE: "U", + DELETE: "D", +} as const; + +export type CnstpSeSaveMode = (typeof CNSTP_SE_SAVE_MODE)[keyof typeof CNSTP_SE_SAVE_MODE]; + /** 백엔드 CnstpSeMngResponseDto */ export interface CnstpSeMngResponseDto { cnstpSeCd: string; @@ -24,6 +32,17 @@ export interface CnstpSeMngSearchDto extends PageableParams { /** 백엔드 CnstpSeMngSaveRequestDto */ export interface CnstpSeMngSaveRequestDto { + cnstpSeCd: string; + mode: CnstpSeSaveMode; + cnstpSeCdNm?: string; + upCnstpCd?: string; + cdLvNo?: number; + useYn?: string; + expln?: string; +} + +/** PUT /{cnstpSeCd} 요청 (mode는 서비스에서 U로 설정) */ +export interface CnstpSeMngPutRequestDto { cnstpSeCd: string; cnstpSeCdNm?: string; upCnstpCd?: string; @@ -61,7 +80,7 @@ export interface CnstpSeMngService { findAll(params: CnstpSeMngSearchDto): Promise>; findTree(): Promise; findById(cnstpSeCd: string): Promise; - save(body: CnstpSeMngSaveRequestDto): Promise; - update(cnstpSeCd: string, body: CnstpSeMngSaveRequestDto): Promise; + saveMulti(body: CnstpSeMngSaveRequestDto[]): Promise; + update(cnstpSeCd: string, body: CnstpSeMngPutRequestDto): Promise; delete(cnstpSeCd: string): Promise; } diff --git a/apps/as/src/entities/elapr/api/elapr-dmnd-service.ts b/apps/as/src/entities/elapr/api/elapr-dmnd-service.ts new file mode 100644 index 000000000..8b2e67fa2 --- /dev/null +++ b/apps/as/src/entities/elapr/api/elapr-dmnd-service.ts @@ -0,0 +1,17 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; + +import type { + ElaprDmndResponse, + ElaprDmndSaveRequest, + ElaprDmndService, +} from "../model/elapr-dmnd-interface"; + +async function create(body: ElaprDmndSaveRequest): Promise { + const response = await client.post(ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, body); + return response.data; +} + +export const ElaprDmndApi: ElaprDmndService = { + create, +}; diff --git a/apps/as/src/entities/elapr/api/elapr-prgrs-service.ts b/apps/as/src/entities/elapr/api/elapr-prgrs-service.ts new file mode 100644 index 000000000..97f925113 --- /dev/null +++ b/apps/as/src/entities/elapr/api/elapr-prgrs-service.ts @@ -0,0 +1,7 @@ +import { client } from "@/shared/api"; + +export async function processElaprPrgrs(elaprPrgrsId: string, atrzPrgrsSttsComCd = "ATRZ_STTS_003"): Promise { + await client.put(`/as-elapr-service/api/v1/elapr-prgrs-stts/${elaprPrgrsId}/process`, { + atrzPrgrsSttsComCd, + }); +} diff --git a/apps/as/src/entities/elapr/index.ts b/apps/as/src/entities/elapr/index.ts index caa639e51..ad35ae13b 100644 --- a/apps/as/src/entities/elapr/index.ts +++ b/apps/as/src/entities/elapr/index.ts @@ -1,4 +1,5 @@ export { elaprAplnService } from "./api/elapr-apln-service"; +export { ElaprDmndApi } from "./api/elapr-dmnd-service"; export { elaprTmpltService } from "./api/elapr-tmplt-service"; export { useCreateElaprTmplt, @@ -24,6 +25,7 @@ export { type ElaprAplnService, type ElaprAplnUpdateRequest, } from "./model/elapr-apln-interface"; +export type { ElaprDmndSaveRequest } from "./model/elapr-dmnd-interface"; export type { ElaprTmplt, ElaprTmpltSaveRequest, diff --git a/apps/as/src/entities/elapr/model/elapr-dmnd-interface.ts b/apps/as/src/entities/elapr/model/elapr-dmnd-interface.ts new file mode 100644 index 000000000..249d98bf9 --- /dev/null +++ b/apps/as/src/entities/elapr/model/elapr-dmnd-interface.ts @@ -0,0 +1,22 @@ +export interface ElaprDmndSaveRequest { + elaprDocNo: string; + elaprTmpltId: string; + dmndTtl: string; + dmndCn: string; +} + +export interface ElaprDmndResponse { + elaprDmndId: string; + elaprDocNo: string; + elaprTmpltId: string; + dmndTtl: string; + dmndCn: string; + atrzSttsComCd: string; + rqstrUuid: string; + regDt: string; + updtDt: string; +} + +export interface ElaprDmndService { + create(body: ElaprDmndSaveRequest): Promise; +} diff --git a/apps/as/src/entities/esntl/api/esntl-service.ts b/apps/as/src/entities/esntl/api/esntl-service.ts index 458940399..e8de77618 100644 --- a/apps/as/src/entities/esntl/api/esntl-service.ts +++ b/apps/as/src/entities/esntl/api/esntl-service.ts @@ -11,50 +11,41 @@ import type { EsntlAtchFileMngTreeSearchParams, } from "../model/esntl-interface"; +const BASE = ENDPOINT.AS_SERVICE.ESNTL_ATCH_FILE; + async function findTree( params: EsntlAtchFileMngTreeSearchParams, ): Promise { - const response = await client.get( - `${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/list/tree`, - { params }, - ); + const response = await client.get(`${BASE}/list/tree`, { + params, + }); return response.data; } async function findPage( params: EsntlAtchFileMngSearchParams, ): Promise> { - const response = await client.get>( - ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE, - { params }, - ); + const response = await client.get>(BASE, { params }); return response.data; } async function findById(esntlAtchFileId: string): Promise { - const response = await client.get( - `${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/${esntlAtchFileId}`, - ); + const response = await client.get(`${BASE}/${esntlAtchFileId}`); return response.data; } async function create(body: EsntlAtchFileMngSaveRequest): Promise { - const response = await client.post(ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE, body); + const response = await client.post(BASE, body); return response.data; } async function update(esntlAtchFileId: string, body: EsntlAtchFileMngSaveRequest): Promise { - const response = await client.put( - `${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/${esntlAtchFileId}`, - body, - ); + const response = await client.put(`${BASE}/${esntlAtchFileId}`, body); return response.data; } async function remove(esntlAtchFileId: string): Promise { - const response = await client.delete( - `${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/${esntlAtchFileId}`, - ); + const response = await client.delete(`${BASE}/${esntlAtchFileId}`); return response.data; } diff --git a/apps/as/src/entities/esntl/api/queries.ts b/apps/as/src/entities/esntl/api/queries.ts index 48e30fc68..dc7afb789 100644 --- a/apps/as/src/entities/esntl/api/queries.ts +++ b/apps/as/src/entities/esntl/api/queries.ts @@ -10,7 +10,7 @@ import type { import { esntlAtchFileMngService } from "./esntl-service"; -const esntlAtchFileMngBaseKey = [ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE] as const; +const esntlAtchFileMngBaseKey = [ENDPOINT.AS_SERVICE.ESNTL_ATCH_FILE] as const; export function useEsntlAtchFileMngTree(params: EsntlAtchFileMngTreeSearchParams) { return useQuery({ 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 434a77918..b47de0782 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,34 +1,62 @@ import { client } from "@/shared/api"; import { ENDPOINT } from "@/shared/config"; +import type { PageResponse } from "@/shared/model"; + import type { + AdstZoneResponseDto, InstlSncnApplySaveRequestDto, - InstlSncnMng, + InstlSncnMngDetResponse, + InstlSncnMngResponseDto, InstlSncnMngSearchParams, InstlSncnMngService, - SpringPage, - InstlSncnMngDetResponse, } from "../model/instl-sncn-interface"; +async function getSidoCombo(): Promise { + const response = await client.get( + `${ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_MST}/sido`, + ); + + return response.data; +} + +async function getSigunguCombo(sidoCd: string): Promise { + const response = await client.get( + `${ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_MST}/sigungu`, + { params: { sidoCd } }, + ); + + return response.data; +} + +const BASE = ENDPOINT.AS_SERVICE.INSTL_SNCN; + export class InstlSncnApiService implements InstlSncnMngService { - public async getPage(params: InstlSncnMngSearchParams): Promise> { - const response = await client.get>(ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN, { + public async getPage( + params: InstlSncnMngSearchParams, + ): Promise> { + const response = await client.get>(BASE, { params, }); return response.data; } public async getById(id: string): Promise { - const response = await client.get(`${ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN}/${id}`); + const response = await client.get(`${BASE}/${id}`); return response.data; } public async saveApply(payload: InstlSncnApplySaveRequestDto): Promise { - const response = await client.post( - `${ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN}/save-apply`, - payload, - ); + const response = await client.post(`${BASE}/save-apply`, payload); return response.data; } + public async techReview(id: string, payload: InstlSncnApplySaveRequestDto): Promise { + await client.post(`${BASE}/${id}/tech-review`, payload); + } } export const InstlSncnApi = new InstlSncnApiService(); + +export const instlSncnRegionService = { + getSidoCombo, + getSigunguCombo, +}; diff --git a/apps/as/src/entities/instl-sncn/api/queries.ts b/apps/as/src/entities/instl-sncn/api/queries.ts new file mode 100644 index 000000000..46f56d231 --- /dev/null +++ b/apps/as/src/entities/instl-sncn/api/queries.ts @@ -0,0 +1,46 @@ +import { useQuery } from "@tanstack/react-query"; + +import { ENDPOINT } from "@/shared/config"; + +import type { InstlSncnMngSearchParams } from "../model/instl-sncn-interface"; + +import { InstlSncnApi, instlSncnRegionService } from "./instl-sncn-service"; + +export const instlSncnKeys = { + all: ["instl-sncn"] as const, + list: (params: InstlSncnMngSearchParams) => [...instlSncnKeys.all, "list", params] as const, + detail: (id: string) => [...instlSncnKeys.all, "detail", id] as const, + sidoCombo: () => [ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_MST, "sido"] as const, + sigunguCombo: (sidoCd: string) => + [ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_MST, "sigungu", sidoCd] as const, +}; + +export function useInstlSncnList(params: InstlSncnMngSearchParams) { + return useQuery({ + queryKey: instlSncnKeys.list(params), + queryFn: () => InstlSncnApi.getPage(params), + }); +} + +export function useInstlSncnDetail(id: string) { + return useQuery({ + queryKey: instlSncnKeys.detail(id), + queryFn: () => InstlSncnApi.getById(id), + enabled: Boolean(id), + }); +} + +export function useInstlSncnSidoCombo() { + return useQuery({ + queryKey: instlSncnKeys.sidoCombo(), + queryFn: () => instlSncnRegionService.getSidoCombo(), + }); +} + +export function useInstlSncnSigunguCombo(sidoCd: string) { + return useQuery({ + queryKey: instlSncnKeys.sigunguCombo(sidoCd), + queryFn: () => instlSncnRegionService.getSigunguCombo(sidoCd), + enabled: Boolean(sidoCd), + }); +} diff --git a/apps/as/src/entities/instl-sncn/index.ts b/apps/as/src/entities/instl-sncn/index.ts index 5da39b459..146c16215 100644 --- a/apps/as/src/entities/instl-sncn/index.ts +++ b/apps/as/src/entities/instl-sncn/index.ts @@ -1,11 +1,28 @@ -export { InstlSncnApi } from "./api/instl-sncn-service"; +export { InstlSncnApi, instlSncnRegionService } from "./api/instl-sncn-service"; export { + instlSncnKeys, + useInstlSncnDetail, + useInstlSncnList, + useInstlSncnSidoCombo, + useInstlSncnSigunguCombo, +} from "./api/queries"; +export { + type AdstZoneResponseDto, + type AtflMngClientResponseDto, + type BizCstTreeDto, + type BizSclTreeDto, + type CklstRvwRsltTreeDto, + type ElaprDocPrgrsClientResponseDto, + type EsntlAtchFileMngTreeResponseDto, INSTL_SNCN_REGISTER_SELECTION_KEY, type InstlSncnApplySaveRequestDto, type InstlSncnBizCstSaveRequestDto, type InstlSncnBzScaleSaveDto, type InstlSncnCklstResultSaveDto, - type InstlSncnMng, + type InstlSncnMngDetResponse, + type InstlSncnMngResponseDto, + type InstlSncnMngSaveRequestDto, type InstlSncnMngSearchParams, - type SpringPage, + type InstlSncnRvwOpnnResponseDto, + type ReviewStepCode, } 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 b2872fea7..433345b86 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,58 +1,100 @@ -export const INSTL_SNCN_REGISTER_SELECTION_KEY = "instlSncnRegisterSelection"; +import type { PageableParams, PageResponse } from "@/shared/model"; -export interface SpringPage { - content: T[]; - totalElements: number; - totalPages: number; - number: number; - size: number; - first: boolean; - last: boolean; - numberOfElements: number; +/** 행정구역(시도·시군구) 콤보 응답 DTO */ +export interface AdstZoneResponseDto { + /** 행정구역 코드 */ + adstCd: string; + /** 행정구역 명 */ + adstNm: string; } -export interface InstlSncnMngSearchParams { +export const INSTL_SNCN_REGISTER_SELECTION_KEY = "instlSncnRegisterSelection"; + +export interface InstlSncnMngSearchParams extends PageableParams { bizNm?: string; taskSeComCd?: string; sidoComCd?: string; sggComCd?: string; - page?: number; - size?: number; - sort?: string; } -export interface InstlSncnMng { - instlSncnDocNo: string; - admdstCd: string; - sidoNm?: string; - sggNm?: string; - chrgrNm?: string; - instNm?: string; - envoSeComCd: string; +/** 설치인가관리 저장 요청 DTO */ +export interface InstlSncnMngSaveRequestDto { + /** 설치인가문서번호 */ + instlSncnDocNo?: string; + /** 행정구역코드 */ + admdstCd?: string; + /** 환경청구분공통코드 */ + envoSeComCd?: string; + /** 인가구분공통코드 */ sncnSeComCd: string; - envoSeCdNm: string; + /** 업무구분공통코드 */ taskSeComCd: string; - taskSeCdNm: string; - bgtBizCd: string; - bgtAplyYr: string; + /** 예산사업코드 */ + bgtBizCd?: string; + /** 예산신청연도 */ + bgtAplyYr?: string; + /** 사업명 */ bizNm: string; + /** 사업시작연도 */ bizBgngYr: string; + /** 사업종료연도 */ bizEndYr: string; + /** 사업내용 */ bizCn: string; - aplcntNm: string; - aplcntMblTelno: string; - aplcntOgdpNm: string; - aplcntEmlAddr: string; + /** 신청자명 */ + aplcntNm?: string; + /** 신청자휴대전화번호 */ + aplcntMblTelno?: string; + /** 신청자소속명 */ + aplcntOgdpNm?: string; + /** 신청자이메일주소 */ + aplcntEmlAddr?: string; + /** 설계자명 */ dsgnrNm: string; + /** 설계자전화번호 */ dsgnrTelno: string; + /** 설계자소속명 */ dsgnrOgdpNm: string; + /** 설계자이메일주소 */ dsgnrEmlAddr: string; - atchFileGroupId: string; - prgrsSttsComCd: string; + /** 첨부파일그룹아이디 */ + atchFileGroupId?: string; + /** 진행상태공통코드 */ + prgrsSttsComCd?: string; + /** 재이용시설구분공통코드 */ + rtlztnFcltSeComCd?: string; + /** 재이용용도구분공통코드 */ + rtlztnUsgSeComCd?: string; +} + +/** 설치인가관리 응답 DTO */ +export interface InstlSncnMngResponseDto extends InstlSncnMngSaveRequestDto { + /** 설치인가문서번호 (필수로 재정의) */ + instlSncnDocNo: string; + /** 환경청구분공통코드명 */ + envoSeCdNm: string; + /** 인가구분공통코드명 */ + sncnSeCdNm: string; + /** 업무구분공통코드명 */ + taskSeCdNm: string; + /** 진행상태공통코드명 */ prgrsSttsCdNm: string; + /** 재이용시설구분공통코드명 */ + rtlztnFcltSeComNm: string; + /** 재이용용도구분공통코드명 */ + rtlztnUsgSeComNm: string; + /** 등록일시 */ regDt: string; - mdfcnDt: string; + /** 등록자 UUID */ + rgtr: string; + /** 등록자명 */ rgtrNm: string; + /** 수정 일시 */ + mdfcnDt: string; + /** 시도 */ + sido: string; + /** 구군 */ + gugun: string; } /** 설치인가 신청 - 사업규모 저장 DTO(배열 원소) */ @@ -88,6 +130,7 @@ export interface InstlSncnCklstResultSaveDto { cklstRvwSn: number; rvwStpComCd: string; selected: "Y" | "N" | null; + picRvwCn?: string | null; } /** 설치인가 신청 - 필수첨부파일 저장 DTO(배열 원소) */ @@ -98,7 +141,7 @@ export interface InstlSncnEsntlAtchFileSaveDto { /** 설치인가 신청 - 통합 저장 요청 DTO */ export interface InstlSncnApplySaveRequestDto { - instlSncnMng: InstlSncnMng; + instlSncnMng: InstlSncnMngSaveRequestDto; bzScaleList: InstlSncnBzScaleSaveDto[]; bizCstList: InstlSncnBizCstSaveRequestDto[]; cklstResultList: InstlSncnCklstResultSaveDto[]; @@ -107,17 +150,190 @@ export interface InstlSncnApplySaveRequestDto { export type ReviewStepCode = "LOCAL" | "ENV" | "KECO"; +/** 공종 트리와 사업규모(수량) 조합 DTO */ +export interface BizSclTreeDto { + /** 들여쓰기 적용된 공종명(시각적) */ + indCnstpSeCdNm: string; + /** 공종 코드 */ + cnstpSeCd: string; + /** 공종 코드명 */ + cnstpSeCdNm: string; + /** 사용 여부(Y/N) */ + useYn: string; + /** 트리 레벨(깊이) */ + treeLevel: number; + /** 트리 경로 표시용 문자열 */ + treePath: string; + /** 기본계획수량 */ + bscPlanQty: number; + /** 예산반영수량 */ + bgtRfltQty: number; + /** 금회설계수량 */ + tstmDesignQty: number; +} + +/** 회계구분 트리와 사업비용 조합 DTO */ +export interface BizCstTreeDto { + /** 들여쓰기 적용된 회계구분명(시각적) */ + indAcntgSeCdNm: string; + /** 회계구분 코드 */ + acntgSeCd: string; + /** 회계구분 코드명 */ + acntgSeCdNm: string; + /** 사용 여부(Y/N) */ + useYn: string; + /** 트리 레벨(깊이) */ + treeLevel: number; + /** 트리 경로 표시용 문자열 */ + treePath: string; + /** 기본계획금액 */ + bscPlanAmt: number; + /** 예산반영금액 */ + bgtRfltAmt: number; + /** 금회설계금액 */ + tstmDesignAmt: number; +} + +/** 점검표 검토결과 트리 DTO */ +export interface CklstRvwRsltTreeDto { + /** 점검표일련번호 */ + cklstSn: number; + /** 점검표명 */ + cklstNm: string; + /** 점검표항목일련번호 */ + cklstArtclSn: number; + /** 점검표항목명 */ + cklstArtclNm: string; + /** 검토일련번호 */ + cklstRvwSn: number; + /** 상위검토일련번호 */ + upCklstRvwSn: number | null; + /** 트리 레벨 */ + treeLevel: number; + /** 검토내용 */ + rvwCn: string; + /** 점검 사용 여부 */ + bchckUseYn: string; + /** 정렬순서 */ + sortSeq: number; + /** 업무구분코드 */ + taskSeComCd: string; + /** 검토단계공통코드 */ + rvwStpComCd: string; + /** 검토결과이상여부 */ + rvwRsltAnmlyYn: string; + /** 검토담당자 UUID */ + rvwPicUuid: string | null; + /** 담당자검토내용 */ + picRvwCn: string | null; + /** 검토일시 */ + rvwDt: string | null; +} + +/** 필수첨부파일관리 트리 조회 응답 DTO */ +export interface EsntlAtchFileMngTreeResponseDto { + /** 필수첨부파일아이디 */ + esntlAtchFileId: string; + /** 문서구분공통코드 */ + docSeComCd: string; + /** 필수첨부파일명 */ + esntlAtchFileNm: string; + /** 필수첨부여부 */ + esntlAtchYn: string; + /** 필수첨부파일비고 */ + esntlAtchFileRmrk: string | null; + /** 첨부파일 코드 */ + atflCd?: string; + /** 첨부파일 일련번호 */ + atflSn?: number; + /** 첨부파일 ID */ + atflId?: string; + /** 원본 파일명 */ + orgnlFileNm?: string; + /** 첨부파일 크기 */ + atflFlieSz?: number; + /** 첨부파일 타입 */ + atflType?: string; + /** 다운로드 횟수 */ + dwnldNocs?: number; + /** 등록일시 */ + regDt?: string; +} + +/** 첨부파일 응답 DTO (attach-service client) */ +export interface AtflMngClientResponseDto { + atflCd: string; + atflSn: number; + atflId: string; + physFileNm: string; + orgnlFileNm: string; + atflFlieSz: number; + atflType: string; + dwnldNocs: number; + delYn: string; + lnkgDmnId: string | null; + lnkgDmnNm: string | null; + regDt: string; +} + +/** 설치인가검토의견 응답 DTO */ +export interface InstlSncnRvwOpnnResponseDto { + /** 설치인가문서번호 */ + instlSncnDocNo: string; + /** 검토의견일련번호 */ + rvwOpnnSn: number; + /** 의견자구분공통코드 */ + opnrSeComCd: string; + /** 의견자구분공통코드명 */ + opnrSeCdNm: string | null; + /** 의견자명 */ + opnrNm: string; + /** 검토결과내용 */ + rvwRsltCn: string; + /** 검토결과이상여부 */ + rvwRsltAnmlyYn: string; + /** 첨부파일그룹아이디 */ + atchFileGroupId: string | null; + /** 첨부파일 상세 정보 */ + atchFileInfo?: AtflMngClientResponseDto; + /** 등록일시 */ + regDt: string; +} + +/** 전자결재 진행상태 (클라이언트 응답용) */ +export interface ElaprDocPrgrsClientResponseDto { + elaprDocNo?: string; + elaprDmndId?: string; + elaprPrgrsId?: string; + atrzSeqNo?: number; + atrzSeComCd?: string; + atrzSeComNm?: string; + aprvrUuid?: string; + aprvrUuidNm?: string; + atrzPrgrsSttsComCd?: string; + atrzPrgrsSttsComNm?: string; + rjctRsn?: string | null; + atrzDt?: string | null; + jbpsSeNm?: string; + jbgdSeNm?: string; + ognzNm?: string; +} + +/** 설치인가관리 상세 응답 DTO */ export interface InstlSncnMngDetResponse { - detDto: InstlSncnMng; - bizSclList: any[]; - bizCstList: any[]; - cklstResultMap?: Record; - esntlFileList: any[]; - etcFileList: any[]; + detDto: InstlSncnMngResponseDto; + bizSclList: BizSclTreeDto[]; + bizCstList: BizCstTreeDto[]; + cklstResultList?: CklstRvwRsltTreeDto[]; + esntlFileList: EsntlAtchFileMngTreeResponseDto[]; + etcFileList: AtflMngClientResponseDto[]; + rvwList?: InstlSncnRvwOpnnResponseDto[]; + elaprDocPrgrsList?: ElaprDocPrgrsClientResponseDto[] | null; } export interface InstlSncnMngService { - getPage: (params: InstlSncnMngSearchParams) => Promise>; + getPage: (params: InstlSncnMngSearchParams) => Promise>; getById: (id: string) => Promise; saveApply: (payload: InstlSncnApplySaveRequestDto) => Promise; + techReview: (id: string, payload: InstlSncnApplySaveRequestDto) => Promise; } diff --git a/apps/as/src/entities/rvw-mnpw/api/queries.ts b/apps/as/src/entities/rvw-mnpw/api/queries.ts new file mode 100644 index 000000000..7b38c6acf --- /dev/null +++ b/apps/as/src/entities/rvw-mnpw/api/queries.ts @@ -0,0 +1,102 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { ENDPOINT } from "@/shared/config"; + +import type { + RvwMnpwMngPageSearchDto, + RvwMnpwMngSaveRequestDto, + RvwMnpwMngSearchDto, + RvwMnpwMngUpdateRequestDto, +} from "../model/rvw-mnpw-interface"; + +import { rvwMnpwMngService } from "./rvw-mnpw-service"; + +const rvwMnpwMngBaseKey = [ENDPOINT.AS_SERVICE.RVW_MNPW] as const; + +export function useRvwMnpwMngPage( + params: RvwMnpwMngPageSearchDto, + options?: { enabled?: boolean }, +) { + return useQuery({ + queryKey: [...rvwMnpwMngBaseKey, "findPage", params], + queryFn: () => rvwMnpwMngService.findPage(params), + enabled: options?.enabled, + }); +} + +export function useRvwMnpwMngLoginUserList( + searchDto: RvwMnpwMngSearchDto, + options?: { enabled?: boolean }, +) { + return useQuery({ + queryKey: [...rvwMnpwMngBaseKey, "findRvwLoginUserList", searchDto], + queryFn: () => rvwMnpwMngService.findRvwLoginUserList(searchDto), + enabled: options?.enabled, + }); +} + +export function useRvwMnpwMng( + atrzSeComCd: string | null | undefined, + ognzSn: number | null | undefined, + userUuid: string | null | undefined, +) { + return useQuery({ + queryKey: [...rvwMnpwMngBaseKey, "findById", atrzSeComCd, ognzSn, userUuid], + queryFn: () => rvwMnpwMngService.findById(atrzSeComCd!, ognzSn!, userUuid!), + enabled: Boolean(atrzSeComCd && ognzSn != null && userUuid), + }); +} + +export function useSaveRvwMnpwMng() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (body: RvwMnpwMngSaveRequestDto) => rvwMnpwMngService.save(body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: rvwMnpwMngBaseKey }); + }, + }); +} + +export function useUpdateRvwMnpwMng() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + atrzSeComCd, + ognzSn, + userUuid, + body, + }: { + atrzSeComCd: string; + ognzSn: number; + userUuid: string; + body: RvwMnpwMngUpdateRequestDto; + }) => rvwMnpwMngService.update(atrzSeComCd, ognzSn, userUuid, body), + onSuccess: (_data, { atrzSeComCd, ognzSn, userUuid }) => { + queryClient.invalidateQueries({ queryKey: rvwMnpwMngBaseKey }); + queryClient.invalidateQueries({ + queryKey: [...rvwMnpwMngBaseKey, "findById", atrzSeComCd, ognzSn, userUuid], + }); + }, + }); +} + +export function useDeleteRvwMnpwMng() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + atrzSeComCd, + ognzSn, + userUuid, + }: { + atrzSeComCd: string; + ognzSn: number; + userUuid: string; + }) => rvwMnpwMngService.delete(atrzSeComCd, ognzSn, userUuid), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: rvwMnpwMngBaseKey }); + }, + }); +} diff --git a/apps/as/src/entities/rvw-mnpw/api/rvw-mnpw-service.ts b/apps/as/src/entities/rvw-mnpw/api/rvw-mnpw-service.ts new file mode 100644 index 000000000..2e99f3fba --- /dev/null +++ b/apps/as/src/entities/rvw-mnpw/api/rvw-mnpw-service.ts @@ -0,0 +1,81 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { PageResponse } from "@/shared/model"; + +import type { + RvwMnpwMngPageSearchDto, + RvwMnpwMngResponseDto, + RvwMnpwMngSaveRequestDto, + RvwMnpwMngSearchDto, + RvwMnpwMngService, + RvwMnpwMngUpdateRequestDto, +} from "../model/rvw-mnpw-interface"; + +const BASE = ENDPOINT.AS_SERVICE.RVW_MNPW; + +async function findPage( + params: RvwMnpwMngPageSearchDto, +): Promise> { + const response = await client.get>(BASE, { params }); + return response.data; +} + +async function findRvwLoginUserList( + searchDto: RvwMnpwMngSearchDto, +): Promise> { + const response = await client.get>(`${BASE}/loginuser/me`, { + params: searchDto, + }); + return response.data; +} + +async function findById( + atrzSeComCd: string, + ognzSn: number, + userUuid: string, +): Promise { + const response = await client.get( + `${BASE}/${atrzSeComCd}/${ognzSn}/${userUuid}`, + ); + return response.data; +} + +async function saveRvwMnpwMng(body: RvwMnpwMngSaveRequestDto): Promise { + const { atrzSeComCd, ognzSn, userUuid } = body; + const response = await client.post( + `${BASE}/${atrzSeComCd}/${ognzSn}/${userUuid}`, + body, + ); + return response.data; +} + +async function updateRvwMnpwMng( + atrzSeComCd: string, + ognzSn: number, + userUuid: string, + body: RvwMnpwMngUpdateRequestDto, +): Promise { + const response = await client.put( + `${BASE}/${atrzSeComCd}/${ognzSn}/${userUuid}`, + body, + ); + return response.data; +} + +async function deleteRvwMnpwMng( + atrzSeComCd: string, + ognzSn: number, + userUuid: string, +): Promise { + const response = await client.delete(`${BASE}/${atrzSeComCd}/${ognzSn}/${userUuid}`); + return response.data; +} + +export const rvwMnpwMngService = { + findPage, + findRvwLoginUserList, + findById, + save: saveRvwMnpwMng, + update: updateRvwMnpwMng, + delete: deleteRvwMnpwMng, +} satisfies RvwMnpwMngService; diff --git a/apps/as/src/entities/rvw-mnpw/index.ts b/apps/as/src/entities/rvw-mnpw/index.ts new file mode 100644 index 000000000..380c50eb7 --- /dev/null +++ b/apps/as/src/entities/rvw-mnpw/index.ts @@ -0,0 +1,17 @@ +export { + useDeleteRvwMnpwMng, + useRvwMnpwMng, + useRvwMnpwMngLoginUserList, + useRvwMnpwMngPage, + useSaveRvwMnpwMng, + useUpdateRvwMnpwMng, +} from "./api/queries"; +export { rvwMnpwMngService } from "./api/rvw-mnpw-service"; +export type { + RvwMnpwMngPageSearchDto, + RvwMnpwMngResponseDto, + RvwMnpwMngSaveRequestDto, + RvwMnpwMngSearchDto, + RvwMnpwMngService, + RvwMnpwMngUpdateRequestDto, +} from "./model/rvw-mnpw-interface"; diff --git a/apps/as/src/entities/rvw-mnpw/model/rvw-mnpw-interface.ts b/apps/as/src/entities/rvw-mnpw/model/rvw-mnpw-interface.ts new file mode 100644 index 000000000..4fee8df2e --- /dev/null +++ b/apps/as/src/entities/rvw-mnpw/model/rvw-mnpw-interface.ts @@ -0,0 +1,54 @@ +import type { PageableParams, PageResponse } from "@/shared/model"; + +/** 백엔드 RvwMnpwMngResponseDto */ +export interface RvwMnpwMngResponseDto { + atrzSeComCd: string; + atrzSeComCdNm?: string; + ognzSn: number; + userUuid: string; + userUuidNm?: string; + ognzNm?: string; + jbpsSeNm?: string; + jbgdSeNm?: string; + rgtr?: string; + regDt?: string; +} + +/** 백엔드 RvwMnpwMngSearchDto */ +export interface RvwMnpwMngSearchDto { + atrzSeComCd?: string; + ognzSn?: number; + userUuid?: string; +} + +/** findPage — RvwMnpwMngSearchDto + Pageable */ +export type RvwMnpwMngPageSearchDto = RvwMnpwMngSearchDto & PageableParams; + +/** 백엔드 RvwMnpwMngSaveRequestDto */ +export interface RvwMnpwMngSaveRequestDto { + atrzSeComCd: string; + ognzSn: number; + userUuid: string; +} + +/** 백엔드 RvwMnpwMngUpdateRequestDto */ +export interface RvwMnpwMngUpdateRequestDto { + newAtrzSeComCd: string; +} + +/** Controller public 메서드명·시그니처와 동일 */ +export interface RvwMnpwMngService { + findPage(params: RvwMnpwMngPageSearchDto): Promise>; + findRvwLoginUserList( + searchDto: RvwMnpwMngSearchDto, + ): Promise>; + findById(atrzSeComCd: string, ognzSn: number, userUuid: string): Promise; + save(body: RvwMnpwMngSaveRequestDto): Promise; + update( + atrzSeComCd: string, + ognzSn: number, + userUuid: string, + body: RvwMnpwMngUpdateRequestDto, + ): Promise; + delete(atrzSeComCd: string, ognzSn: number, userUuid: string): Promise; +} diff --git a/apps/as/src/entities/tus/index.ts b/apps/as/src/entities/tus/index.ts new file mode 100644 index 000000000..5cf2e3fbf --- /dev/null +++ b/apps/as/src/entities/tus/index.ts @@ -0,0 +1 @@ +export { handleTusUpload, type TusUploadOptions } from "./lib/utils"; diff --git a/apps/as/src/entities/tus/lib/utils.ts b/apps/as/src/entities/tus/lib/utils.ts new file mode 100644 index 000000000..6bb40bfd7 --- /dev/null +++ b/apps/as/src/entities/tus/lib/utils.ts @@ -0,0 +1,61 @@ +import { Upload } from "tus-js-client"; + +import { ENDPOINT, PROXY_HOST } from "@/shared/config"; +import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config/env"; +import { getCookie } from "@/shared/lib"; + +interface TusUploadOptions { + metadata?: Record; + handleUploadStart?: (upload: Upload) => void; + handleProgress?: (percentage: number) => void; + handleSuccess?: (upload: Upload, atflCd: string, atflId: string) => void; + handleError?: (error: Error) => void; +} + +function handleTusUpload(file: File, options?: TusUploadOptions) { + let atflCd = ""; + let atflId = ""; + + const metadata = { + filename: file.name, + filetype: file.type, + ...options?.metadata, + }; + + const upload = new Upload(file, { + endpoint: `${PROXY_HOST}${ENDPOINT.ATTACH_SERVICE.TUS_UPLOAD}`, + chunkSize: 10 * 1024 * 1024, + headers: { + [CLAIM_NAME]: getCookie(CLAIM_NAME) ?? "", + [AUTH_USER_ID]: getCookie(AUTH_USER_ID) ?? "", + }, + metadata, + onError(error) { + console.error("TUS upload failed:", error); + options?.handleError?.(error); + }, + onProgress(bytesUploaded, bytesTotal) { + const percentage = (bytesUploaded / bytesTotal) * 100; + options?.handleProgress?.(percentage); + }, + onAfterResponse(_req, res) { + const fileInfo = res.getHeader("X-File-Info"); + if (!fileInfo) return; + + const parsed = JSON.parse(fileInfo) as { atflCd?: string; atflId?: string }; + atflCd = parsed.atflCd ?? ""; + atflId = parsed.atflId ?? ""; + }, + onSuccess() { + options?.handleSuccess?.(upload, atflCd, atflId); + }, + }); + + upload.start(); + options?.handleUploadStart?.(upload); + + return upload; +} + +export { handleTusUpload }; +export type { TusUploadOptions }; 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 index 71dcc5833..47e75d944 100644 --- 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 @@ -4,6 +4,8 @@ export interface OgnzUserTreeUserDto { userNm: string; jbpsSeCd: string; jbgdSeCd: string; + jbpsSeNm: string; + jbgdSeNm: string; } /** OgnzUserTreeResponseDto */ diff --git a/apps/as/src/pages/acntg-code-management/index.ts b/apps/as/src/pages/acntg-code-management/index.ts new file mode 100644 index 000000000..ee4bfa445 --- /dev/null +++ b/apps/as/src/pages/acntg-code-management/index.ts @@ -0,0 +1 @@ +export { AcntgCodeManagementPage } from "./ui/acntg-code-management-page"; diff --git a/apps/as/src/pages/acntg-code-management/lib/acntg-se-grid.ts b/apps/as/src/pages/acntg-code-management/lib/acntg-se-grid.ts new file mode 100644 index 000000000..c0ca7abc7 --- /dev/null +++ b/apps/as/src/pages/acntg-code-management/lib/acntg-se-grid.ts @@ -0,0 +1,245 @@ +import type { AcntgSeMngResponseDto } from "@/entities/acntg"; + +import type { + AcntgDisplaySlot, + AcntgSeBaseEntry, + AcntgSeGridState, +} from "../model/acntg-se-grid-types"; +import type { AcntgSeRow } from "../model/acntg-se-row-types"; +import { buildAcntgDisplayRows } from "../model/build-acntg-display-rows"; + +export function createLoadedRowKey(index: number, acntgSeCd: string): string { + return `loaded-${index}-${acntgSeCd}`; +} + +export function createDraftRowKey(): string { + return `draft-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; +} + +export function createEmptyGridState(): AcntgSeGridState { + return { + baseByKey: {}, + structure: [], + drafts: {}, + patches: {}, + deletedKeys: new Set(), + }; +} + +function toInitialRow(entry: AcntgSeBaseEntry): AcntgSeRow { + return { + ...entry, + isNew: false, + isDirty: false, + isDeleted: false, + }; +} + +export function buildStructureFromBaseRows( + baseByKey: Record, +): AcntgDisplaySlot[] { + const rows = Object.values(baseByKey).map(toInitialRow); + const display = buildAcntgDisplayRows(rows); + const cdToRowKey = new Map(rows.map((row) => [row.acntgSeCd, row.rowKey])); + + return display.map(({ row, depth }) => ({ + rowKey: row.rowKey, + depth, + parentRowKey: row.upAcntgSeCd?.trim() ? (cdToRowKey.get(row.upAcntgSeCd.trim()) ?? null) : null, + })); +} + +export function hydrateGridFromServer(content: AcntgSeMngResponseDto[]): AcntgSeGridState { + const baseByKey: Record = {}; + + content.forEach((item, index) => { + const rowKey = createLoadedRowKey(index, item.acntgSeCd); + baseByKey[rowKey] = { ...item, rowKey }; + }); + + return { + baseByKey, + structure: buildStructureFromBaseRows(baseByKey), + drafts: {}, + patches: {}, + deletedKeys: new Set(), + }; +} + +export function resolveParentAcntgSeCd( + state: AcntgSeGridState, + parentRowKey: string | null, +): string | undefined { + if (!parentRowKey) return undefined; + const parent = mergeAcntgRow(state, parentRowKey); + const cd = parent?.acntgSeCd?.trim(); + return cd || undefined; +} + +export function mergeAcntgRow(state: AcntgSeGridState, rowKey: string): AcntgSeRow | null { + const draft = state.drafts[rowKey]; + if (draft) { + return { + rowKey, + acntgSeCd: draft.acntgSeCd, + acntgSeCdNm: draft.acntgSeCdNm, + expln: draft.expln, + useYn: draft.useYn, + cdLvNo: draft.cdLvNo, + upAcntgSeCd: resolveParentAcntgSeCd(state, draft.parentRowKey), + isNew: true, + isDirty: true, + isDeleted: false, + }; + } + + const base = state.baseByKey[rowKey]; + if (!base) return null; + + const patch = state.patches[rowKey] ?? {}; + const hasPatch = Object.keys(patch).length > 0; + + return { + ...base, + ...patch, + rowKey, + isNew: false, + isDirty: hasPatch, + isDeleted: state.deletedKeys.has(rowKey), + }; +} + +export function mergeAllAcntgRows(state: AcntgSeGridState): AcntgSeRow[] { + const keys = new Set([...Object.keys(state.baseByKey), ...Object.keys(state.drafts)]); + + return [...keys] + .map((rowKey) => mergeAcntgRow(state, rowKey)) + .filter((row): row is AcntgSeRow => row != null); +} + +export function hasAcntgGridPendingChanges(state: AcntgSeGridState): boolean { + return ( + state.deletedKeys.size > 0 || + Object.keys(state.drafts).length > 0 || + Object.keys(state.patches).length > 0 + ); +} + +export function collectDescendantRowKeys( + structure: AcntgDisplaySlot[], + rootRowKey: string, +): Set { + const result = new Set([rootRowKey]); + const queue = [rootRowKey]; + + while (queue.length > 0) { + const parentKey = queue.shift()!; + for (const slot of structure) { + if (slot.parentRowKey === parentKey && !result.has(slot.rowKey)) { + result.add(slot.rowKey); + queue.push(slot.rowKey); + } + } + } + + return result; +} + +export function insertRootSlot( + structure: AcntgDisplaySlot[], + slot: AcntgDisplaySlot, +): AcntgDisplaySlot[] { + const firstRootIndex = structure.findIndex((item) => item.parentRowKey === null); + if (firstRootIndex === -1) return [slot, ...structure]; + return [...structure.slice(0, firstRootIndex), slot, ...structure.slice(firstRootIndex)]; +} + +export function insertChildSlot( + structure: AcntgDisplaySlot[], + parentRowKey: string, + slot: AcntgDisplaySlot, +): AcntgDisplaySlot[] { + const parentIndex = structure.findIndex((item) => item.rowKey === parentRowKey); + if (parentIndex === -1) return [...structure, slot]; + + const insertAt = parentIndex + 1; + return [...structure.slice(0, insertAt), slot, ...structure.slice(insertAt)]; +} + +export function removeSlotsFromStructure( + structure: AcntgDisplaySlot[], + rowKeysToRemove: Set, +): AcntgDisplaySlot[] { + return structure.filter((slot) => !rowKeysToRemove.has(slot.rowKey)); +} + +export function patchAcntgGridRow( + state: AcntgSeGridState, + rowKey: string, + patch: Partial, +): AcntgSeGridState { + const draft = state.drafts[rowKey]; + if (draft) { + return { + ...state, + drafts: { + ...state.drafts, + [rowKey]: { + ...draft, + ...(patch.acntgSeCd !== undefined ? { acntgSeCd: patch.acntgSeCd } : {}), + ...(patch.acntgSeCdNm !== undefined ? { acntgSeCdNm: patch.acntgSeCdNm ?? "" } : {}), + ...(patch.expln !== undefined ? { expln: patch.expln ?? "" } : {}), + ...(patch.useYn !== undefined ? { useYn: patch.useYn } : {}), + }, + }, + }; + } + + const { rowKey: _rk, isNew: _n, isDirty: _d, isDeleted: _del, ...dtoPatch } = patch; + + return { + ...state, + patches: { + ...state.patches, + [rowKey]: { ...state.patches[rowKey], ...dtoPatch }, + }, + }; +} + +export function markAcntgRowsDeleted( + state: AcntgSeGridState, + rootRowKey: string, +): AcntgSeGridState { + const keysToDelete = collectDescendantRowKeys(state.structure, rootRowKey); + const deletedKeys = new Set(state.deletedKeys); + for (const key of keysToDelete) { + deletedKeys.add(key); + } + return { ...state, deletedKeys }; +} + +export function validateDraftParentAcntgSeCd( + state: AcntgSeGridState, + rowKey: string, +): string | null { + const draft = state.drafts[rowKey]; + if (!draft?.parentRowKey) return null; + + const parent = mergeAcntgRow(state, draft.parentRowKey); + if (!parent?.acntgSeCd?.trim()) { + return "하위 항목을 추가하려면 상위 코드를 먼저 입력하세요."; + } + + return null; +} + +export function removeDraftFromGrid(state: AcntgSeGridState, rowKey: string): AcntgSeGridState { + const { [rowKey]: _removed, ...restDrafts } = state.drafts; + const keysToRemove = collectDescendantRowKeys(state.structure, rowKey); + + return { + ...state, + drafts: restDrafts, + structure: removeSlotsFromStructure(state.structure, keysToRemove), + }; +} diff --git a/apps/as/src/pages/acntg-code-management/lib/build-save-multi-payload.ts b/apps/as/src/pages/acntg-code-management/lib/build-save-multi-payload.ts new file mode 100644 index 000000000..f18634b51 --- /dev/null +++ b/apps/as/src/pages/acntg-code-management/lib/build-save-multi-payload.ts @@ -0,0 +1,62 @@ +import { ACNTG_SE_SAVE_MODE, type AcntgSeMngSaveRequestDto } from "@/entities/acntg"; + +import type { AcntgSeRow } from "../model/acntg-se-row-types"; + +import { getVisibleRows } from "./row-state"; + +function compareCdLvNoDesc(a: AcntgSeRow, b: AcntgSeRow): number { + return (b.cdLvNo ?? 1) - (a.cdLvNo ?? 1); +} + +function compareCdLvNoAsc(a: AcntgSeRow, b: AcntgSeRow): number { + return (a.cdLvNo ?? 1) - (b.cdLvNo ?? 1); +} + +export function buildSaveMultiPayload(rows: AcntgSeRow[]): AcntgSeMngSaveRequestDto[] { + const payload: AcntgSeMngSaveRequestDto[] = []; + + const deletedRows = rows.filter((row) => row.isDeleted && !row.isNew).sort(compareCdLvNoDesc); + + for (const row of deletedRows) { + payload.push({ + mode: ACNTG_SE_SAVE_MODE.DELETE, + acntgSeCd: row.acntgSeCd, + acntgSeCdNm: row.acntgSeCdNm?.trim() || "", + upAcntgSeCd: row.upAcntgSeCd, + cdLvNo: row.cdLvNo, + useYn: row.useYn, + expln: row.expln?.trim() || undefined, + }); + } + + const visibleRows = getVisibleRows(rows).sort(compareCdLvNoAsc); + + for (const row of visibleRows) { + if (row.isNew) { + payload.push({ + mode: ACNTG_SE_SAVE_MODE.CREATE, + acntgSeCd: row.acntgSeCd.trim(), + acntgSeCdNm: row.acntgSeCdNm.trim(), + upAcntgSeCd: row.upAcntgSeCd, + cdLvNo: row.cdLvNo, + useYn: row.useYn, + expln: row.expln?.trim() || undefined, + }); + continue; + } + + if (row.isDirty) { + payload.push({ + mode: ACNTG_SE_SAVE_MODE.UPDATE, + acntgSeCd: row.acntgSeCd.trim(), + acntgSeCdNm: row.acntgSeCdNm.trim(), + upAcntgSeCd: row.upAcntgSeCd, + cdLvNo: row.cdLvNo, + useYn: row.useYn, + expln: row.expln?.trim() || undefined, + }); + } + } + + return payload; +} diff --git a/apps/as/src/pages/acntg-code-management/lib/row-state.ts b/apps/as/src/pages/acntg-code-management/lib/row-state.ts new file mode 100644 index 000000000..908df1f70 --- /dev/null +++ b/apps/as/src/pages/acntg-code-management/lib/row-state.ts @@ -0,0 +1,5 @@ +import type { AcntgSeRow } from "../model/acntg-se-row-types"; + +export function getVisibleRows(rows: AcntgSeRow[]): AcntgSeRow[] { + return rows.filter((row) => !row.isDeleted); +} diff --git a/apps/as/src/pages/acntg-code-management/lib/use-acntg-se-rows.ts b/apps/as/src/pages/acntg-code-management/lib/use-acntg-se-rows.ts new file mode 100644 index 000000000..5fd9f85c5 --- /dev/null +++ b/apps/as/src/pages/acntg-code-management/lib/use-acntg-se-rows.ts @@ -0,0 +1,172 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import type { AcntgSeMngResponseDto } from "@/entities/acntg"; + +import type { AcntgSeGridState } from "../model/acntg-se-grid-types"; +import type { AcntgSeRow } from "../model/acntg-se-row-types"; + +import { + createDraftRowKey, + createEmptyGridState, + hasAcntgGridPendingChanges, + hydrateGridFromServer, + insertChildSlot, + insertRootSlot, + markAcntgRowsDeleted, + mergeAcntgRow, + mergeAllAcntgRows, + patchAcntgGridRow, + removeDraftFromGrid, + validateDraftParentAcntgSeCd, +} from "./acntg-se-grid"; +import { buildSaveMultiPayload } from "./build-save-multi-payload"; + +function validateAcntgSeRow(row: AcntgSeRow): string | null { + if (!row.acntgSeCd.trim()) return "코드를 입력하세요."; + if (!row.acntgSeCdNm?.trim()) return "코드명을 입력하세요."; + const parentCd = row.upAcntgSeCd?.trim(); + if (parentCd && row.acntgSeCd.trim() === parentCd) { + return "상위 코드와 동일한 코드는 사용할 수 없습니다."; + } + return null; +} + +interface UseAcntgSeRowsOptions { + serverContent: AcntgSeMngResponseDto[] | undefined; + onAlert: (message: string) => void; + onSave: (payload: ReturnType) => Promise; +} + +export function useAcntgSeRows({ serverContent, onAlert, onSave }: UseAcntgSeRowsOptions) { + const [grid, setGrid] = useState(createEmptyGridState); + const hasPendingRef = useRef(false); + + hasPendingRef.current = hasAcntgGridPendingChanges(grid); + + useEffect(() => { + if (serverContent == null) return; + if (hasPendingRef.current) return; + setGrid(hydrateGridFromServer(serverContent)); + }, [serverContent]); + + const displayRows = useMemo(() => { + return grid.structure + .filter((slot) => !grid.deletedKeys.has(slot.rowKey)) + .map((slot) => { + const row = mergeAcntgRow(grid, slot.rowKey); + return row ? { row, depth: slot.depth } : null; + }) + .filter((item): item is { row: AcntgSeRow; depth: number } => item != null); + }, [grid]); + + const canSave = hasAcntgGridPendingChanges(grid); + + const updateRow = useCallback((rowKey: string, patch: Partial) => { + setGrid((prev) => patchAcntgGridRow(prev, rowKey, patch)); + }, []); + + const addRootRow = useCallback(() => { + const rowKey = createDraftRowKey(); + setGrid((prev) => ({ + ...prev, + drafts: { + ...prev.drafts, + [rowKey]: { + rowKey, + parentRowKey: null, + cdLvNo: 1, + acntgSeCd: "", + acntgSeCdNm: "", + expln: "", + useYn: "Y", + }, + }, + structure: insertRootSlot(prev.structure, { rowKey, depth: 0, parentRowKey: null }), + })); + }, []); + + const addChildRow = useCallback((parent: AcntgSeRow) => { + const rowKey = createDraftRowKey(); + const slot = { + rowKey, + depth: 0, + parentRowKey: parent.rowKey, + }; + + setGrid((prev) => { + const parentSlot = prev.structure.find((item) => item.rowKey === parent.rowKey); + const depth = (parentSlot?.depth ?? 0) + 1; + + return { + ...prev, + drafts: { + ...prev.drafts, + [rowKey]: { + rowKey, + parentRowKey: parent.rowKey, + cdLvNo: (parent.cdLvNo ?? 1) + 1, + acntgSeCd: "", + acntgSeCdNm: "", + expln: "", + useYn: "Y", + }, + }, + structure: insertChildSlot(prev.structure, parent.rowKey, { + ...slot, + depth, + }), + }; + }); + }, []); + + const removeDraft = useCallback((rowKey: string) => { + setGrid((prev) => removeDraftFromGrid(prev, rowKey)); + }, []); + + const markDeleted = useCallback((rowKey: string) => { + setGrid((prev) => markAcntgRowsDeleted(prev, rowKey)); + }, []); + + const resetFromServer = useCallback((content: AcntgSeMngResponseDto[]) => { + setGrid(hydrateGridFromServer(content)); + }, []); + + const save = useCallback(async () => { + const mergedRows = mergeAllAcntgRows(grid); + const payload = buildSaveMultiPayload(mergedRows); + if (payload.length === 0) return false; + + for (const row of mergedRows) { + if (!row.isDeleted && (row.isNew || row.isDirty)) { + const parentCodeError = validateDraftParentAcntgSeCd(grid, row.rowKey); + if (parentCodeError) { + onAlert(parentCodeError); + return false; + } + + const fieldError = validateAcntgSeRow(row); + if (fieldError) { + onAlert(fieldError); + return false; + } + } + } + + await onSave(payload); + return true; + }, [grid, onAlert, onSave]); + + return { + displayRows, + canSave, + updateRow, + addRootRow, + addChildRow, + removeDraft, + markDeleted, + resetFromServer, + save, + }; +} diff --git a/apps/as/src/pages/acntg-code-management/model/acntg-se-grid-types.ts b/apps/as/src/pages/acntg-code-management/model/acntg-se-grid-types.ts new file mode 100644 index 000000000..a115c122d --- /dev/null +++ b/apps/as/src/pages/acntg-code-management/model/acntg-se-grid-types.ts @@ -0,0 +1,34 @@ +import type { AcntgSeMngResponseDto } from "@/entities/acntg"; + +export interface AcntgDisplaySlot { + rowKey: string; + depth: number; + parentRowKey: string | null; +} + +export interface AcntgSeDraft { + rowKey: string; + parentRowKey: string | null; + cdLvNo: number; + acntgSeCd: string; + acntgSeCdNm: string; + expln: string; + useYn: string; +} + +export interface AcntgSeBaseEntry extends AcntgSeMngResponseDto { + rowKey: string; +} + +export interface AcntgSeGridState { + baseByKey: Record; + structure: AcntgDisplaySlot[]; + drafts: Record; + patches: Record>; + deletedKeys: Set; +} + +export interface AcntgSeDisplayItem { + rowKey: string; + depth: number; +} diff --git a/apps/as/src/pages/acntg-code-management/model/acntg-se-row-types.ts b/apps/as/src/pages/acntg-code-management/model/acntg-se-row-types.ts new file mode 100644 index 000000000..7ee397014 --- /dev/null +++ b/apps/as/src/pages/acntg-code-management/model/acntg-se-row-types.ts @@ -0,0 +1,8 @@ +import type { AcntgSeMngResponseDto } from "@/entities/acntg"; + +export interface AcntgSeRow extends AcntgSeMngResponseDto { + rowKey: string; + isNew: boolean; + isDirty: boolean; + isDeleted: boolean; +} diff --git a/apps/as/src/pages/acntg-code-management/model/build-acntg-display-rows.ts b/apps/as/src/pages/acntg-code-management/model/build-acntg-display-rows.ts new file mode 100644 index 000000000..e0e55d02a --- /dev/null +++ b/apps/as/src/pages/acntg-code-management/model/build-acntg-display-rows.ts @@ -0,0 +1,77 @@ +import type { AcntgSeRow } from "./acntg-se-row-types"; + +export interface AcntgSeDisplayRow { + row: AcntgSeRow; + depth: number; +} + +export function formatCdLvNo(lvNo?: number): string { + return `${lvNo ?? 1}단계`; +} + +export function canAddChildRow(): boolean { + return true; +} + +export function formatTreeBranchPrefix(depth: number): string { + if (depth <= 0) return ""; + const vertical = "\u2502 "; + const branch = "\u251C\u2500"; + return vertical.repeat(depth - 1) + branch; +} + +function isRootRow(row: AcntgSeRow): boolean { + return (row.cdLvNo ?? 1) === 1 && !row.upAcntgSeCd?.trim(); +} + +function rowOrderIndex(rows: AcntgSeRow[], row: AcntgSeRow): number { + return rows.findIndex((candidate) => candidate.rowKey === row.rowKey); +} + +function pushDisplayRow( + result: AcntgSeDisplayRow[], + displayedRowKeys: Set, + row: AcntgSeRow, + depth: number, +) { + if (displayedRowKeys.has(row.rowKey)) return; + displayedRowKeys.add(row.rowKey); + result.push({ row, depth }); +} + +export function buildAcntgDisplayRows(rows: AcntgSeRow[]): AcntgSeDisplayRow[] { + const result: AcntgSeDisplayRow[] = []; + const displayedRowKeys = new Set(); + + const roots = rows.filter(isRootRow); + + function appendSubtree(parent: AcntgSeRow, depth: number) { + const children = rows + .filter( + (row) => + row.rowKey !== parent.rowKey && + row.upAcntgSeCd != null && + row.upAcntgSeCd === parent.acntgSeCd && + row.acntgSeCd !== parent.acntgSeCd, + ) + .sort((a, b) => rowOrderIndex(rows, a) - rowOrderIndex(rows, b)); + + for (const child of children) { + pushDisplayRow(result, displayedRowKeys, child, depth); + appendSubtree(child, depth + 1); + } + } + + for (const root of roots) { + pushDisplayRow(result, displayedRowKeys, root, 0); + appendSubtree(root, 1); + } + + const orphans = rows.filter((row) => !displayedRowKeys.has(row.rowKey)); + + for (const orphan of orphans) { + pushDisplayRow(result, displayedRowKeys, orphan, Math.max(0, (orphan.cdLvNo ?? 1) - 1)); + } + + return result; +} diff --git a/apps/as/src/pages/acntg-code-management/ui/acntg-code-management-page.tsx b/apps/as/src/pages/acntg-code-management/ui/acntg-code-management-page.tsx new file mode 100644 index 000000000..d480ced59 --- /dev/null +++ b/apps/as/src/pages/acntg-code-management/ui/acntg-code-management-page.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { useCallback } from "react"; + +import { useConfirm } from "@frontend-opensource/use-react-hooks"; + +import { useAlertStore } from "@/shared/lib"; +import { useAcntgSeMngPage, useSaveMultiAcntgSeMng } from "@/entities/acntg"; +import { + Button, + Skeleton, + Table, + TableBody, + TableHead, + TableHeader, + TableRow, +} from "@common/components"; + +import { useAcntgSeRows } from "../lib/use-acntg-se-rows"; +import type { AcntgSeRow } from "../model/acntg-se-row-types"; + +import { AcntgSeRowComponent } from "./acntg-se-row"; + +function AcntgCodeManagementPage() { + const { confirm } = useConfirm(); + const alert = useAlertStore((state) => state.setMessage); + + const pageQuery = useAcntgSeMngPage({ + page: 0, + size: 500, + sort: "cdLvNo,asc", + }); + + const saveMultiMutation = useSaveMultiAcntgSeMng(); + + const handlePersist = useCallback( + async (payload: Parameters[0]) => { + await saveMultiMutation.mutateAsync(payload); + }, + [saveMultiMutation], + ); + + const { + displayRows, + canSave, + updateRow, + addRootRow, + addChildRow, + removeDraft, + markDeleted, + resetFromServer, + save, + } = useAcntgSeRows({ + serverContent: pageQuery.data?.content, + onAlert: alert, + onSave: handlePersist, + }); + + const handleDelete = async (row: AcntgSeRow) => { + if (!(await confirm("삭제하시겠습니까?"))) return; + + if (row.isNew) { + removeDraft(row.rowKey); + return; + } + + markDeleted(row.rowKey); + }; + + const handleSave = async () => { + if (!(await confirm("저장하시겠습니까?"))) return; + + const saved = await save(); + if (!saved) return; + + alert("저장되었습니다."); + const result = await pageQuery.refetch(); + resetFromServer(result.data?.content ?? []); + }; + + const isSaving = saveMultiMutation.isPending; + + return ( +
+
+
+

회계코드 관리

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

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

+ ) : ( +
+ + + + + + + + + + + + 코드 + 코드명 + 설명 + 사용 여부 + 구분 + 관리 + + + + {displayRows.map(({ row, depth }) => ( + updateRow(row.rowKey, patch)} + onAddChild={() => addChildRow(row)} + onDelete={() => handleDelete(row)} + /> + ))} + +
+
+ )} +
+
+ ); +} + +export { AcntgCodeManagementPage }; diff --git a/apps/as/src/pages/acntg-code-management/ui/acntg-se-row.tsx b/apps/as/src/pages/acntg-code-management/ui/acntg-se-row.tsx new file mode 100644 index 000000000..69b53ec60 --- /dev/null +++ b/apps/as/src/pages/acntg-code-management/ui/acntg-se-row.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { + Button, + Input, + Label, + RadioGroup, + RadioGroupItem, + TableCell, + TableRow, +} from "@common/components"; + +import type { AcntgSeRow } from "../model/acntg-se-row-types"; +import { + canAddChildRow, + formatCdLvNo, + formatTreeBranchPrefix, +} from "../model/build-acntg-display-rows"; + +interface AcntgSeRowProps { + row: AcntgSeRow; + depth: number; + onChange: (patch: Partial) => void; + onAddChild: () => void; + onDelete: () => void; +} + +function AcntgSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: AcntgSeRowProps) { + const levelLabel = formatCdLvNo(row.cdLvNo); + const branchPrefix = formatTreeBranchPrefix(depth); + + return ( + + +
+ {branchPrefix ? ( + {branchPrefix} + ) : null} + onChange({ acntgSeCd: e.target.value })} + placeholder="코드" + maxLength={10} + disabled={!row.isNew} + className="flex-1" + /> +
+
+ + onChange({ acntgSeCdNm: e.target.value })} + placeholder="코드명" + maxLength={100} + /> + + + onChange({ expln: e.target.value })} + placeholder="설명" + maxLength={1000} + /> + + + onChange({ useYn: v })} + className="flex justify-center gap-4" + > +
+ + +
+
+ + +
+
+
+ {levelLabel} + +
+ + +
+
+
+ ); +} + +export { AcntgSeRowComponent }; diff --git a/apps/as/src/pages/approval-line-management/lib/validators.ts b/apps/as/src/pages/approval-line-management/lib/validators.ts index c5fbe0b71..4e301afa9 100644 --- a/apps/as/src/pages/approval-line-management/lib/validators.ts +++ b/apps/as/src/pages/approval-line-management/lib/validators.ts @@ -1,10 +1,7 @@ import type { ElaprAplnRow } from "../model/apln-row-types"; -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"; +const ATRZ_RECEIVER_CD = "ATRZ01"; +const ATRZ_FINAL_CD = "ATRZ99"; export function getVisibleRows(rows: ElaprAplnRow[]): ElaprAplnRow[] { return rows.filter((row) => !row.isDeleted); diff --git a/apps/as/src/pages/approval-line-management/model/columns.tsx b/apps/as/src/pages/approval-line-management/model/columns.tsx index aa00e9afc..a7beb668b 100644 --- a/apps/as/src/pages/approval-line-management/model/columns.tsx +++ b/apps/as/src/pages/approval-line-management/model/columns.tsx @@ -35,10 +35,10 @@ export function createTmpltColumns({ onEdit, onDelete }: CreateTmpltColumnsOptio header: "관리", cell: ({ row }) => (
e.stopPropagation()}> - -
diff --git a/apps/as/src/pages/approval-line-management/model/reviewer-option.ts b/apps/as/src/pages/approval-line-management/model/reviewer-option.ts index 569646744..cb79bbf13 100644 --- a/apps/as/src/pages/approval-line-management/model/reviewer-option.ts +++ b/apps/as/src/pages/approval-line-management/model/reviewer-option.ts @@ -1,4 +1,4 @@ -import type { OgnzUserTreeUserDto } from "@/entities/user-organization"; +import type { RvwMnpwMngResponseDto } from "@/entities/rvw-mnpw"; export interface ReviewerOption { uuid: string; @@ -6,10 +6,10 @@ export interface ReviewerOption { deptNm: string; } -export function toReviewerOption(user: OgnzUserTreeUserDto, ognzNm: string): ReviewerOption { +export function fromRvwMnpwMng(item: RvwMnpwMngResponseDto): ReviewerOption { return { - uuid: user.userUnqId, - name: user.userNm, - deptNm: ognzNm, + uuid: item.userUuid, + name: item.userUuidNm ?? "", + deptNm: item.ognzNm ?? "", }; } diff --git a/apps/as/src/pages/approval-line-management/ui/aprvr-picker-dialog.tsx b/apps/as/src/pages/approval-line-management/ui/aprvr-picker-dialog.tsx index 8b6b7aa1d..38f7c810d 100644 --- a/apps/as/src/pages/approval-line-management/ui/aprvr-picker-dialog.tsx +++ b/apps/as/src/pages/approval-line-management/ui/aprvr-picker-dialog.tsx @@ -1,8 +1,6 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; - -import { useOgnzUserTree } from "@/entities/user-organization"; +import { useRvwMnpwMngPage } from "@/entities/rvw-mnpw"; import { Button, Dialog, @@ -10,110 +8,109 @@ import { DialogFooter, DialogHeader, DialogTitle, - Input, + Skeleton, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, } from "@common/components"; -import { collectExpandedOgnzSnIds, filterOgnzUserTree } from "../lib/filter-ognz-user-tree"; -import type { ReviewerOption } from "../model/reviewer-option"; +import { fromRvwMnpwMng, type ReviewerOption } from "../model/reviewer-option"; -import { OgnzUserTree } from "./ognz-user-tree"; +const PAGE_SIZE = 500; interface AprvrPickerDialogProps { open: boolean; onOpenChange: (open: boolean) => void; + atrzSeComCd: string; onSelect: (reviewer: ReviewerOption) => void; } -function AprvrPickerDialog({ open, onOpenChange, onSelect }: AprvrPickerDialogProps) { - const [keyword, setKeyword] = useState(""); - const [displayTree, setDisplayTree] = useState>([]); - const [expandedOgnzSnIds, setExpandedOgnzSnIds] = useState>(new Set()); - const [selectedUserId, setSelectedUserId] = useState(); +function AprvrPickerDialog({ + open, + onOpenChange, + atrzSeComCd, + onSelect, +}: AprvrPickerDialogProps) { + const { data, isPending, isError } = useRvwMnpwMngPage( + { + atrzSeComCd, + page: 0, + size: PAGE_SIZE, + sort: "userUuidNm,asc", + }, + { enabled: open }, + ); - const { data, isPending, isError } = useOgnzUserTree({ enabled: open }); + const items = data?.content ?? []; - const resetState = useCallback(() => { - setKeyword(""); - setSelectedUserId(undefined); - setExpandedOgnzSnIds(new Set()); - setDisplayTree(data ?? []); - }, [data]); - - 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); + const handleSelect = (item: (typeof items)[number]) => { + onSelect(fromRvwMnpwMng(item)); onOpenChange(false); }; return ( - + 검토인력 선택 -
-
-
- setKeyword(e.target.value)} - /> - - -
-
- -
- -
+
+ {isPending ? ( +
+ + +
+ ) : isError ? ( +

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

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

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

+ ) : ( + + + + + + + + + + + 성명 + 부서 + 직위 + 직급 + 선택 + + + + {items.map((item) => ( + + + {item.userUuidNm || item.userUuid} + + {item.ognzNm || "-"} + {item.jbpsSeNm || "-"} + {item.jbgdSeNm || "-"} + + + + + ))} + +
+ )}
diff --git a/apps/as/src/pages/approval-line-management/ui/elapr-apln-grid.tsx b/apps/as/src/pages/approval-line-management/ui/elapr-apln-grid.tsx index 1dd757ff9..c2393715b 100644 --- a/apps/as/src/pages/approval-line-management/ui/elapr-apln-grid.tsx +++ b/apps/as/src/pages/approval-line-management/ui/elapr-apln-grid.tsx @@ -75,6 +75,7 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) { const isDisabled = !elaprTmpltId; const serverRows = aplnQuery.data?.content ?? []; const canSave = hasUnsavedChanges(rows, serverRows); + const pickingRow = pickingRowKey ? rows.find((r) => r.rowKey === pickingRowKey) : undefined; const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; @@ -151,11 +152,11 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
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 5539d2347..fcb315bdb 100644 --- a/apps/as/src/pages/approval-line-management/ui/sortable-apln-row.tsx +++ b/apps/as/src/pages/approval-line-management/ui/sortable-apln-row.tsx @@ -36,7 +36,7 @@ function SortableAplnRow({ onChange, onPickAprvr, }: SortableAplnRowProps) { - const { data: atrzSeCodes } = useCodeList("ATRZSECD"); + const { data: atrzSeCodes } = useCodeList("ATRZSE"); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: row.rowKey, @@ -50,7 +50,7 @@ function SortableAplnRow({ }; const rowDisabled = isLocked && !isEditing; - const atrzSeLabel = row.atrzSeComNm ?? findCodeLabel(atrzSeCodes, row.atrzSeComCd); + const atrzSeLabel = row.atrzSeComNm || findCodeLabel(atrzSeCodes, row.atrzSeComCd); return ( @@ -72,7 +72,7 @@ function SortableAplnRow({ {isEditing ? ( @@ -111,10 +111,22 @@ function SortableAplnRow({ ) : ( <> - - diff --git a/apps/as/src/pages/checklist-management/model/cklst-artcl-columns.tsx b/apps/as/src/pages/checklist-management/model/cklst-artcl-columns.tsx index c356d9e04..cc519945e 100644 --- a/apps/as/src/pages/checklist-management/model/cklst-artcl-columns.tsx +++ b/apps/as/src/pages/checklist-management/model/cklst-artcl-columns.tsx @@ -41,7 +41,7 @@ export function createCklstArtclColumns({ onEdit, onDelete }: CreateCklstArtclCo -
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 index 0e0ed52a7..fd74d47ce 100644 --- a/apps/as/src/pages/checklist-management/model/cklst-mng-columns.tsx +++ b/apps/as/src/pages/checklist-management/model/cklst-mng-columns.tsx @@ -54,7 +54,7 @@ export function createCklstMngColumns({ onEdit, onDelete }: CreateCklstMngColumn - 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 index 3f4bdd3f5..480ab9508 100644 --- a/apps/as/src/pages/checklist-management/ui/cklst-artcl-list.tsx +++ b/apps/as/src/pages/checklist-management/ui/cklst-artcl-list.tsx @@ -100,7 +100,12 @@ function CklstArtclList({ cklstSn }: CklstArtclListProps) {

점검항목 관리

-
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 index 8ff919256..3efa22ba9 100644 --- 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 @@ -102,7 +102,7 @@ function CklstMngFormDialog({
setValue("docSeComCd", v)} placeholder="문서구분 선택" @@ -112,7 +112,7 @@ function CklstMngFormDialog({
setValue("taskSeComCd", v)} placeholder="업무구분 선택" 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 index a6a3eaa0e..921295ac2 100644 --- a/apps/as/src/pages/checklist-management/ui/cklst-mng-list.tsx +++ b/apps/as/src/pages/checklist-management/ui/cklst-mng-list.tsx @@ -123,7 +123,7 @@ function CklstMngList({ selectedCklstSn, onSelectCklst }: CklstMngListProps) {

점검표 관리

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

공종코드 관리

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

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

+ ) : ( +
+ + + + + + + + + + + + 코드 + 코드명 + 설명 + 사용 여부 + 구분 + 관리 + + + + {displayRows.map(({ row, depth }) => ( + updateRow(row.rowKey, patch)} + onAddChild={() => addChildRow(row)} + onDelete={() => handleDelete(row)} + /> + ))} + +
+
+ )} +
+
+ ); +} + +export { CnstpCodeManagementPage }; diff --git a/apps/as/src/pages/cnstp-code-management/ui/cnstp-se-row.tsx b/apps/as/src/pages/cnstp-code-management/ui/cnstp-se-row.tsx new file mode 100644 index 000000000..090e7d2ba --- /dev/null +++ b/apps/as/src/pages/cnstp-code-management/ui/cnstp-se-row.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { + Button, + Input, + Label, + RadioGroup, + RadioGroupItem, + TableCell, + TableRow, +} from "@common/components"; + +import { + canAddChildRow, + formatCdLvNo, + formatTreeBranchPrefix, +} from "../model/build-cnstp-display-rows"; +import type { CnstpSeRow } from "../model/cnstp-se-row-types"; + +interface CnstpSeRowProps { + row: CnstpSeRow; + depth: number; + onChange: (patch: Partial) => void; + onAddChild: () => void; + onDelete: () => void; +} + +function CnstpSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: CnstpSeRowProps) { + const levelLabel = formatCdLvNo(row.cdLvNo); + const branchPrefix = formatTreeBranchPrefix(depth); + + return ( + + +
+ {branchPrefix ? ( + {branchPrefix} + ) : null} + onChange({ cnstpSeCd: e.target.value })} + placeholder="코드" + maxLength={10} + disabled={!row.isNew} + className="flex-1" + /> +
+
+ + onChange({ cnstpSeCdNm: e.target.value })} + placeholder="코드명" + maxLength={100} + /> + + + onChange({ expln: e.target.value })} + placeholder="설명" + maxLength={1000} + /> + + + onChange({ useYn: v })} + className="flex justify-center gap-4" + > +
+ + +
+
+ + +
+
+
+ {levelLabel} + +
+ + +
+
+
+ ); +} + +export { CnstpSeRowComponent }; diff --git a/apps/as/src/pages/code-management/index.ts b/apps/as/src/pages/code-management/index.ts deleted file mode 100644 index 3b00522e0..000000000 --- a/apps/as/src/pages/code-management/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { CodeManagementPage } from "./ui/code-management-page"; diff --git a/apps/as/src/pages/code-management/model/acntg-se-table-columns.tsx b/apps/as/src/pages/code-management/model/acntg-se-table-columns.tsx deleted file mode 100644 index 2e4cc96f0..000000000 --- a/apps/as/src/pages/code-management/model/acntg-se-table-columns.tsx +++ /dev/null @@ -1,61 +0,0 @@ -"use client"; - -import { createColumnHelper } from "@tanstack/react-table"; - -import type { AcntgSeMngResponseDto } from "@/entities/acntg"; -import { Button } from "@common/components"; - -const columnHelper = createColumnHelper(); - -interface CreateAcntgSeColumnsOptions { - onEdit: (row: AcntgSeMngResponseDto) => void; - onDelete: (row: AcntgSeMngResponseDto) => void; -} - -function formatUseYn(value: string | undefined) { - if (value === "Y") return "사용"; - if (value === "N") return "미사용"; - return "-"; -} - -function formatOptional(value: string | undefined) { - return value?.trim() ? value : "-"; -} - -export function createAcntgSeColumns({ onEdit, onDelete }: CreateAcntgSeColumnsOptions) { - return [ - columnHelper.accessor("acntgSeCd", { - header: "코드", - meta: { align: "center" }, - }), - columnHelper.accessor("acntgSeCdNm", { - header: "코드명", - meta: { align: "left" }, - cell: ({ getValue }) => formatOptional(getValue()), - }), - columnHelper.accessor("expln", { - header: "설명", - meta: { align: "left" }, - cell: ({ getValue }) => formatOptional(getValue()), - }), - columnHelper.accessor("useYn", { - header: "사용여부", - meta: { align: "center" }, - cell: ({ getValue }) => formatUseYn(getValue()), - }), - columnHelper.display({ - id: "actions", - header: "관리", - cell: ({ row }) => ( -
- - -
- ), - }), - ]; -} diff --git a/apps/as/src/pages/code-management/model/cnstp-se-table-columns.tsx b/apps/as/src/pages/code-management/model/cnstp-se-table-columns.tsx deleted file mode 100644 index 45dcadff8..000000000 --- a/apps/as/src/pages/code-management/model/cnstp-se-table-columns.tsx +++ /dev/null @@ -1,61 +0,0 @@ -"use client"; - -import { createColumnHelper } from "@tanstack/react-table"; - -import type { CnstpSeMngResponseDto } from "@/entities/cnstp"; -import { Button } from "@common/components"; - -const columnHelper = createColumnHelper(); - -interface CreateCnstpSeColumnsOptions { - onEdit: (row: CnstpSeMngResponseDto) => void; - onDelete: (row: CnstpSeMngResponseDto) => void; -} - -function formatUseYn(value: string | undefined) { - if (value === "Y") return "사용"; - if (value === "N") return "미사용"; - return "-"; -} - -function formatOptional(value: string | undefined) { - return value?.trim() ? value : "-"; -} - -export function createCnstpSeColumns({ onEdit, onDelete }: CreateCnstpSeColumnsOptions) { - return [ - columnHelper.accessor("cnstpSeCd", { - header: "코드", - meta: { align: "center" }, - }), - columnHelper.accessor("cnstpSeCdNm", { - header: "코드명", - meta: { align: "left" }, - cell: ({ getValue }) => formatOptional(getValue()), - }), - columnHelper.accessor("expln", { - header: "설명", - meta: { align: "left" }, - cell: ({ getValue }) => formatOptional(getValue()), - }), - columnHelper.accessor("useYn", { - header: "사용여부", - meta: { align: "center" }, - cell: ({ getValue }) => formatUseYn(getValue()), - }), - columnHelper.display({ - id: "actions", - header: "관리", - cell: ({ row }) => ( -
- - -
- ), - }), - ]; -} diff --git a/apps/as/src/pages/code-management/ui/acntg-se-form-dialog.tsx b/apps/as/src/pages/code-management/ui/acntg-se-form-dialog.tsx deleted file mode 100644 index 5d6afc414..000000000 --- a/apps/as/src/pages/code-management/ui/acntg-se-form-dialog.tsx +++ /dev/null @@ -1,151 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { useForm } from "react-hook-form"; - -import { useAcntgSeMng } from "@/entities/acntg"; -import { - Button, - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - Input, - Label, - RadioGroup, - RadioGroupItem, - Skeleton, -} from "@common/components"; - -export interface AcntgSeFormValues { - acntgSeCd: string; - acntgSeCdNm: string; - expln: string; - useYn: "Y" | "N"; -} - -interface AcntgSeFormDialogProps { - mode: "create" | "edit"; - open: boolean; - acntgSeCd?: string | null; - onOpenChange: (open: boolean) => void; - isSubmitting?: boolean; - onSubmit: (values: AcntgSeFormValues) => void; -} - -function AcntgSeFormDialog({ - mode, - open, - acntgSeCd, - onOpenChange, - isSubmitting, - onSubmit, -}: AcntgSeFormDialogProps) { - const detailQuery = useAcntgSeMng(mode === "edit" ? acntgSeCd : null); - - const { register, handleSubmit, reset, setValue, watch } = useForm({ - defaultValues: { - acntgSeCd: "", - acntgSeCdNm: "", - expln: "", - useYn: "Y", - }, - }); - - const useYn = watch("useYn"); - - useEffect(() => { - if (!open) return; - - if (mode === "edit") { - if (!detailQuery.data) return; - reset({ - acntgSeCd: detailQuery.data.acntgSeCd, - acntgSeCdNm: detailQuery.data.acntgSeCdNm ?? "", - expln: detailQuery.data.expln ?? "", - useYn: detailQuery.data.useYn === "N" ? "N" : "Y", - }); - return; - } - - reset({ - acntgSeCd: "", - acntgSeCdNm: "", - expln: "", - useYn: "Y", - }); - }, [open, mode, detailQuery.data, reset]); - - const isLoading = mode === "edit" && detailQuery.isPending; - - return ( - - - - {mode === "create" ? "회계코드 등록" : "회계코드 수정"} - - {isLoading ? ( -
- - - -
- ) : ( -
-
- - -
-
- - -
-
- - -
-
- - setValue("useYn", v as "Y" | "N")} - className="flex gap-6" - > -
- - -
-
- - -
-
-
- - - - -
- )} -
-
- ); -} - -export { AcntgSeFormDialog }; diff --git a/apps/as/src/pages/code-management/ui/acntg-se-section.tsx b/apps/as/src/pages/code-management/ui/acntg-se-section.tsx deleted file mode 100644 index f0daf9d4f..000000000 --- a/apps/as/src/pages/code-management/ui/acntg-se-section.tsx +++ /dev/null @@ -1,144 +0,0 @@ -"use client"; - -import { useCallback, useMemo, useState } from "react"; - -import { useConfirm } from "@frontend-opensource/use-react-hooks"; -import { getCoreRowModel, type PaginationState } from "@tanstack/react-table"; - -import { useDataTable } from "@/shared/lib"; -import { - type AcntgSeMngResponseDto, - useAcntgSeMng, - useAcntgSeMngPage, - useDeleteAcntgSeMng, - useSaveAcntgSeMng, - useUpdateAcntgSeMng, -} from "@/entities/acntg"; -import { Button } from "@common/components"; - -import { createAcntgSeColumns } from "../model/acntg-se-table-columns"; - -import { AcntgSeFormDialog, type AcntgSeFormValues } from "./acntg-se-form-dialog"; - -interface AcntgSeSectionProps { - pagination: PaginationState; - onPaginationChange: (pagination: PaginationState) => void; -} - -function AcntgSeSection({ pagination, onPaginationChange }: AcntgSeSectionProps) { - const { confirm } = useConfirm(); - const [dialogMode, setDialogMode] = useState<"create" | "edit" | null>(null); - const [editingCd, setEditingCd] = useState(null); - - const pageQuery = useAcntgSeMngPage({ - page: pagination.pageIndex, - size: pagination.pageSize, - }); - - const detailQuery = useAcntgSeMng(dialogMode === "edit" ? editingCd : null); - const saveMutation = useSaveAcntgSeMng(); - const updateMutation = useUpdateAcntgSeMng(); - const deleteMutation = useDeleteAcntgSeMng(); - - const handleDelete = useCallback( - async (row: AcntgSeMngResponseDto) => { - if (!(await confirm("삭제하시겠습니까?"))) return; - await deleteMutation.mutateAsync(row.acntgSeCd); - }, - [confirm, deleteMutation], - ); - - const columns = useMemo( - () => - createAcntgSeColumns({ - onEdit: (row) => { - setEditingCd(row.acntgSeCd); - setDialogMode("edit"); - }, - onDelete: handleDelete, - }), - [handleDelete], - ); - - const { tableComponent } = useDataTable({ - query: pageQuery, - tableOptions: { - columns, - state: { pagination }, - onPaginationChange: (updater) => { - const next = typeof updater === "function" ? updater(pagination) : updater; - onPaginationChange(next); - }, - getCoreRowModel: getCoreRowModel(), - manualPagination: true, - }, - dataTableOptions: { - viewPagination: true, - }, - }); - - const handleFormSubmit = async (values: AcntgSeFormValues) => { - if (!values.acntgSeCd.trim() || !values.acntgSeCdNm.trim()) return; - - if (dialogMode === "create") { - if (!(await confirm("저장하시겠습니까?"))) return; - await saveMutation.mutateAsync({ - acntgSeCd: values.acntgSeCd.trim(), - acntgSeCdNm: values.acntgSeCdNm.trim(), - expln: values.expln.trim() || undefined, - useYn: values.useYn, - cdLvNo: 1, - }); - } else if (dialogMode === "edit" && detailQuery.data) { - if (!(await confirm("수정하시겠습니까?"))) return; - await updateMutation.mutateAsync({ - acntgSeCd: detailQuery.data.acntgSeCd, - body: { - acntgSeCd: detailQuery.data.acntgSeCd, - acntgSeCdNm: values.acntgSeCdNm.trim(), - expln: values.expln.trim() || undefined, - useYn: values.useYn, - upAcntgSeCd: detailQuery.data.upAcntgSeCd, - cdLvNo: detailQuery.data.cdLvNo, - }, - }); - } - - setDialogMode(null); - setEditingCd(null); - }; - - const handleOpenCreate = () => { - setEditingCd(null); - setDialogMode("create"); - }; - - return ( -
-
-

회계코드 관리

- -
- {tableComponent} - {dialogMode ? ( - { - if (!open) { - setDialogMode(null); - setEditingCd(null); - } - }} - isSubmitting={saveMutation.isPending || updateMutation.isPending} - onSubmit={handleFormSubmit} - /> - ) : null} -
- ); -} - -export { AcntgSeSection }; diff --git a/apps/as/src/pages/code-management/ui/cnstp-se-form-dialog.tsx b/apps/as/src/pages/code-management/ui/cnstp-se-form-dialog.tsx deleted file mode 100644 index 362728cb9..000000000 --- a/apps/as/src/pages/code-management/ui/cnstp-se-form-dialog.tsx +++ /dev/null @@ -1,151 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { useForm } from "react-hook-form"; - -import { useCnstpSeMng } from "@/entities/cnstp"; -import { - Button, - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - Input, - Label, - RadioGroup, - RadioGroupItem, - Skeleton, -} from "@common/components"; - -export interface CnstpSeFormValues { - cnstpSeCd: string; - cnstpSeCdNm: string; - expln: string; - useYn: "Y" | "N"; -} - -interface CnstpSeFormDialogProps { - mode: "create" | "edit"; - open: boolean; - cnstpSeCd?: string | null; - onOpenChange: (open: boolean) => void; - isSubmitting?: boolean; - onSubmit: (values: CnstpSeFormValues) => void; -} - -function CnstpSeFormDialog({ - mode, - open, - cnstpSeCd, - onOpenChange, - isSubmitting, - onSubmit, -}: CnstpSeFormDialogProps) { - const detailQuery = useCnstpSeMng(mode === "edit" ? cnstpSeCd : null); - - const { register, handleSubmit, reset, setValue, watch } = useForm({ - defaultValues: { - cnstpSeCd: "", - cnstpSeCdNm: "", - expln: "", - useYn: "Y", - }, - }); - - const useYn = watch("useYn"); - - useEffect(() => { - if (!open) return; - - if (mode === "edit") { - if (!detailQuery.data) return; - reset({ - cnstpSeCd: detailQuery.data.cnstpSeCd, - cnstpSeCdNm: detailQuery.data.cnstpSeCdNm ?? "", - expln: detailQuery.data.expln ?? "", - useYn: detailQuery.data.useYn === "N" ? "N" : "Y", - }); - return; - } - - reset({ - cnstpSeCd: "", - cnstpSeCdNm: "", - expln: "", - useYn: "Y", - }); - }, [open, mode, detailQuery.data, reset]); - - const isLoading = mode === "edit" && detailQuery.isPending; - - return ( - - - - {mode === "create" ? "공종코드 등록" : "공종코드 수정"} - - {isLoading ? ( -
- - - -
- ) : ( -
-
- - -
-
- - -
-
- - -
-
- - setValue("useYn", v as "Y" | "N")} - className="flex gap-6" - > -
- - -
-
- - -
-
-
- - - - -
- )} -
-
- ); -} - -export { CnstpSeFormDialog }; diff --git a/apps/as/src/pages/code-management/ui/cnstp-se-section.tsx b/apps/as/src/pages/code-management/ui/cnstp-se-section.tsx deleted file mode 100644 index 23b9d792f..000000000 --- a/apps/as/src/pages/code-management/ui/cnstp-se-section.tsx +++ /dev/null @@ -1,147 +0,0 @@ -"use client"; - -import { useCallback, useMemo, useState } from "react"; - -import { useConfirm } from "@frontend-opensource/use-react-hooks"; -import { getCoreRowModel, type PaginationState } from "@tanstack/react-table"; - -import { useDataTable } from "@/shared/lib"; -import { - type CnstpSeMngResponseDto, - useCnstpSeMng, - useCnstpSeMngPage, - useDeleteCnstpSeMng, - useSaveCnstpSeMng, - useUpdateCnstpSeMng, -} from "@/entities/cnstp"; -import { Button } from "@common/components"; - -import { createCnstpSeColumns } from "../model/cnstp-se-table-columns"; - -import { CnstpSeFormDialog, type CnstpSeFormValues } from "./cnstp-se-form-dialog"; - -const DEFAULT_PAGE_INDEX = 0; -const DEFAULT_PAGE_SIZE = 10; - -interface CnstpSeSectionProps { - pagination: PaginationState; - onPaginationChange: (pagination: PaginationState) => void; -} - -function CnstpSeSection({ pagination, onPaginationChange }: CnstpSeSectionProps) { - const { confirm } = useConfirm(); - const [dialogMode, setDialogMode] = useState<"create" | "edit" | null>(null); - const [editingCd, setEditingCd] = useState(null); - - const pageQuery = useCnstpSeMngPage({ - page: pagination.pageIndex, - size: pagination.pageSize, - }); - - const detailQuery = useCnstpSeMng(dialogMode === "edit" ? editingCd : null); - const saveMutation = useSaveCnstpSeMng(); - const updateMutation = useUpdateCnstpSeMng(); - const deleteMutation = useDeleteCnstpSeMng(); - - const handleDelete = useCallback( - async (row: CnstpSeMngResponseDto) => { - if (!(await confirm("삭제하시겠습니까?"))) return; - await deleteMutation.mutateAsync(row.cnstpSeCd); - }, - [confirm, deleteMutation], - ); - - const columns = useMemo( - () => - createCnstpSeColumns({ - onEdit: (row) => { - setEditingCd(row.cnstpSeCd); - setDialogMode("edit"); - }, - onDelete: handleDelete, - }), - [handleDelete], - ); - - const { tableComponent } = useDataTable({ - query: pageQuery, - tableOptions: { - columns, - state: { pagination }, - onPaginationChange: (updater) => { - const next = typeof updater === "function" ? updater(pagination) : updater; - onPaginationChange(next); - }, - getCoreRowModel: getCoreRowModel(), - manualPagination: true, - }, - dataTableOptions: { - viewPagination: true, - }, - }); - - const handleFormSubmit = async (values: CnstpSeFormValues) => { - if (!values.cnstpSeCd.trim()) return; - - if (dialogMode === "create") { - if (!(await confirm("저장하시겠습니까?"))) return; - await saveMutation.mutateAsync({ - cnstpSeCd: values.cnstpSeCd.trim(), - cnstpSeCdNm: values.cnstpSeCdNm.trim() || undefined, - expln: values.expln.trim() || undefined, - useYn: values.useYn, - cdLvNo: 1, - }); - } else if (dialogMode === "edit" && detailQuery.data) { - if (!(await confirm("수정하시겠습니까?"))) return; - await updateMutation.mutateAsync({ - cnstpSeCd: detailQuery.data.cnstpSeCd, - body: { - cnstpSeCd: detailQuery.data.cnstpSeCd, - cnstpSeCdNm: values.cnstpSeCdNm.trim() || undefined, - expln: values.expln.trim() || undefined, - useYn: values.useYn, - upCnstpCd: detailQuery.data.upCnstpCd, - cdLvNo: detailQuery.data.cdLvNo, - }, - }); - } - - setDialogMode(null); - setEditingCd(null); - }; - - const handleOpenCreate = () => { - setEditingCd(null); - setDialogMode("create"); - }; - - return ( -
-
-

공종코드 관리

- -
- {tableComponent} - {dialogMode ? ( - { - if (!open) { - setDialogMode(null); - setEditingCd(null); - } - }} - isSubmitting={saveMutation.isPending || updateMutation.isPending} - onSubmit={handleFormSubmit} - /> - ) : null} -
- ); -} - -export { CnstpSeSection, DEFAULT_PAGE_INDEX, DEFAULT_PAGE_SIZE }; diff --git a/apps/as/src/pages/code-management/ui/code-management-page.tsx b/apps/as/src/pages/code-management/ui/code-management-page.tsx deleted file mode 100644 index 95e7dcd9f..000000000 --- a/apps/as/src/pages/code-management/ui/code-management-page.tsx +++ /dev/null @@ -1,30 +0,0 @@ -"use client"; - -import { useState } from "react"; - -import type { PaginationState } from "@tanstack/react-table"; - -import { AcntgSeSection } from "./acntg-se-section"; -import { CnstpSeSection, DEFAULT_PAGE_INDEX, DEFAULT_PAGE_SIZE } from "./cnstp-se-section"; - -function CodeManagementPage() { - const [cnstpPagination, setCnstpPagination] = useState({ - pageIndex: DEFAULT_PAGE_INDEX, - pageSize: DEFAULT_PAGE_SIZE, - }); - const [acntgPagination, setAcntgPagination] = useState({ - pageIndex: DEFAULT_PAGE_INDEX, - pageSize: DEFAULT_PAGE_SIZE, - }); - - return ( -
-
- - -
-
- ); -} - -export { CodeManagementPage }; diff --git a/apps/as/src/pages/esntl-atch-file-mng/model/esntl-atch-file-columns.tsx b/apps/as/src/pages/esntl-atch-file-mng/model/esntl-atch-file-columns.tsx index ee3b04dda..ad5f32c62 100644 --- a/apps/as/src/pages/esntl-atch-file-mng/model/esntl-atch-file-columns.tsx +++ b/apps/as/src/pages/esntl-atch-file-mng/model/esntl-atch-file-columns.tsx @@ -68,10 +68,10 @@ export function createEsntlAtchFileColumns({ header: "관리", cell: ({ row }) => (
e.stopPropagation()}> - -
diff --git a/apps/as/src/pages/instl-sncn-old/index.ts b/apps/as/src/pages/instl-sncn-old/index.ts new file mode 100644 index 000000000..570133ba4 --- /dev/null +++ b/apps/as/src/pages/instl-sncn-old/index.ts @@ -0,0 +1,4 @@ +export { CnfmListPage } from "./ui/cnfm-list-page"; +export { CnfmViewPage } from "./ui/cnfm-view-page"; +export { CnfmRegistPage } from "./ui/cnfm-regist-page"; +export { CnfmModifyPage } from "./ui/cnfm-modify-page"; diff --git a/apps/as/src/pages/instl-sncn/ui/cnfm-list-page.tsx b/apps/as/src/pages/instl-sncn-old/ui/cnfm-list-page.tsx similarity index 90% rename from apps/as/src/pages/instl-sncn/ui/cnfm-list-page.tsx rename to apps/as/src/pages/instl-sncn-old/ui/cnfm-list-page.tsx index a5ff158dd..8597baf69 100644 --- a/apps/as/src/pages/instl-sncn/ui/cnfm-list-page.tsx +++ b/apps/as/src/pages/instl-sncn-old/ui/cnfm-list-page.tsx @@ -71,15 +71,15 @@ function CnfmListPage() { const [pageIndex, setPageIndex] = useState(0); const [pageSize, setPageSize] = useState(10); const [taskDialogOpen, setTaskDialogOpen] = useState(false); - const [selectedTaskSeComCd, setSelectedTaskSeComCd] = useState(""); + 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); + if (selectedTaskSeComCd.length) return; + if (fnrsCodeList.length === 0) return; + // setSelectedTaskSeComCd([fnrsCodeList[0].lwpoCd]); }, [taskDialogOpen, selectedTaskSeComCd, fnrsCodeList]); const query = useQuery({ @@ -108,20 +108,25 @@ function CnfmListPage() { }; const handleOpenTaskDialog = () => { - setSelectedTaskSeComCd((prev) => prev || fnrsCodeList[0]?.lwpoCd || ""); + //setSelectedTaskSeComCd((prev) => (prev && prev.length ? prev : [fnrsCodeList[0]?.lwpoCd || ""])); setTaskDialogOpen(true); }; const handleConfirmTaskDialog = () => { - const selected = fnrsCodeList.find((item) => item.lwpoCd === selectedTaskSeComCd); - if (!selected) return; + if (!selectedTaskSeComCd || selectedTaskSeComCd.length === 0) { + alert("하나 이상의 업무구분을 선택하세요."); + return; + } + // prepare arrays of selected codes and names + const selectedCodes = selectedTaskSeComCd; + const selectedNames = selectedTaskSeComCd.map((code) => fnrsCodeList.find((item) => item.lwpoCd === code)?.lwpoCdNm || ""); sessionStorage.setItem( INSTL_SNCN_REGISTER_SELECTION_KEY, JSON.stringify({ - taskSeComCd: selected.lwpoCd, - taskSeComCdNm: selected.lwpoCdNm, - }), + taskSeComCdList: selectedCodes, + taskSeComCdNmList: selectedNames + }) ); setTaskDialogOpen(false); router.push("/instl-sncn/register"); @@ -226,7 +231,7 @@ function CnfmListPage() { 담당자 해당기관 진행상황 - 시행일 + 신청일 @@ -248,12 +253,12 @@ function CnfmListPage() { className="cnfm-row-clickable" onClick={() => router.push(`/instl-sncn/${row.instlSncnDocNo}`)} > - {row.sidoNm || "-"} - {row.sggNm || "-"} + {row.sido || "-"} + {row.gugun|| "-"} {row.bizNm || "-"} {row.taskSeCdNm || row.taskSeComCd || "-"} - {row.chrgrNm || row.aplcntNm || "-"} - {row.instNm || row.aplcntOgdpNm || "-"} + {row.aplcntNm || "-"} + {row.envoSeCdNm || "-"}
@@ -367,11 +372,15 @@ function CnfmListPage() { {fnrsCodeList.map((item) => ( @@ -387,11 +396,11 @@ function CnfmListPage() { > 취소 - diff --git a/apps/as/src/pages/instl-sncn/ui/cnfm-modify-page.tsx b/apps/as/src/pages/instl-sncn-old/ui/cnfm-modify-page.tsx similarity index 100% rename from apps/as/src/pages/instl-sncn/ui/cnfm-modify-page.tsx rename to apps/as/src/pages/instl-sncn-old/ui/cnfm-modify-page.tsx diff --git a/apps/as/src/pages/instl-sncn/ui/cnfm-page.css b/apps/as/src/pages/instl-sncn-old/ui/cnfm-page.css similarity index 100% rename from apps/as/src/pages/instl-sncn/ui/cnfm-page.css rename to apps/as/src/pages/instl-sncn-old/ui/cnfm-page.css diff --git a/apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx b/apps/as/src/pages/instl-sncn-old/ui/cnfm-regist-page.tsx similarity index 80% rename from apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx rename to apps/as/src/pages/instl-sncn-old/ui/cnfm-regist-page.tsx index 7f815ea84..0f178f101 100644 --- a/apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx +++ b/apps/as/src/pages/instl-sncn-old/ui/cnfm-regist-page.tsx @@ -12,10 +12,12 @@ import { AUTH_USER_ID, CLAIM_NAME, ENDPOINT, PROXY_HOST } from "@/shared/config" import { getCookie, setCookie, useAlertStore, useUserStore } from "@/shared/lib"; import { deleteAttachment } from "@/shared/lib/attach"; import { formatFileSize } from "@/shared/lib/file-utils"; -import { type AcntgSeMngTreeDto, useAcntgSeMngTree } from "@/entities/acntg"; +import type { AcntgSeMngTreeDto } from "@/entities/acntg"; +import { acntgSeMngService } from "@/entities/acntg"; import type { CklstTreeNode } from "@/entities/cklst"; import { cklstMngService } from "@/entities/cklst"; -import { type CnstpSeMngTreeDto, useCnstpSeMngTree } from "@/entities/cnstp"; +import type { CnstpSeMngTreeDto } from "@/entities/cnstp"; +import { cnstpSeMngService } from "@/entities/cnstp"; import { esntlAtchFileMngService, type EsntlAtchFileMngTreeResponseDto } from "@/entities/esntl"; import { INSTL_SNCN_REGISTER_SELECTION_KEY, @@ -23,7 +25,8 @@ import { type InstlSncnApplySaveRequestDto, type InstlSncnBizCstSaveRequestDto, type InstlSncnBzScaleSaveDto, - type InstlSncnMng, + type InstlSncnMngResponseDto, + InstlSncnMngSaveRequestDto, } from "@/entities/instl-sncn"; import { EtcAttachments, NumCell, PrivacyConsent, ReadCell } from "@/widgets/cnfm"; import { Button, Input } from "@common/components"; @@ -97,7 +100,7 @@ const INSTL_PROGRESS_ORDER = [ const getProgressStepCount = (prgrsSttsComCd?: string): number => { const idx = INSTL_PROGRESS_ORDER.indexOf(prgrsSttsComCd ?? "ST"); if (idx <= INSTL_PROGRESS_ORDER.indexOf("ST")) return 1; - if (idx <= INSTL_PROGRESS_ORDER.indexOf("TP")) return 2; + if (idx <= INSTL_PROGRESS_ORDER.indexOf("ER")) return 2; return 3; }; @@ -106,14 +109,17 @@ const getRoleStepCount = (dtlInstCd?: string): number => { return mapped ? REVIEW_STEP_ROLE_RANK[mapped] : 0; }; +// 업무구분 코드 -> 표시명 매핑 (체크리스트 그룹 표시용) +const TASK_CODE_LABELS: Record = { + RE: "재이용수", + SP: "하수관로", + SF: "하수처리시설", +}; + const normalizeChecklistResultRows = (payload: any): any[] => { - // support new API field `cklstResultMap` while keeping backward-compatibility - const raw = payload?.cklstResultMap ?? payload?.cklstResultList; - if (Array.isArray(raw)) return raw; - if (raw && typeof raw === "object") { - return Object.values(raw).flatMap((rows) => (Array.isArray(rows) ? rows : [])); - } - return []; + // expect flat `cklstResultList` only + const raw = payload?.cklstResultList ?? []; + return Array.isArray(raw) ? raw : []; }; /* ------------------------------------------------------------------ */ @@ -148,6 +154,13 @@ const normalizeChecklistResultRows = (payload: any): any[] => { function CnfmRegistPage({ initialData }: { initialData?: any } = {}) { const router = useRouter(); const userInfo = useUserStore((state) => state.userInfo); + // userInfo?.dtlInstCd 테스트 코드 ENV.ROLE_USER03 , KECO 권한 테스트용 (실제 권한에 맞게 변경 필요) + /* + const userInfo = { + ...userInfo2, + dtlInstCd: "ROLE_USER03", //ROLE_USER03, ROLE_USER02, ROLE_USER01 KECO 권한 테스트용 (실제 권한에 맞게 변경 필요) + }; + */ const alert = useAlertStore((state) => state.setMessage); const qc = useQueryClient(); const currentDet = useMemo(() => initialData?.detDto ?? initialData, [initialData]); @@ -172,37 +185,51 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) { useEffect(() => { if (visibleReviewStepCodes.length === 0) return; - if (editableReviewStep && selectedReviewStep !== editableReviewStep) { - setSelectedReviewStep(editableReviewStep); - return; - } - if (!visibleReviewStepCodes.includes(selectedReviewStep)) { - setSelectedReviewStep(visibleReviewStepCodes[visibleReviewStepCodes.length - 1]); - } - }, [editableReviewStep, selectedReviewStep, visibleReviewStepCodes]); + // Only set default/initial selection when visible steps or editable step change. + // Do NOT include `selectedReviewStep` in deps to avoid overwriting user clicks. + setSelectedReviewStep((current) => { + // keep current if still visible + if (current && visibleReviewStepCodes.includes(current)) return current; + // prefer editable step if available + if (editableReviewStep) return editableReviewStep; + // otherwise pick the last visible step + return visibleReviewStepCodes[visibleReviewStepCodes.length - 1]; + }); + }, [editableReviewStep, visibleReviewStepCodes]); const docId = useMemo(() => { return currentDet?.instlSncnDocNo ?? ""; }, [currentDet]); const [docSeComCd] = useState("INSTL"); - const [taskSeComCdNm, setTaskSeComCdNm] = useState("재이용수"); - const [taskSeComCd, setTaskSeComCd] = useState("RE"); + const [taskSeComCdNm, setTaskSeComCdNm] = useState( + () => (currentDet?.taskSeComCdNm as string) ?? "", + ); + const [taskSeComCd, setTaskSeComCd] = useState( + () => (currentDet?.taskSeComCd as string) ?? "RE", + ); useEffect(() => { + // do not override server-provided initialData (modify mode) + if (currentDet) return; + 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 || "재이용수"); + const parsed = JSON.parse(raw) as { + taskSeComCdList?: string[]; + taskSeComCdNmList?: string[]; + }; + + setTaskSeComCd(parsed.taskSeComCdList?.join(",") || "RE"); + setTaskSeComCdNm(parsed.taskSeComCdNmList?.join(",") || "재이용수"); } catch { // noop } finally { sessionStorage.removeItem(INSTL_SNCN_REGISTER_SELECTION_KEY); } - }, []); + }, [currentDet]); /* ── 기본 정보 ── */ const [bzNm, setBzNm] = useState(""); const [bzBgngYr, setBzBgngYr] = useState(""); @@ -232,6 +259,9 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) { staleTime: Infinity, }); + // selected task code for header filtering (null = none selected) + const [selectedTaskCode, setSelectedTaskCode] = useState(null); + const [requiredFiles, setRequiredFiles] = useState>( {}, ); @@ -405,6 +435,10 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) { } // console.log("etcFileGroupIdRef.current 초기화:", etcFileGroupIdRef.current); + // 수정모드인 경우 서버가 전달한 업무구분(단일 혹은 콤마로 결합된 복수)을 상태로 복원 + setTaskSeComCd(det.taskSeComCd); + setTaskSeComCdNm(det.taskSeCdNm); + // 매핑 완료 플래그 설정 — 이후 동일 컴포넌트 인스턴스에서 재매핑 방지 initialMappedRef.current = true; @@ -597,18 +631,47 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) { /* ── 체크리스트 ── */ type CheckResultVal = "Y" | "N" | null; const [checkResults, setCheckResults] = useState>({}); + const [checkComments, setCheckComments] = useState>({}); const cklstRowRefs = useRef>({}); + const [pendingInvalidKey, setPendingInvalidKey] = useState(null); const { data: cklstTree = [] } = useQuery({ queryKey: ["cklstTree", docSeComCd, taskSeComCd], - queryFn: () => cklstMngService.findTree({ docSeComCd, taskSeComCd }), + queryFn: async () => { + // support comma-separated task codes (e.g. "RE,SP") by splitting + const raw = taskSeComCd ?? ""; + const codes = raw + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + + if (codes.length === 0) return [] as CklstTreeNode[]; + if (codes.length === 1) { + return await cklstMngService.findTree({ docSeComCd, taskSeComCd: codes[0] }); + } + + const results = await Promise.all( + codes.map((code) => cklstMngService.findTree({ docSeComCd, taskSeComCd: code })), + ); + const flat = results.flat(); + // dedupe by cklstRvwSn, keep first occurrence + const map = new Map(); + for (const row of flat) { + if (!map.has(row.cklstRvwSn)) map.set(row.cklstRvwSn, row); + } + return Array.from(map.values()); + }, enabled: Boolean(docSeComCd) && Boolean(taskSeComCd), staleTime: Infinity, }); const cklstRows = useMemo(() => { // bchckUseYn 필터 제거: 부모가 있는 자식 노드(bchckUseYn=N)도 트리에 포함 - const useRows = cklstTree; + // cklstTree 내의 피드 taskSeComCd 에 따라서 그룹핑 되도록 해줘 + + const useRows = selectedTaskCode + ? cklstTree.filter((r) => (r as any).taskSeComCd === selectedTaskCode) + : cklstTree; if (useRows.length === 0) return [] as CklstTreeNode[]; // artcl 단위로 분리하지 않고 전체 rows에서 단일 childMap을 구성한다. @@ -642,11 +705,30 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) { walk(null); return ordered; - }, [cklstTree]); + }, [cklstTree, selectedTaskCode]); + + // when cklstTree changes, default selectedTaskCode to first available code + useEffect(() => { + const codes = Array.from( + new Set((cklstTree ?? []).map((r) => (r as any).taskSeComCd).filter(Boolean)), + ); + + if (codes.length === 0) { + if (selectedTaskCode !== null) setSelectedTaskCode(null); + return; + } + + // if there's no selection yet, or the current selection is not in the new codes, + // pick the first available code + if (!selectedTaskCode || !codes.includes(selectedTaskCode)) { + setSelectedTaskCode(codes[0]); + } + }, [cklstTree, selectedTaskCode]); const cklstKey = (row: CklstTreeNode, rvwStpComCd?: string) => { const stepCode = rvwStpComCd ?? DEFAULT_RVW_STP_COM_CD; - return `${row.cklstSn}_${row.cklstArtclSn}_${row.cklstRvwSn}_${stepCode}`; + const taskCode = (row as any).taskSeComCd ?? ""; + return `${taskCode}_${row.cklstSn}_${row.cklstArtclSn}_${row.cklstRvwSn}_${stepCode}`; }; useEffect(() => { @@ -664,9 +746,10 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) { }); }); - // 행이 없어졌을 때 stale key만 정리 (현재 화면에 노출되는 step 기준) + // 행이 없어졌을 때 stale key만 정리 + // build liveKeys from full cklstTree so inputs for other taskSeComCd are preserved const liveKeys = new Set(); - cklstRows.forEach((row) => { + (cklstTree ?? []).forEach((row) => { visibleReviewStepCodes.forEach((stepCode) => { liveKeys.add(cklstKey(row, stepCode)); }); @@ -701,7 +784,23 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) { return changed ? next : prev; }); - }, [cklstRows, visibleReviewStepCodes]); + }, [cklstTree, visibleReviewStepCodes]); + + // when a previously-missing invalid key is set (due to switching task code), + // try to scroll/focus it after the rows have re-rendered + useEffect(() => { + if (!pendingInvalidKey) return; + const el = cklstRowRefs.current[pendingInvalidKey]; + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "center" }); + const firstRadio = el.querySelector( + `input[name="chk_${pendingInvalidKey}"]`, + ); + firstRadio?.focus(); + setPendingInvalidKey(null); + } + // otherwise, wait for cklstRows update (effect will re-run when pendingInvalidKey remains set) + }, [pendingInvalidKey, cklstRows]); // 수정 모드에서 서버의 체크리스트 결과값을 폼 상태로 반영 useEffect(() => { @@ -710,13 +809,15 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) { if (!Array.isArray(results) || results.length === 0) return; const lookup = new Map(); + const commentLookup = new Map(); const stepCodesInResult = new Set(); for (const r of results) { const stepCode = (r.rvwStpComCd ?? DEFAULT_RVW_STP_COM_CD) as ReviewStepCode; if (!REVIEW_STEP_CODES.includes(stepCode)) continue; stepCodesInResult.add(stepCode); - const key = `${r.cklstSn}_${r.cklstArtclSn ?? ""}_${r.cklstRvwSn}_${stepCode}`; + const key = `${r.taskSeComCd ?? ""}_${r.cklstSn}_${r.cklstArtclSn ?? ""}_${r.cklstRvwSn}_${stepCode}`; if (r.rvwRsltAnmlyYn === "Y" || r.rvwRsltAnmlyYn === "N") lookup.set(key, r.rvwRsltAnmlyYn); + if (r.picRvwCn) commentLookup.set(key, String(r.picRvwCn)); } setCheckResults((prev) => { @@ -730,7 +831,8 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) { targetStepCodes.forEach((stepCode) => { const k = cklstKey(row, stepCode); const v = lookup.get(k); - if (v && next[k] !== v) { + // only apply server-provided value when we don't already have a user-entered value + if (v && (next[k] === null || next[k] === undefined)) { next[k] = v as CheckResultVal; changed = true; } @@ -738,14 +840,29 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) { } return changed ? next : prev; }); + // set comment state separately to avoid rerendering conflicts + setCheckComments((prev) => { + const next: Record = { ...prev }; + let changed = false; + for (const [k, v] of commentLookup.entries()) { + // only set comment if none exists yet (preserve user edits) + if (!next[k]) { + next[k] = v; + changed = true; + } + } + return changed ? next : prev; + }); }, [initialData, cklstRows, visibleReviewStepCodes]); const cklstStats = useMemo(() => { if (!selectedReviewStep) { return { total: 0, completed: 0, percent: 0 }; } - const total = cklstRows.length; - const completed = cklstRows.reduce((acc, row) => { + // only include rows that are marked for checkbox use (bchckUseYn === "Y") + const relevantRows = cklstRows.filter((row) => row.bchckUseYn === "Y"); + const total = relevantRows.length; + const completed = relevantRows.reduce((acc, row) => { const val = checkResults[cklstKey(row, selectedReviewStep)] ?? ""; return acc + (val ? 1 : 0); }, 0); @@ -759,48 +876,35 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) { const code = r?.rvwStpComCd as ReviewStepCode | undefined; if (code && REVIEW_STEP_CODES.includes(code)) persistedStepCodeSet.add(code); }); + // Always persist currently visible review steps as well visibleReviewStepCodes.forEach((code) => persistedStepCodeSet.add(code)); - return Array.from(persistedStepCodeSet).flatMap((stepCode) => - cklstRows.map((row) => ({ - cklstSn: row.cklstSn, - cklstArtclSn: row.cklstArtclSn, - cklstRvwSn: row.cklstRvwSn, - rvwStpComCd: stepCode, - selected: checkResults[cklstKey(row, stepCode)] ?? null, - })), + // only persist rows that are enabled for checkbox (`bchckUseYn === "Y") + // only persist rows that are enabled for checkbox (`bchckUseYn === "Y") + // Use full cklstTree so items not currently visible (other task codes) are included + const relevantRows = (cklstTree ?? []).filter((row) => row.bchckUseYn === "Y"); + + // Ensure we generate DTOs grouped by all known task codes (TASK_CODE_LABELS keys) + const allTaskCodes = Object.keys(TASK_CODE_LABELS); + + return allTaskCodes.flatMap((taskCode) => + Array.from(persistedStepCodeSet).flatMap((rvwStpComCd) => + relevantRows + .filter((row) => ((row as any).taskSeComCd ?? "") === taskCode) + .map((row) => ({ + taskSeComCd: taskCode, + cklstSn: row.cklstSn, + cklstArtclSn: row.cklstArtclSn, + cklstRvwSn: row.cklstRvwSn, + rvwStpComCd: rvwStpComCd, + selected: checkResults[cklstKey(row, rvwStpComCd)] ?? null, + picRvwCn: checkComments[cklstKey(row, rvwStpComCd)] ?? null, + })), + ), ); }; - // 맵 형식으로 저장할 경우(업서트 처리용) - 키는 상세 코드(EX: LOCAL.지자체) 포맷 - const REVIEW_STEP_FULL: Record = { - LOCAL: "LOCAL.지자체", - ENV: "ENV.환경청", - KECO: "KECO.환경공단", - }; - - const toCklstSaveDtoMap = () => { - const persistedStepCodeSet = new Set(); - normalizeChecklistResultRows(initialData).forEach((r: any) => { - const code = r?.rvwStpComCd as ReviewStepCode | undefined; - if (code && REVIEW_STEP_CODES.includes(code)) persistedStepCodeSet.add(code); - }); - visibleReviewStepCodes.forEach((code) => persistedStepCodeSet.add(code)); - - const result: Record = {}; - Array.from(persistedStepCodeSet).forEach((stepCode) => { - const fullKey = REVIEW_STEP_FULL[stepCode]; - result[fullKey] = cklstRows.map((row) => ({ - cklstSn: row.cklstSn, - cklstArtclSn: row.cklstArtclSn, - cklstRvwSn: row.cklstRvwSn, - rvwStpComCd: fullKey, - selected: checkResults[cklstKey(row, stepCode)] ?? null, - })); - }); - - return result; - }; + // removed map-shaped payload generation — server now expects `cklstResultList` only const toEsntlAtchFileSaveDtoList = () => Object.values(requiredFiles) @@ -815,7 +919,11 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) { const [bzScaleMap, setBzScaleMap] = useState>({}); /* ── 공종구분 트리 로드 ── */ - const { data: cnstpTree = [] } = useCnstpSeMngTree(); + const { data: cnstpTree = [] } = useQuery({ + queryKey: ["cnstpTree"], + queryFn: () => cnstpSeMngService.findTree(), + staleTime: Infinity, + }); /* ── 공종구분 리프 노드 집합 ── */ const cnstpLeafSet = useMemo(() => { @@ -865,7 +973,11 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) { const [amtMap, setAmtMap] = useState>({}); /* ── 회계구분 트리 로드 ── */ - const { data: acntgTree = [] } = useAcntgSeMngTree(); + const { data: acntgTree = [] } = useQuery({ + queryKey: ["acntgTree"], + queryFn: () => acntgSeMngService.findTree(), + staleTime: Infinity, // 코드 데이터 — 앱 수명 동안 재요청 불필요 + }); /* ── 리프 노드 집합 (자식이 없는 노드 = 직접 입력 가능) ── */ const leafSet = useMemo(() => { @@ -962,13 +1074,15 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) { } // 현재 입력 단계의 체크리스트 미선택 항목이 있으면 진행 불가 + /* const hasUncheckedChecklist = cklstRows.some( (row) => !checkResults[cklstKey(row, editableReviewStep)], ); if (hasUncheckedChecklist) { alert("체크리스트를 모두 체크하세요."); return false; - } + } + */ // 저장 시: 필수 첨부파일(esntlAtchYn=Y) 업로드 여부 검증 if (mode === "SAVE") { const missingRequiredFile = requiredAttachmentDefs.find((item) => { @@ -981,19 +1095,36 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) { return false; } - // 저장 시: 사용 항목(Y)은 검토 결과가 반드시 예(Y)여야 함 - const invalidYesRow = cklstRows.find( - (row) => row.bchckUseYn === "Y" && checkResults[cklstKey(row, editableReviewStep)] !== "Y", + // 저장 시: bchckUseYn =="Y" 인 경우, 화면에 표시되는 모든 review step에 대해 + // 검토 결과가 선택되어 있어야 함 (visibleReviewStepCodes 기준) + // check across all checklist items (not only currently visible rows) + const allRelevant = (cklstTree ?? []).filter((row) => row.bchckUseYn === "Y"); + const missingRow = allRelevant.find((row) => + visibleReviewStepCodes.some((step) => !checkResults[cklstKey(row, step)]), ); - if (invalidYesRow) { - const invalidKey = cklstKey(invalidYesRow, editableReviewStep); + if (missingRow) { + const missingStep = visibleReviewStepCodes.find( + (step) => !checkResults[cklstKey(missingRow, step)], + )!; + const invalidKey = cklstKey(missingRow, missingStep); + console.warn("검토 결과 미선택 항목 발견:", invalidKey, missingRow, missingStep); + 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)여야 합니다.`); + if (invalidRowEl) { + invalidRowEl.scrollIntoView({ behavior: "smooth", block: "center" }); + const firstRadio = invalidRowEl.querySelector( + `input[name="chk_${invalidKey}"]`, + ); + firstRadio?.focus(); + } else { + // row not visible: switch header filter to the missing row's task code, + // then focus after render via pendingInvalidKey + const taskCode = (missingRow as any).taskSeComCd ?? null; + if (taskCode) setSelectedTaskCode(taskCode); + setPendingInvalidKey(invalidKey); + } + + alert(`체크리스트 항목 "${missingRow.rvwCn}"의 검토결과를 선택하세요.`); return false; } } @@ -1001,18 +1132,14 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) { return true; } - const toInstlSncnMngPayload = (prgrsSttsComCd: "SV" | "ST"): InstlSncnMng => ({ - instlSncnDocNo: docId ?? "", // 수정 시 기존 문서번호 유지, 신규 저장 시 빈값 + const toInstlSncnMngPayload = ( + prgrsSttsComCd: "SV" | "ST" | "ER", + ): InstlSncnMngSaveRequestDto => ({ + instlSncnDocNo: docId ?? "", admdstCd: "", - sidoNm: "", - sggNm: "", - chrgrNm: "", - instNm: "", envoSeComCd: currentDet?.envoSeComCd ?? "", sncnSeComCd: sncnSeComCd || DEFAULT_SNCN_SE_COM_CD, - envoSeCdNm: "", taskSeComCd, - taskSeCdNm: taskSeComCdNm, bgtBizCd: "", bgtAplyYr: "", bizNm: bzNm, @@ -1029,23 +1156,17 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) { dsgnrEmlAddr, atchFileGroupId: etcFileGroupId, prgrsSttsComCd, - prgrsSttsCdNm: "", - regDt: "", - mdfcnDt: "", - rgtrNm: "", }); async function handleSave() { if (!fnValidation("SAVE")) return; - // 저장 시에는 하위 호환성을 위해 기존 배열 필드도 함께 전송하되, - // 추가로 서버에서 upsert 처리할 수 있도록 `cklstResultMap` 형태로도 전송합니다. - const payload: InstlSncnApplySaveRequestDto & { cklstResultMap?: Record } = { + // 저장 시에는 `cklstResultList`만 전송합니다 (map 필드 제거) + const payload: InstlSncnApplySaveRequestDto = { instlSncnMng: toInstlSncnMngPayload("SV"), // 저장 시 진행상태는 SV (임시저장) bzScaleList: toBzScaleSaveDtoList(), bizCstList: toBizCstSaveDtoList(), cklstResultList: toCklstSaveDtoList(), esntlAtchFileList: toEsntlAtchFileSaveDtoList(), - cklstResultMap: toCklstSaveDtoMap(), }; try { @@ -1061,16 +1182,28 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) { } } - function handleSubmit() { - if (!fnValidation("PRESENTN")) return; - const payload = { - instlSncnMng: toInstlSncnMngPayload("ST"), + async function handleSubmit() { + if (!fnValidation("SAVE")) return; + + const payload: InstlSncnApplySaveRequestDto = { + instlSncnMng: toInstlSncnMngPayload("SV"), // 저장 시 진행상태는 SV (임시저장) bzScaleList: toBzScaleSaveDtoList(), bizCstList: toBizCstSaveDtoList(), - cklstRvwRsltList: toCklstSaveDtoList(), + cklstResultList: toCklstSaveDtoList(), + esntlAtchFileList: toEsntlAtchFileSaveDtoList(), }; - void payload; - alert("제출 API 연동 후 progrsSttus=ST 전송"); + + try { + const issuedDocNo = await InstlSncnApi.saveApply(payload); + alert(`제출 되었습니다. 문서번호: ${issuedDocNo}`); + } catch (error) { + console.error("저장 API 호출 실패", error); + const serverMessage = + axios.isAxiosError(error) && error.response?.data?.message + ? (error.response.data as { message: string }).message + : "저장 중 오류가 발생했습니다."; + alert(serverMessage); + } } /* ---------------------------------------------------------------- */ @@ -1171,7 +1304,9 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) { 신청 지자체 - {userInfo?.enoNm ?? "\u00A0"} + {isModifyMode + ? `${currentDet?.sido ?? "-"} ${currentDet?.gugun ?? "-"}` + : (userInfo?.laeSeNm ?? "\u00A0")} @@ -1182,9 +1317,9 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
{[ - ["CNFM", "설치인가"], - ["APR", "설치승인"], - ["AFA", "폐지인가"], + ["1", "설치인가"], + ["2", "설치승인"], + ["3", "폐지승인"], ].map(([val, label]) => (