Merge remote-tracking branch 'origin/main' into iipbs/wr/dev

This commit is contained in:
2026-06-10 11:36:11 +09:00
239 changed files with 25556 additions and 6245 deletions
+2 -2
View File
@@ -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"
}
}
}
@@ -0,0 +1 @@
export { AcntgCodeManagementPage as default } from "@/pages/acntg-code-management";
@@ -0,0 +1 @@
export { ChecklistManagementPage as default } from "@/pages/checklist-management";
@@ -0,0 +1 @@
export { CnstpCodeManagementPage as default } from "@/pages/cnstp-code-management";
@@ -0,0 +1 @@
export { EsntlAtchFileMngPage as default } from "@/pages/esntl-atch-file-mng";
@@ -1,3 +1,5 @@
import { CnfmRegistPage } from "@/pages/instl-sncn";
export default CnfmRegistPage;
export default function Page(props: any) {
return <CnfmRegistPage {...props} />;
}
@@ -0,0 +1 @@
export { RvwMnpwMngPage as default } from "@/pages/rvw-mnpw-mng";
+153 -152
View File
@@ -24,24 +24,23 @@
```
┌───────────────────────────────────────────────────────────────────────────────────────────────┐
│ 페이지 타이틀: 체크리스트관리 브레드크럼: 디지털행정지원 > … > │
│ 페이지 타이틀: 체크리스트관리 브레드크럼: 디지털행정지원 > … > │
├──────────────────────────────────────────────┬────────────────────────────────────────────────┤
│ [영역 A] 점검표 관리 │ [영역 B] 점검항목 관리 │
│ [점검표 추가] │ [점검항목 추가] │
│ [점검표 추가] │ [점검항목 추가] │
│ ┌────────────────────────────────────────┐ │ ┌──────────────────────────────────────────┐ │
│ │ 점검표일련번호 | 점검표명 | 문서구분 │ │ │ 점검항목코드 | 점검항목명 | 사용여부 │ │
│ │ 업무구분 | 사용여부 | 등록일시 | 관리 │ │ │ 등록일시 | 관리 │ │
│ │ 점검표일련번호 | 점검표명 | 문서구분 │ │ │ 점검항목코드 | 점검항목명 | 사용여부 │ │
│ │ 업무구분 | 사용여부 | 등록일시 | 관리 │ │ │ 등록일시 | 관리 │ │
│ ├────────────────────────────────────────┤ │ ├──────────────────────────────────────────┤ │
│ │ 1 (선택 행 강조) 설치인가 기본 … [관리]│ │ │ ITEM_001 요건검토 … [관리][삭제]│ │
│ │ [삭제] │ │ │ ITEM_002 법령검토 … [관리][삭제]│ │
│ │ 1 (선택 행 강조) [관리] [삭제] │ │ │ ITEM_001 요건검토 … [관리][삭제] │ │
│ │ │ │ │ ITEM_002 법령검토 … [관리][삭제] │ │
│ └────────────────────────────────────────┘ │ └──────────────────────────────────────────┘ │
├──────────────────────────────────────────────┴────────────────────────────────────────────────┤
│ [영역 C] 체크리스트 항목 (선택된 점검표: {cklstNm}) │
│ [미리보기] [추가] [저장] │
│ [영역 C] 체크리스트 항목 [미리보기] [추가] [저장] │
│ ┌─────────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ 점검항목 ▼ | 주요 검토내용 | 사용 여부 ○예 ○아니오 | 구분 | 관리 │ │
│ │ L1 [요건검토 ▼] | (입력) | ○예 ○아니오 | 1단계| [하위추가]│ │
│ │ L2 [요건검토 ▼] | (입력) | ○예 ○아니오 | 2단계| [삭제] │ │
│ │ 점검항목 ▼ | 주요 검토내용 | 사용 여부 | 구분 | 관리 │ │
│ │ L1 [요건검토 ▼] | (입력) | ○예 ○아니오 | 1단계 | [하위추가]│ │
│ │ L2 [요건검토 ▼] | (입력) | ○예 ○아니오 | 2단계 | [삭제] │ │
│ └─────────────────────────────────────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────────────────────────────────┘
```
@@ -61,7 +60,6 @@ BEGIN
// 영역 A: 점검표 행 선택
ON 영역 A 행 CLICK DO
SET 선택된 점검표 = 행.cklstSn
DISPLAY "선택된 점검표: {cklstNm}" (영역 C 헤더)
CALL GET /as-checklist-service/api/v1/cklst-rvw?cklstSn={cklstSn}
// TBD: 점검표 단위 전체 검토 트리 조회 API — 현재는 항목별 by-artcl 조합 또는 클라이언트 병합
DISPLAY 체크리스트 항목 그리드 (영역 C)
@@ -174,42 +172,42 @@ END
### 4-1. 헤더 / 툴바
| 항목 | 내용 |
| ----------- | ----------------------------------------- |
| 섹션 타이틀 | 점검표 관리 |
| 항목 | 내용 |
| ----------- | ------------------------------------------ |
| 섹션 타이틀 | 점검표 관리 |
| 우측 버튼 | **[점검표 추가]** — 점검표 등록 다이얼로그 |
### 4-2. 그리드 컬럼
| 컬럼명 | Field | 타입 | 비고 |
| ---------------- | ------------- | ------ | ------------------------------------------------- |
| 점검표일련번호 | `cklstSn` | number | PK |
| 점검표명 | `cklstNm` | string | |
| 컬럼명 | Field | 타입 | 비고 |
| ---------------- | ------------- | ------ | ---------------------------------------------------- |
| 점검표일련번호 | `cklstSn` | number | PK |
| 점검표명 | `cklstNm` | string | |
| 문서구분공통코드 | `docSeComCd` | string | 코드명 표시 — `ASDOCSECD` 등 공통코드 (TBD 상위코드) |
| 업무구분공통코드 | `taskSeComCd` | string | RE/SF/SP 등 — 공통코드 (TBD 상위코드) |
| 사용여부 | `useYn` | string | `Y`→예, `N`→아니오 |
| 등록일시 | `regDt` | string | `yyyy-MM-dd HH:mm:ss` |
| 관리 | — | — | 행별 **[관리] · [삭제]** |
| 업무구분공통코드 | `taskSeComCd` | string | RE/SF/SP 등 — 공통코드 (TBD 상위코드) |
| 사용여부 | `useYn` | string | `Y`→예, `N`→아니오 |
| 등록일시 | `regDt` | string | `yyyy-MM-dd HH:mm:ss` |
| 관리 | — | — | 행별 **[관리] · [삭제]** |
- 행 클릭 시: `cklstSn` 선택 → 영역 C 헤더·그리드 갱신.
- 페이지네이션: Spring `Pageable` (`page`, `size`, `sort`).
### 4-3. 버튼
| 버튼명 | 위치 | 동작 |
| ---------- | -------------- | ------------------------------------------------- |
| 버튼명 | 위치 | 동작 |
| ----------- | -------------- | ------------------------------------------------- |
| 점검표 추가 | 섹션 우측 상단 | 점검표 추가 다이얼로그 오픈 |
| 관리 | 행별 | 점검표 수정 다이얼로그 (기존 값 초기화) |
| 삭제 | 행별 | 확인 후 삭제. 선택 중인 점검표이면 영역 C 초기화. |
| 관리 | 행별 | 점검표 수정 다이얼로그 (기존 값 초기화) |
| 삭제 | 행별 | 확인 후 삭제. 선택 중인 점검표이면 영역 C 초기화. |
### 4-4. 다이얼로그: 점검표 추가 / 수정
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
| -------- | ---------------- | ---- | ------ | ----------------- | ----------------------------------------- |
| 점검표명 | Text | Y | — | `cklstNm` | placeholder: "예: 설치인가 체크리스트" |
| 문서구분 | Select(공통코드) | N | — | `docSeComCd` | 예: FNRS, INSTL, PWRK, DESIGN |
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
| -------- | ---------------- | ---- | ------ | ----------------- | ------------------------------------------------ |
| 점검표명 | Text | Y | — | `cklstNm` | placeholder: "예: 설치인가 체크리스트" |
| 문서구분 | Select(공통코드) | N | — | `docSeComCd` | 예: FNRS, INSTL, PWRK, DESIGN |
| 업무구분 | Select(공통코드) | Y | — | `taskSeComCd` | 예: RE(재이용수), SF(하수처리시설), SP(하수관로) |
| 사용여부 | Select | N | Y | `useYn` | Y/N, UI 표시: 예/아니오 |
| 사용여부 | Select | N | Y | `useYn` | Y/N, UI 표시: 예/아니오 |
| 버튼명 | 동작 |
| ------ | -------------------------------------- |
@@ -225,29 +223,29 @@ END
### 5-1. 헤더 / 툴바
| 항목 | 내용 |
| ----------- | ------------------------------------------- |
| 섹션 타이틀 | 점검항목 관리 |
| 항목 | 내용 |
| ----------- | ---------------------------------------------- |
| 섹션 타이틀 | 점검항목 관리 |
| 우측 버튼 | **[점검항목 추가]** — 점검항목 등록 다이얼로그 |
### 5-2. 그리드 컬럼
| 컬럼명 | Field | 타입 | 비고 |
| ------------ | --------------- | ------ | ---------------------------------------------- |
| 점검항목코드 | `cklstArtclSn` | number | UI mock `ITEM_001` 형식 표시 여부 TBD |
| 점검항목명 | `cklstArtclNm` | string | |
| 사용여부 | `useYn` | string | `Y`→예, `N`→아니오 |
| 등록일시 | `regDt` | string | `yyyy-MM-dd HH:mm:ss` |
| 관리 | — | — | 행별 **[관리] · [삭제]** |
| 컬럼명 | Field | 타입 | 비고 |
| ------------ | -------------- | ------ | ------------------------------------- |
| 점검항목코드 | `cklstArtclSn` | number | UI mock `ITEM_001` 형식 표시 여부 TBD |
| 점검항목명 | `cklstArtclNm` | string | |
| 사용여부 | `useYn` | string | `Y`→예, `N`→아니오 |
| 등록일시 | `regDt` | string | `yyyy-MM-dd HH:mm:ss` |
| 관리 | — | — | 행별 **[관리] · [삭제]** |
> **TBD**: 백엔드 `CklstArtcl`은 복합 PK(`cklstArtclSn`, `cklstSn`)이며 등록 시 `cklstSn` 필수. UI 다이얼로그에는 `cklstSn` 입력이 없음 — 소속 점검표 지정 방식·전역 마스터 여부 백엔드 정책 확인.
### 5-3. 다이얼로그: 점검항목 추가 / 수정
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
| ---------- | --------- | ---- | ------ | ----------------- | ----------------------- |
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
| ---------- | --------- | ---- | ------ | ----------------- | --------------------------- |
| 점검항목명 | Text | Y | — | `cklstArtclNm` | placeholder: "예: 법령검토" |
| 사용여부 | Select | N | Y | `useYn` | Y/N |
| 사용여부 | Select | N | Y | `useYn` | Y/N |
| 버튼명 | 동작 |
| ------ | -------------------------------------- |
@@ -270,32 +268,32 @@ END
### 6-2. 그리드 컬럼
| 컬럼명 | Field | 입력 UI | 비고 |
| -------------- | --------------- | ------------------------------- | ------------------------------------------------- |
| 점검항목 | `cklstArtclSn` | Select (영역 B 항목 목록) | L1/L2 접두 표시, `cklstArtclNm` 라벨 |
| 주요 검토내용 | `rvwCn` | Text / Textarea | 최대 4000자 |
| 사용 여부 | `useYn` | Radio (예/아니오) | `Y` / `N` |
| 구분 | `rvwCnLvNo` | 읽기 전용 | 1→1단계, 2→2단계 |
| 관리 | — | **[하위추가]** (1단계만) · **[삭제]** | 2단계 행은 [하위추가] 미표시 |
| 컬럼명 | Field | 입력 UI | 비고 |
| ------------- | -------------- | ------------------------------------- | ------------------------------------ |
| 점검항목 | `cklstArtclSn` | Select (영역 B 항목 목록) | L1/L2 접두 표시, `cklstArtclNm` 라벨 |
| 주요 검토내용 | `rvwCn` | Text / Textarea | 최대 4000자 |
| 사용 여부 | `useYn` | Radio (예/아니오) | `Y` / `N` |
| 구분 | `rvwCnLvNo` | 읽기 전용 | 1→1단계, 2→2단계, 3→3단계 |
| 관리 | — | **[하위추가]** (1·2단계, 저장 후) · **[삭제]** | 3단계 행은 [하위추가] 미표시 |
#### 계층 정책
| 항목 | 내용 |
| -------------- | -------------------------------------------------------------------- |
| 최대 깊이 | 2단계 (`rvwCnLvNo`: 1, 2) |
| 최대 깊이 | 3단계 (`rvwCnLvNo`: 1, 2, 3) |
| 상위-하위 연결 | `upCklstRvwSn` — 2단계 행은 부모 1단계 `cklstRvwSn` 참조 |
| 정렬 | `sortSeq` — 동일 레벨 내 순서 (TBD: UI 드래그 정렬 여부) |
| 체크 사용 | `bchckUseYn` — 미리보기·실사용 화면 체크박스 노출 여부 (TBD UI 반영) |
### 6-3. 버튼
| 버튼명 | 동작 |
| -------- | ------------------------------------------------------------ |
| 추가 | 1단계 검토 행 1건 추가 (편집 가능) |
| 저장 | 변경·신규 검토 행 일괄 POST/PUT (일괄 저장 API 없음) |
| 미리보기 | 선택 점검표 체크리스트 읽기 전용 미리보기 (TBD) |
| 하위추가 | 선택 1단계 행 아래 2단계 자식 행 추가 |
| 삭제 | 확인 후 DELETE 또는 미저장 행 로컬 제거 |
| 버튼명 | 동작 |
| -------- | ---------------------------------------------------- |
| 추가 | 1단계 검토 행 1건 추가 (편집 가능) |
| 저장 | 변경·신규 검토 행 일괄 POST/PUT (일괄 저장 API 없음) |
| 미리보기 | 선택 점검표 체크리스트 읽기 전용 미리보기 (TBD) |
| 하위추가 | 선택 1·2단계 행 아래 하위 단계(2·3단계) 자식 행 추가 |
| 삭제 | 확인 후 DELETE 또는 미저장 행 로컬 제거 |
---
@@ -303,39 +301,38 @@ END
### 7-1. 공통
| 항목 | 내용 |
| ----------- | -------------------------------------------------------------------- |
| 서비스 | `as-checklist-service` |
| Base prefix | `/as-checklist-service/api/v1` (게이트웨이 프록시 경유) |
| 인증 | JWT (`Authorization` 헤더), `X-Site-Id`, `token-id` |
| 페이징 | Spring `Pageable`: `page`, `size`, `sort` → `PageResponse<T>` |
| 엔티티 | `@/entities/cklst` (`cklstMngService`, `cklstArtclService`) |
| 미구현 엔티티 | `@/entities/cklst-rvw` — **TBD** (영역 C용 `cklst-rvw` API) |
| 항목 | 내용 |
| ------------- | ------------------------------------------------------------- |
| 서비스 | `as-checklist-service` |
| Base prefix | `/as-checklist-service/api/v1` (게이트웨이 프록시 경유) |
| 인증 | JWT (`Authorization` 헤더), `X-Site-Id`, `token-id` |
| 페이징 | Spring `Pageable`: `page`, `size`, `sort` → `PageResponse<T>` |
| 엔티티 | `@/entities/cklst` (`cklstMngService`, `cklstArtclService`, `cklstRvwService`) |
### 7-2. 점검표 (영역 A) — `cklst`
| 기능 | Method | URL | Request / Query | Response | 클라이언트 |
| --------- | ------ | ---------------- | ---------------------------- | ------------------- | -------------------------- |
| 목록 조회 | GET | `/cklst` | `CklstMngSearchParams` + Pageable | `Page<CklstMng>` | `cklstMngService.findPage` |
| 단건 조회 | GET | `/cklst/{cklstSn}` | — | `CklstMng` | `cklstMngService.findById` |
| 등록 | POST | `/cklst` | `CklstMngSaveRequest` | `number` (cklstSn) | `cklstMngService.create` |
| 수정 | PUT | `/cklst/{cklstSn}` | `CklstMngSaveRequest` | `number` (cklstSn) | `cklstMngService.update` |
| 삭제 | DELETE | `/cklst/{cklstSn}` | — | `number` (cklstSn) | `cklstMngService.remove` |
| 기능 | Method | URL | Request / Query | Response | 클라이언트 |
| --------- | ------ | ------------------ | --------------------------------- | ------------------ | -------------------------- |
| 목록 조회 | GET | `/cklst` | `CklstMngSearchParams` + Pageable | `Page<CklstMng>` | `cklstMngService.findPage` |
| 단건 조회 | GET | `/cklst/{cklstSn}` | — | `CklstMng` | `cklstMngService.findById` |
| 등록 | POST | `/cklst` | `CklstMngSaveRequest` | `number` (cklstSn) | `cklstMngService.create` |
| 수정 | PUT | `/cklst/{cklstSn}` | `CklstMngSaveRequest` | `number` (cklstSn) | `cklstMngService.update` |
| 삭제 | DELETE | `/cklst/{cklstSn}` | — | `number` (cklstSn) | `cklstMngService.remove` |
**CklstMng (응답)**
| Field | 타입 | 설명 |
| ------------- | ------- | ------------------ |
| `cklstSn` | number | 점검표일련번호 |
| `cklstNm` | string | 점검표명 |
| `taskSeComCd` | string | 업무구분공통코드 |
| `useYn` | string? | 사용여부 |
| `docSeComCd` | string? | 문서구분공통코드 |
| `rgtr` | string? | 등록자 UUID |
| `rgtrNm` | string? | 등록자명 |
| `mdfr` | string? | 수정자 UUID |
| `regDt` | string? | 등록일시 |
| `mdfcnDt` | string? | 수정일시 |
| Field | 타입 | 설명 |
| ------------- | ------- | ---------------- |
| `cklstSn` | number | 점검표일련번호 |
| `cklstNm` | string | 점검표명 |
| `taskSeComCd` | string | 업무구분공통코드 |
| `useYn` | string? | 사용여부 |
| `docSeComCd` | string? | 문서구분공통코드 |
| `rgtr` | string? | 등록자 UUID |
| `rgtrNm` | string? | 등록자명 |
| `mdfr` | string? | 수정자 UUID |
| `regDt` | string? | 등록일시 |
| `mdfcnDt` | string? | 수정일시 |
**CklstMngSaveRequest**: `cklstNm`\*, `taskSeComCd`\*, `useYn?`, `docSeComCd?`
@@ -345,25 +342,25 @@ END
### 7-3. 점검표항목 (영역 B) — `cklst-artcl`
| 기능 | Method | URL | Request / Query | Response | 클라이언트 |
| ----------------------- | ------ | ---------------------------------------- | --------------------------------- | --------------------- | ------------------------------- |
| 목록 조회 (페이징) | GET | `/cklst-artcl` | `CklstArtclSearchParams` + Pageable | `Page<CklstArtcl>` | `cklstArtclService.findPage` |
| 점검표별 전체 목록 | GET | `/cklst-artcl/by-cklst/{cklstSn}` | — | `CklstArtcl[]` | `cklstArtclService.findByCklstSn` |
| 단건 조회 | GET | `/cklst-artcl/{cklstArtclSn}/{cklstSn}` | — | `CklstArtcl` | `cklstArtclService.findById` |
| 등록 | POST | `/cklst-artcl` | `CklstArtclSaveRequest` | `CklstArtclId` | `cklstArtclService.create` |
| 수정 | PUT | `/cklst-artcl/{cklstArtclSn}/{cklstSn}` | `CklstArtclUpdateRequest` | `CklstArtclId` | `cklstArtclService.update` |
| 삭제 | DELETE | `/cklst-artcl/{cklstArtclSn}/{cklstSn}` | — | `CklstArtclId` | `cklstArtclService.remove` |
| 기능 | Method | URL | Request / Query | Response | 클라이언트 |
| ------------------ | ------ | --------------------------------------- | ----------------------------------- | ------------------ | --------------------------------- |
| 목록 조회 (페이징) | GET | `/cklst-artcl` | `CklstArtclSearchParams` + Pageable | `Page<CklstArtcl>` | `cklstArtclService.findPage` |
| 점검표별 전체 목록 | GET | `/cklst-artcl/by-cklst/{cklstSn}` | — | `CklstArtcl[]` | `cklstArtclService.findByCklstSn` |
| 단건 조회 | GET | `/cklst-artcl/{cklstArtclSn}/{cklstSn}` | — | `CklstArtcl` | `cklstArtclService.findById` |
| 등록 | POST | `/cklst-artcl` | `CklstArtclSaveRequest` | `CklstArtclId` | `cklstArtclService.create` |
| 수정 | PUT | `/cklst-artcl/{cklstArtclSn}/{cklstSn}` | `CklstArtclUpdateRequest` | `CklstArtclId` | `cklstArtclService.update` |
| 삭제 | DELETE | `/cklst-artcl/{cklstArtclSn}/{cklstSn}` | — | `CklstArtclId` | `cklstArtclService.remove` |
**CklstArtcl (응답)**
| Field | 타입 | 설명 |
| ---------------- | ------- | -------------------- |
| `cklstArtclSn` | number | 점검표항목일련번호 |
| `cklstSn` | number | 점검표일련번호 |
| `cklstArtclNm` | string? | 점검표항목명 |
| `useYn` | string? | 사용여부 |
| `rgtr` / `rgtrNm`| string? | 등록자 |
| `regDt` | string? | 등록일시 |
| Field | 타입 | 설명 |
| ----------------- | ------- | ------------------ |
| `cklstArtclSn` | number | 점검표항목일련번호 |
| `cklstSn` | number | 점검표일련번호 |
| `cklstArtclNm` | string? | 점검표항목명 |
| `useYn` | string? | 사용여부 |
| `rgtr` / `rgtrNm` | string? | 등록자 |
| `regDt` | string? | 등록일시 |
**CklstArtclSaveRequest**: `cklstSn`\*, `cklstArtclNm?`, `useYn?`
@@ -375,37 +372,39 @@ END
### 7-4. 점검표검토 (영역 C) — `cklst-rvw`
> **entities 미구현** — `@/entities/cklst-rvw` 추가 예정. 아래는 백엔드 `CklstRvwController` 기준.
| 기능 | Method | URL | Request / Query | Response | 클라이언트 (예정) |
| ---------------------------- | ------ | -------------------------------------------------- | -------------------------- | ------------------ | ------------------------ |
| 목록 조회 (페이징) | GET | `/cklst-rvw` | `CklstRvwSearchDto` + Pageable | `Page<CklstRvw>` | TBD `cklstRvwService.findPage` |
| 항목별 검토 전체 목록 | GET | `/cklst-rvw/by-artcl/{cklstSn}/{cklstArtclSn}` | — | `CklstRvw[]` | TBD `findByArtcl` |
| 단건 조회 | GET | `/cklst-rvw/{cklstRvwSn}/{cklstArtclSn}/{cklstSn}` | — | `CklstRvw` | TBD `findById` |
| 등록 | POST | `/cklst-rvw` | `CklstRvwSaveRequestDto` | `CklstRvwId` | TBD `create` |
| 수정 | PUT | `/cklst-rvw/{cklstRvwSn}/{cklstArtclSn}/{cklstSn}` | `CklstRvwUpdateRequestDto` | `CklstRvwId` | TBD `update` |
| 삭제 | DELETE | `/cklst-rvw/{cklstRvwSn}/{cklstArtclSn}/{cklstSn}` | — | `CklstRvwId` | TBD `remove` |
| 기능 | Method | URL | Request / Query | Response | 클라이언트 |
| --------------------- | ------ | -------------------------------------------------- | ------------------------------ | ---------------- | ------------------------------- |
| 목록 조회 (페이징) | GET | `/cklst-rvw` | `CklstRvwSearchParams` + Pageable | `Page<CklstRvw>` | `cklstRvwService.findPage` |
| 항목별 검토 전체 목록 | GET | `/cklst-rvw/by-artcl/{cklstSn}/{cklstArtclSn}` | — | `CklstRvw[]` | `cklstRvwService.findByArtcl` |
| 단건 조회 | GET | `/cklst-rvw/{cklstRvwSn}/{cklstArtclSn}/{cklstSn}` | — | `CklstRvw` | `cklstRvwService.findById` |
| 등록 | POST | `/cklst-rvw` | `CklstRvwSaveRequest` | `CklstRvwId` | `cklstRvwService.create` |
| 수정 | PUT | `/cklst-rvw/{cklstRvwSn}/{cklstArtclSn}/{cklstSn}` | `CklstRvwUpdateRequest` | `CklstRvwId` | `cklstRvwService.update` |
| 삭제 | DELETE | `/cklst-rvw/{cklstRvwSn}/{cklstArtclSn}/{cklstSn}` | — | `CklstRvwId` | `cklstRvwService.remove` |
**CklstRvw (응답)**
| Field | 타입 | 설명 |
| --------------- | ------- | ---------------------- |
| `cklstRvwSn` | number | 점검표검토일련번호 |
| `cklstArtclSn` | number | 점검표항목일련번호 |
| `cklstSn` | number | 점검표일련번호 |
| `upCklstRvwSn` | number? | 상위점검표검토일련번호 |
| `rvwCnLvNo` | number? | 검토내용레벨번호 |
| `rvwCn` | string? | 검토내용 |
| `bchckUseYn` | string? | 체크사용여부 |
| `sortSeq` | number? | 정렬순서 |
| `useYn` | string? | 사용여부 |
| `regDt` | string? | 등록일시 |
| Field | 타입 | 설명 |
| -------------- | ------- | ---------------------- |
| `cklstRvwSn` | number | 점검표검토일련번호 |
| `cklstArtclSn` | number | 점검표항목일련번호 |
| `cklstSn` | number | 점검표일련번호 |
| `upCklstRvwSn` | number? | 상위점검표검토일련번호 |
| `rvwCnLvNo` | number? | 검토내용레벨번호 |
| `rvwCn` | string? | 검토내용 |
| `bchckUseYn` | string? | 체크사용여부 |
| `sortSeq` | number? | 정렬순서 |
| `useYn` | string? | 사용여부 |
| `regDt` | string? | 등록일시 |
**CklstRvwSaveRequestDto**: `cklstArtclSn`\*, `cklstSn`\*, `upCklstRvwSn?`, `rvwCnLvNo?`, `rvwCn?`, `bchckUseYn?`, `sortSeq?`, `useYn?`
**CklstRvwSaveRequest**: `cklstArtclSn`\*, `cklstSn`\*, `upCklstRvwSn?`, `rvwCnLvNo?`, `rvwCn?`, `bchckUseYn?`, `sortSeq?`, `useYn?`
**CklstRvwUpdateRequestDto**: `upCklstRvwSn?`, `rvwCnLvNo?`, `rvwCn?`, `bchckUseYn?`, `sortSeq?`, `useYn?`
**CklstRvwUpdateRequest**: `upCklstRvwSn?`, `rvwCnLvNo?`, `rvwCn?`, `bchckUseYn?`, `sortSeq?`, `useYn?`
**CklstRvwSearchDto**: `cklstSn?`, `cklstArtclSn?`, `useYn?` + Pageable
**CklstRvwSearchParams**: `cklstSn?`, `cklstArtclSn?`, `useYn?` + `page`, `size`, `sort`
**CklstRvwId**: `{ cklstRvwSn: number; cklstArtclSn: number; cklstSn: number }`
**React Query hooks**: `useCklstRvwPage`, `useCklstRvwByArtcl`, `useCklstRvw`, `useCreateCklstRvw`, `useUpdateCklstRvw`, `useDeleteCklstRvw`
### 7-5. 공통코드 (보조)
@@ -425,49 +424,50 @@ END
- **공통 컴포넌트**: `@common/components` (`Dialog`, `Button`, `Input`, `Select`, `RadioGroup` 등)
- **테이블**: `@tanstack/react-table` + `useDataTable`
- **데이터**: React Query — `@/entities/cklst` hooks (검토 API는 entities 추가 후 연동)
- **데이터**: React Query — `@/entities/cklst` hooks (`useCklstRvwPage` 등 영역 C 포함)
- **공통코드**: `@/entities/code` — `CodeSelect`, `findCodeLabel`
- **상태**: 선택된 `cklstSn`, 영역 C 검토 행 배열(신규·dirty·계층 트리)
- **모의 데이터**: API 연동 전 `@faker-js/faker`
### 8-2. 모듈·파일 배치(제안)
| 구분 | 경로 |
| ------ | ----------------------------------------------------------------------------------------- |
| 라우트 | `apps/as/app/(with-base-layout)/checklist-management/page.tsx` |
| 페이지 | `apps/as/src/pages/checklist-management/` (Public API: page만 export) |
| 구분 | 경로 |
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 라우트 | `apps/as/app/(with-base-layout)/checklist-management/page.tsx` |
| 페이지 | `apps/as/src/pages/checklist-management/` (Public API: page만 export) |
| UI | `.../ui/cklst-mng-list.tsx`, `cklst-mng-form-dialog.tsx`, `cklst-artcl-list.tsx`, `cklst-artcl-form-dialog.tsx`, `cklst-rvw-grid.tsx`, `cklst-preview-dialog.tsx` |
| model | `.../model/cklst-mng-columns.tsx`, `cklst-artcl-columns.tsx`, `cklst-rvw-row-types.ts` |
| lib | `.../lib/use-cklst-rvw-rows.ts`, `validators.ts`, `build-rvw-tree.ts` |
| 엔티티 | `apps/as/src/entities/cklst/` (구현됨), `entities/cklst-rvw/` (TBD) |
| 문서 | `apps/as/docs/checklist-management/SPEC.md` |
| model | `.../model/cklst-mng-columns.tsx`, `cklst-artcl-columns.tsx`, `cklst-rvw-row-types.ts` |
| lib | `.../lib/use-cklst-rvw-rows.ts`, `validators.ts`, `build-rvw-tree.ts` |
| 엔티티 | `apps/as/src/entities/cklst/` (`cklstMng`, `cklstArtcl`, `cklstRvw` 서비스·hooks) |
| 페이지 | `apps/as/src/pages/checklist-management/` — `/checklist-management` |
| 문서 | `apps/as/docs/checklist-management/SPEC.md` |
### 8-3. 영역별 UI 매핑
| 영역 | 주요 UI |
| ---- | ----------------------------------------------------------------------- |
| A | DataTable + 행 선택 + 점검표 추가/수정 Dialog |
| B | DataTable + 점검항목 추가/수정 Dialog |
| 영역 | 주요 UI |
| ---- | ---------------------------------------------------------------------------- |
| A | DataTable + 행 선택 + 점검표 추가/수정 Dialog |
| B | DataTable + 점검항목 추가/수정 Dialog |
| C | 계층형 편집 그리드(1·2단계) + [추가][저장][미리보기] + 행별 [하위추가][삭제] |
| 공통 | `CodeSelect` (문서구분·업무구분), 사용여부 Y/N Select/Radio |
| 공통 | `CodeSelect` (문서구분·업무구분), 사용여부 Y/N Select/Radio |
### 8-4. 다이얼로그·라우팅
| 다이얼로그명 | 트리거 | API 연동 |
| ------------------ | ----------------- | ------------------------------------- |
| 점검표 추가 | [점검표 추가] | `useCreateCklstMng` |
| 점검표 수정 | 행 [관리] | `useUpdateCklstMng` |
| 점검표 삭제 확인 | 행 [삭제] | `useDeleteCklstMng` |
| 점검항목 추가 | [점검항목 추가] | `useCreateCklstArtcl` |
| 점검항목 수정 | 행 [관리] | `useUpdateCklstArtcl` |
| 체크리스트 미리보기 | [미리보기] | TBD — 읽기 전용 트리 렌더 |
| 다이얼로그명 | 트리거 | API 연동 |
| ------------------- | --------------- | ------------------------- |
| 점검표 추가 | [점검표 추가] | `useCreateCklstMng` |
| 점검표 수정 | 행 [관리] | `useUpdateCklstMng` |
| 점검표 삭제 확인 | 행 [삭제] | `useDeleteCklstMng` |
| 점검항목 추가 | [점검항목 추가] | `useCreateCklstArtcl` |
| 점검항목 수정 | 행 [관리] | `useUpdateCklstArtcl` |
| 체크리스트 미리보기 | [미리보기] | TBD — 읽기 전용 트리 렌더 |
라우트: `/checklist-management` (메뉴 URL은 CMS 등록값과 일치시킴)
### 8-5. 데이터 흐름(요약)
- **진입**: `useCklstMngPage` → 영역 A, `useCklstArtclPage` → 영역 B. C 비활성.
- **점검표 선택**: `cklstSn` 설정 → 영역 C 활성 → `cklst-rvw` 목록 조회(TBD) → 트리 그리드 표시.
- **점검표 선택**: `cklstSn` 설정 → 영역 C 활성 → `useCklstRvwPage({ cklstSn })` → 트리 그리드 표시.
- **점검표 CRUD**: Dialog → `useCreateCklstMng` / `useUpdateCklstMng` / `useDeleteCklstMng`.
- **점검항목 CRUD**: Dialog → `useCreateCklstArtcl` / `useUpdateCklstArtcl` / `useDeleteCklstArtcl`.
- **검토 저장**: [저장] → 신규 POST / 기존 PUT 순차 호출 (일괄 API 없음).
@@ -478,7 +478,8 @@ END
- [ ] 영역 B 점검항목 등록 시 `cklstSn` 필수 vs UI 무소속 마스터 — 백엔드·기획 확인
- [ ] `점검항목코드`(ITEM_001) 표시 형식 vs `cklstArtclSn` 숫자 PK
- [ ] 영역 C 전체 검토 트리 조회: `cklstSn` 단위 일괄 API 부재 — `by-artcl` 다건 호출 또는 백엔드 확장
- [ ] `@/entities/cklst-rvw` entities·hooks 구현
- [x] `@/entities/cklst` — `cklstRvwService`·hooks 구현
- [x] `pages/checklist-management` — 영역 A·B·C UI 구현 (`/checklist-management`)
- [ ] 문서구분·업무구분 공통코드 상위코드 ID (`ASDOCSECD` 등)
- [ ] [미리보기] 다이얼로그 UI·데이터 소스
- [ ] `bchckUseYn` UI 반영 여부
+385
View File
@@ -0,0 +1,385 @@
# 화면설계서: 코드관리
## 1. 기본 정보
| 항목 | 내용 |
| ---------- | -------------------------------------------- |
| 버전 | 0.1 |
| 프로세스명 | UI설계 |
| 화면명 | 코드관리 |
| 화면 유형 | [v] 조회 [v] 입력 [v] 수정 [v] 삭제 [ ] 출력 |
| 메뉴 경로 | 디지털행정지원 > 사이트관리 > 코드관리 |
---
## 2. 화면 목적
설치인가 등 AS 업무에서 참조하는 **공종구분코드**(공종코드)와 **회계구분코드**(회계코드)를 한 화면에서 조회·등록·수정·삭제한다. 좌측 **공종코드 관리**와 우측 **회계코드 관리**는 독립된 목록·CRUD 영역이며, 각 섹션은 자체 API(`cnstp-se`, `acntg-se`)와 연동한다.
---
## 3. 화면 구성
### 3-1. 전체 레이아웃
```
┌───────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 페이지 타이틀: 코드관리 브레드크럼: 디지털행정지원 > 사이트관리 > 코드관리 │
├──────────────────────────────────────────────────┬────────────────────────────────────────────────┤
│ [영역 A] 공종코드 관리 │ [영역 B] 회계코드 관리 │
│ [등록] │ [등록] │
│ ┌────────────────────────────────────────────┐ │ ┌──────────────────────────────────────────┐ │
│ │ 코드 | 코드명 | 설명 | 사용여부 | 관리 │ │ │ 코드 | 코드명 | 설명 | 사용여부 | 관리 │ │
│ ├────────────────────────────────────────────┤ │ ├──────────────────────────────────────────┤ │
│ │ WC001 공공하수처리시설 … 사용 [수정][삭제]│ │ │ AC101 시설비 … 사용 [수정][삭제] │ │
│ │ WC002 하수관로정비 … 사용 [수정][삭제]│ │ │ AC102 용역비 … 사용 [수정][삭제] │ │
│ │ WC003 재이용시설 … 사용 [수정][삭제]│ │ │ AC103 운영비 … 미사용 [수정][삭제] │ │
│ └────────────────────────────────────────────┘ │ └──────────────────────────────────────────┘ │
│ « 1 2 3 … » │ « 1 2 3 … » │
└──────────────────────────────────────────────────┴────────────────────────────────────────────────┘
```
### 3-2. 화면 데이터 흐름
```
BEGIN
// 최초 진입 — 좌·우 독립 조회
CALL cnstpSeMngService.findAll({ page, size })
DISPLAY 공종코드 그리드 (영역 A)
CALL acntgSeMngService.findAll({ page, size })
DISPLAY 회계코드 그리드 (영역 B)
// 영역 A: 공종코드 등록
ON 영역 A [등록] 버튼 CLICK DO
OPEN 공종코드 등록 다이얼로그
ON [저장] DO
VALIDATE cnstpSeCd
CALL cnstpSeMngService.save(CnstpSeMngSaveRequestDto)
CALL cnstpSeMngService.findAll
CLOSE 다이얼로그
END ON
ON [취소] / [닫기] DO
CLOSE 다이얼로그
END ON
END ON
// 영역 A: 공종코드 수정
ON 영역 A 행 [수정] CLICK DO
CALL cnstpSeMngService.findById(cnstpSeCd)
OPEN 공종코드 수정 다이얼로그 (기존 값 초기화)
ON [저장] DO
CALL cnstpSeMngService.update(cnstpSeCd, CnstpSeMngSaveRequestDto)
CALL cnstpSeMngService.findAll
CLOSE 다이얼로그
END ON
END ON
// 영역 A: 공종코드 삭제
ON 영역 A 행 [삭제] CLICK DO
CONFIRM "선택한 공종코드를 삭제하시겠습니까?"
CALL cnstpSeMngService.delete(cnstpSeCd)
CALL cnstpSeMngService.findAll
END ON
// 영역 B: 회계코드 등록
ON 영역 B [등록] 버튼 CLICK DO
OPEN 회계코드 등록 다이얼로그
ON [저장] DO
VALIDATE acntgSeCd, acntgSeCdNm
CALL acntgSeMngService.save(AcntgSeMngSaveRequestDto)
CALL acntgSeMngService.findAll
CLOSE 다이얼로그
END ON
ON [취소] / [닫기] DO
CLOSE 다이얼로그
END ON
END ON
// 영역 B: 회계코드 수정
ON 영역 B 행 [수정] CLICK DO
CALL acntgSeMngService.findById(acntgSeCd)
OPEN 회계코드 수정 다이얼로그 (기존 값 초기화)
ON [저장] DO
CALL acntgSeMngService.update(acntgSeCd, AcntgSeMngSaveRequestDto)
CALL acntgSeMngService.findAll
CLOSE 다이얼로그
END ON
END ON
// 영역 B: 회계코드 삭제
ON 영역 B 행 [삭제] CLICK DO
CONFIRM "선택한 회계코드를 삭제하시겠습니까?"
CALL acntgSeMngService.delete(acntgSeCd)
CALL acntgSeMngService.findAll
END ON
END
```
---
## 4. 영역 A: 공종코드 관리
백엔드 **공종구분코드**(`CnstpSeMng`) CRUD. 계층 구조(`upCnstpCd`, `cdLvNo`)를 지원하나, 본 화면은 **평면 목록**으로 표시.
### 4-1. 헤더 / 툴바
| 항목 | 내용 |
| ----------- | ------------------------------------- |
| 섹션 타이틀 | 공종코드 관리 |
| 우측 버튼 | **[등록]** — 공종코드 등록 다이얼로그 |
### 4-2. 그리드 컬럼
| 컬럼명 | Field | 타입 | 비고 |
| -------- | ------------- | ------ | ------------------------ |
| 코드 | `cnstpSeCd` | string | PK, 최대 10자 |
| 코드명 | `cnstpSeCdNm` | string | 최대 100자 |
| 설명 | `expln` | string | 최대 1000자, 선택 |
| 사용여부 | `useYn` | string | `Y`→사용, `N`→미사용 |
| 관리 | — | — | 행별 **[수정] · [삭제]** |
- 행 클릭 시 별도 상세 이동 없음. 수정은 **[수정]** 버튼으로 다이얼로그 진입.
- 페이지네이션: Spring `Pageable` (`page`, `size`, `sort`) — **UI 노출**. 기본 `size: 10`, `@common/components` 공통 페이지네이션 컴포넌트 사용.
### 4-3. 버튼
| 버튼명 | 위치 | 동작 |
| ------ | -------------- | ---------------------------------------- |
| 등록 | 섹션 우측 상단 | 공종코드 등록 다이얼로그 오픈 |
| 수정 | 행별 | 상세 조회 후 수정 다이얼로그 (값 초기화) |
| 삭제 | 행별 | 확인 후 삭제, 목록 갱신 |
### 4-4. 다이얼로그: 공종코드 등록 / 수정
다이얼로그 입력 필드는 **코드 · 코드명 · 설명 · 사용여부** 4개만 노출한다.
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
| -------- | --------- | ---- | ------ | ----------------- | ------------------------------- |
| 코드 | Input | Y | — | `cnstpSeCd` | 등록 시 입력, 수정 시 읽기 전용 |
| 코드명 | Input | N | — | `cnstpSeCdNm` | 최대 100자 |
| 설명 | Input | N | — | `expln` | 최대 1000자 |
| 사용여부 | Radio | N | Y | `useYn` | `Y`→사용, `N`→미사용 |
> UI에 노출하지 않는 API 필드(`upCnstpCd`, `cdLvNo`): 등록 시 `cdLvNo: 1` 등 기본값 전송, 수정 시 `findById` 응답값 유지.
| 버튼명 | 동작 |
| ------ | ------------------------------------------------ |
| 저장 | POST(등록) 또는 PUT(수정) 후 `findAll` 목록 갱신 |
| 취소 | 다이얼로그 닫기, 입력값 폐기 |
| 닫기 | 다이얼로그 닫기 |
---
## 5. 영역 B: 회계코드 관리
백엔드 **회계구분관리**(`AcntgSeMng`) CRUD. 영역 A와 동일한 **평면 목록** UI 패턴을 따른다.
### 5-1. 헤더 / 툴바
| 항목 | 내용 |
| ----------- | ------------------------------------- |
| 섹션 타이틀 | 회계코드 관리 |
| 우측 버튼 | **[등록]** — 회계코드 등록 다이얼로그 |
### 5-2. 그리드 컬럼
| 컬럼명 | Field | 타입 | 비고 |
| -------- | ------------- | ------ | ------------------------ |
| 코드 | `acntgSeCd` | string | PK, 최대 10자 |
| 코드명 | `acntgSeCdNm` | string | 최대 100자 |
| 설명 | `expln` | string | 최대 1000자, 선택 |
| 사용여부 | `useYn` | string | `Y`→사용, `N`→미사용 |
| 관리 | — | — | 행별 **[수정] · [삭제]** |
- 페이지네이션: 영역 A와 동일 — **UI 노출**, 기본 `size: 10`.
### 5-3. 버튼
| 버튼명 | 위치 | 동작 |
| ------ | -------------- | ---------------------------------------- |
| 등록 | 섹션 우측 상단 | 회계코드 등록 다이얼로그 오픈 |
| 수정 | 행별 | 상세 조회 후 수정 다이얼로그 (값 초기화) |
| 삭제 | 행별 | 확인 후 삭제, 목록 갱신 |
### 5-4. 다이얼로그: 회계코드 등록 / 수정
다이얼로그 입력 필드는 **코드 · 코드명 · 설명 · 사용여부** 4개만 노출한다. (영역 A와 동일 패턴)
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
| -------- | --------- | ---- | ------ | ----------------- | ------------------------------- |
| 코드 | Input | Y | — | `acntgSeCd` | 등록 시 입력, 수정 시 읽기 전용 |
| 코드명 | Input | Y | — | `acntgSeCdNm` | 최대 100자 |
| 설명 | Input | N | — | `expln` | 최대 1000자 |
| 사용여부 | Radio | N | Y | `useYn` | `Y`→사용, `N`→미사용 |
> UI에 노출하지 않는 API 필드(`upAcntgSeCd`, `cdLvNo`): 등록 시 `cdLvNo: 1` 등 기본값 전송, 수정 시 `findById` 응답값 유지.
| 버튼명 | 동작 |
| ------ | ------------------------------------------------ |
| 저장 | POST(등록) 또는 PUT(수정) 후 `findAll` 목록 갱신 |
| 취소 | 다이얼로그 닫기, 입력값 폐기 |
| 닫기 | 다이얼로그 닫기 |
---
## 6. API/DTO 명세
### 6-1. 공통
| 항목 | 내용 |
| ----------- | ---------------------------------------------------------------------------------- |
| 서비스 | `as-service` |
| Base prefix | `/as-service/api/v1` (`ENDPOINT.AS_COMMON_SERVICE`) |
| 인증 | JWT (`Authorization` 헤더), `X-Site-Id`, `token-id` |
| 페이징 | Spring `Pageable`: `page`, `size`, `sort` → `PageResponse<T>` |
| 엔티티 | `@/entities/cnstp` (`cnstpSeMngService`), `@/entities/acntg` (`acntgSeMngService`) |
**부가 API (본 화면 목록 외 참조용)**
| 리소스 | Method | URL | Response | 용도 |
| ------------ | ------ | --------------------- | --------------------- | --------------------------------- |
| 공종구분코드 | GET | `/cnstp-se/list/tree` | `CnstpSeMngTreeDto[]` | 계층 트리 (타 화면·상위코드 선택) |
| 회계구분관리 | GET | `/acntg-se/list/tree` | `AcntgSeMngTreeDto[]` | 계층 트리 (타 화면·상위코드 선택) |
### 6-2. 공종구분코드 (영역 A) — `cnstp-se`
| 기능 | Method | URL | Request / Query | Response | 클라이언트 |
| --------- | ------ | ----------------------- | -------------------------------- | ----------------------------- | ---------------------------- |
| 목록 조회 | GET | `/cnstp-se` | `CnstpSeMngSearchDto` + Pageable | `Page<CnstpSeMngResponseDto>` | `cnstpSeMngService.findAll` |
| 트리 조회 | GET | `/cnstp-se/list/tree` | — | `CnstpSeMngTreeDto[]` | `cnstpSeMngService.findTree` |
| 상세 조회 | GET | `/cnstp-se/{cnstpSeCd}` | — | `CnstpSeMngResponseDto` | `cnstpSeMngService.findById` |
| 등록 | POST | `/cnstp-se` | `CnstpSeMngSaveRequestDto` | `string` (cnstpSeCd) | `cnstpSeMngService.save` |
| 수정 | PUT | `/cnstp-se/{cnstpSeCd}` | `CnstpSeMngSaveRequestDto` | `string` (cnstpSeCd) | `cnstpSeMngService.update` |
| 삭제 | DELETE | `/cnstp-se/{cnstpSeCd}` | — | `string` (cnstpSeCd) | `cnstpSeMngService.delete` |
**CnstpSeMngResponseDto**
| Field | 타입 | 설명 |
| ------------- | ------- | ------------------ |
| `cnstpSeCd` | string | 공종구분코드 |
| `cnstpSeCdNm` | string | 공종구분코드명 |
| `upCnstpCd` | string? | 상위공종코드 |
| `cdLvNo` | number | 코드레벨번호 |
| `useYn` | string | 사용여부 |
| `expln` | string? | 설명 (최대 1000자) |
| `rgtr` | string? | 등록자 UUID |
| `rgtrNm` | string? | 등록자명 |
| `mdfr` | string? | 수정자 UUID |
| `regDt` | string? | 등록일시 |
| `mdfcnDt` | string? | 수정일시 |
**CnstpSeMngSaveRequestDto**: `cnstpSeCd`, `cnstpSeCdNm?`, `upCnstpCd?`, `cdLvNo?`, `useYn?`, `expln?`
**CnstpSeMngSearchDto**: `cnstpSeCd?`, `cnstpSeCdNm?`, `useYn?` + `page`, `size`, `sort`
**React Query hooks**: `useCnstpSeMngPage`, `useCnstpSeMngTree`, `useCnstpSeMng`, `useSaveCnstpSeMng`, `useUpdateCnstpSeMng`, `useDeleteCnstpSeMng`
### 6-3. 회계구분관리 (영역 B) — `acntg-se`
| 기능 | Method | URL | Request / Query | Response | 클라이언트 |
| --------- | ------ | ----------------------- | -------------------------------- | ----------------------------- | ---------------------------- |
| 목록 조회 | GET | `/acntg-se` | `AcntgSeMngSearchDto` + Pageable | `Page<AcntgSeMngResponseDto>` | `acntgSeMngService.findAll` |
| 트리 조회 | GET | `/acntg-se/list/tree` | — | `AcntgSeMngTreeDto[]` | `acntgSeMngService.findTree` |
| 상세 조회 | GET | `/acntg-se/{acntgSeCd}` | — | `AcntgSeMngResponseDto` | `acntgSeMngService.findById` |
| 등록 | POST | `/acntg-se` | `AcntgSeMngSaveRequestDto` | `string` (acntgSeCd) | `acntgSeMngService.save` |
| 수정 | PUT | `/acntg-se/{acntgSeCd}` | `AcntgSeMngSaveRequestDto` | `string` (acntgSeCd) | `acntgSeMngService.update` |
| 삭제 | DELETE | `/acntg-se/{acntgSeCd}` | — | `string` (acntgSeCd) | `acntgSeMngService.delete` |
**AcntgSeMngResponseDto**
| Field | 타입 | 설명 |
| ------------- | ------- | ------------------ |
| `acntgSeCd` | string | 회계구분코드 |
| `acntgSeCdNm` | string | 회계구분코드명 |
| `upAcntgSeCd` | string? | 상위회계구분코드 |
| `cdLvNo` | number | 코드레벨번호 |
| `useYn` | string | 사용여부 |
| `expln` | string? | 설명 (최대 1000자) |
| `rgtr` | string? | 등록자 UUID |
| `rgtrNm` | string? | 등록자명 |
| `mdfr` | string? | 수정자 UUID |
| `regDt` | string? | 등록일시 |
| `mdfcnDt` | string? | 수정일시 |
**AcntgSeMngSaveRequestDto**: `acntgSeCd`, `acntgSeCdNm`, `upAcntgSeCd?`, `cdLvNo`, `useYn?`, `expln?`
**AcntgSeMngSearchDto**: `acntgSeCd?`, `acntgSeCdNm?`, `useYn?` + `page`, `size`, `sort`
**React Query hooks**: `useAcntgSeMngPage`, `useAcntgSeMngTree`, `useAcntgSeMng`, `useSaveAcntgSeMng`, `useUpdateAcntgSeMng`, `useDeleteAcntgSeMng`
---
## 7. UI 구현 가이드
### 7-1. 기술 스택·원칙
- Next.js App Router, React Query (`@tanstack/react-query`)
- `@common/components` — `Card`, `Button`, `Dialog`, `DataTable` / `useDataTable`
- `@tanstack/react-table` — 컬럼 정의는 `model/*-table-columns.tsx` 분리
- 기존 entity 레이어 재사용: `@/entities/cnstp`, `@/entities/acntg` (service·queries·interface 구현 완료)
- 좌·우 2열 레이아웃: `grid grid-cols-1 lg:grid-cols-2 gap-4` (모바일 1열 스택)
- 삭제 전 `confirm` / Toast 피드백 — 프로젝트 공통 패턴 준수
- 목록 하단 **페이지네이션 UI 노출** — 영역 A·B 각각 독립 page state
- **검색 필터 UI 미제공** — `findAll` 호출 시 `page`, `size`만 전달
- **등록/수정 다이얼로그** — 코드·코드명·설명은 `Input`, 사용여부는 `Radio` (4필드만 노출)
- 상위 코드 삭제 시 **하위 코드 연쇄 삭제 UI·로직 미구현** — 단건 삭제만 수행
### 7-2. 모듈·파일 배치
| 구분 | 경로 (제안) |
| ----------------- | -------------------------------------------------------------------- |
| 라우트 | `apps/as/src/app/(authenticated)/site/code-management/page.tsx` |
| 페이지 컴포넌트 | `apps/as/src/pages/code-management/ui/code-management-page.tsx` |
| 공종 그리드 컬럼 | `apps/as/src/pages/code-management/model/cnstp-se-table-columns.tsx` |
| 회계 그리드 컬럼 | `apps/as/src/pages/code-management/model/acntg-se-table-columns.tsx` |
| 공종 등록/수정 폼 | `apps/as/src/pages/code-management/ui/cnstp-se-form-dialog.tsx` |
| 회계 등록/수정 폼 | `apps/as/src/pages/code-management/ui/acntg-se-form-dialog.tsx` |
| API·타입 (기존) | `@/entities/cnstp`, `@/entities/acntg` |
### 7-3. 영역별 UI 매핑
| 영역 | UI 컴포넌트 | 데이터 소스 | 비고 |
| ------------ | ------------------------------------- | ------------------------------------------- | ------------------------------ |
| A | `CnstpSeSection` + Table + Pagination | `useCnstpSeMngPage({ page, size })` | 독립 page state·mutation |
| B | `AcntgSeSection` + Table + Pagination | `useAcntgSeMngPage({ page, size })` | A와 상호 의존 없음 |
| A 다이얼로그 | `CnstpSeFormDialog` | `useCnstpSeMng`, `useSave/UpdateCnstpSeMng` | Input 4필드 + Radio 사용여부 |
| B 다이얼로그 | `AcntgSeFormDialog` | `useAcntgSeMng`, `useSave/UpdateAcntgSeMng` | Input 4필드 + Radio 사용여부 |
### 7-4. 다이얼로그·라우팅
| 다이얼로그 | 트리거 | 라우팅 |
| ------------- | ---------------- | -------------------- |
| 공종코드 등록 | 영역 A [등록] | URL 변경 없음 (모달) |
| 공종코드 수정 | 영역 A 행 [수정] | URL 변경 없음 (모달) |
| 회계코드 등록 | 영역 B [등록] | URL 변경 없음 (모달) |
| 회계코드 수정 | 영역 B 행 [수정] | URL 변경 없음 (모달) |
### 7-5. 데이터 흐름 (구현 관점)
```
BEGIN
// 페이지 마운트 — 검색 조건 없이 page·size만 전달
const cnstpQuery = useCnstpSeMngPage({ page: 0, size: 20 })
const acntgQuery = useAcntgSeMngPage({ page: 0, size: 20 })
DISPLAY CodeManagementPage
├─ CnstpSeSection(data=cnstpQuery.data, pagination)
└─ AcntgSeSection(data=acntgQuery.data, pagination)
// 페이지 변경
ON pagination page/size CHANGE DO
refetch cnstpQuery | acntgQuery with new page, size
END ON
// mutation 성공 시
ON save/update/delete SUCCESS DO
invalidateQueries cnstp-se | acntg-se baseKey
TOAST "저장되었습니다." | "삭제되었습니다."
END ON
END
```
@@ -0,0 +1,284 @@
# 화면설계서: 필수첨부파일관리
## 1. 기본 정보
| 항목 | 내용 |
| ---------- | ---------------------------------------------- |
| 버전 | 0.1 |
| 프로세스명 | UI설계 |
| 화면명 | 필수첨부파일관리 |
| 화면 유형 | [v] 조회 [v] 입력 [v] 수정 [v] 삭제 [ ] 출력 |
| 메뉴 경로 | 디지털행정지원 > 사이트관리 > 필수첨부파일관리 |
---
## 2. 화면 목적
문서(업무) 구분별로 **필수 첨부파일** 항목을 등록·조회·수정·삭제한다. 상단 검색 영역에서 **문서구분**으로 목록을 필터링하고, **등록** 또는 행별 **수정**·**삭제**로 마스터 데이터를 유지한다. 등록·수정 시 **필수첨부파일아이디**는 클라이언트에서 입력(또는 생성)하여 요청 본문에 포함한다.
---
## 3. 화면 구성
### 3-1. 전체 레이아웃
```
┌──────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 페이지 타이틀: 필수첨부파일관리 브레드크럼: 디지털행정지원 > … > │
├──────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ [영역 A] 검색 / 액션 │
│ ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ 문서구분 [전체 ▼] [조회] [등록] │ │
│ └────────────────────────────────────────────────────────────────────────────────────────────────┘ │
├──────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ [영역 B] 필수첨부파일 목록 │
│ ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ 필수첨부파일아이디 | 문서구분 | 필수첨부파일명 | 필수첨부여부 | 비고 | 등록일시 | 수정일시 | 관리 │
│ ├────────────────────────────────────────────────────────────────────────────────────────────────┤ │
│ │ EAF-0001 | 설치인가 | 사업계획서 | 예 | 서명본 첨부 | 2026-04-22 10:10:00 | — | [수정][삭제] │ │
│ │ EAF-0002 | 기본계획 | 변경사유서 | 예 | — | 2026-04-22 11:00:00 | — | [수정][삭제] │ │
│ └────────────────────────────────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────────────────────────────┘
(모달) [다이얼로그 C] 필수첨부파일 등록 / 수정
┌──────────────────────────────────────────────────────┐
│ 필수첨부파일 등록 [닫기] │
├──────────────────────────────────────────────────────┤
│ 필수첨부파일아이디(자동발급) [EAF-0004] │
│ 문서구분 [설치인가 ▼] │
│ 필수첨부파일명 [예: 사업계획서] │
│ 필수첨부여부 [ ] 예 [ ] 아니오 │
│ 비고 [비고를 입력하세요] │
├──────────────────────────────────────────────────────┤
│ [취소] [저장] │
└──────────────────────────────────────────────────────┘
```
### 3-2. 화면 데이터 흐름
```
BEGIN
// 최초 진입
CALL 필수첨부파일 목록 조회 (findPage)
DISPLAY 필수첨부파일 그리드 (영역 B)
// 영역 A: 검색
ON [조회] 버튼 CLICK DO
CALL 필수첨부파일 목록 조회 (문서구분 = 선택값, findPage)
DISPLAY 필수첨부파일 그리드 (영역 B)
END ON
// 영역 A: 등록
ON [등록] 버튼 CLICK DO
OPEN 필수첨부파일 등록 다이얼로그 (다이얼로그 C)
ON [저장] DO
CONFIRM "등록하시겠습니까?"
VALIDATE 문서구분, 필수첨부파일명, 필수첨부여부
CALL 필수첨부파일 등록 (create)
CALL 필수첨부파일 목록 조회
CLOSE 다이얼로그
TOAST "저장되었습니다."
END ON
ON [취소] / [닫기] DO
CLOSE 다이얼로그
END ON
END ON
// 영역 B: 수정
ON 행 [수정] CLICK DO
OPEN 필수첨부파일 수정 다이얼로그 (기존 값 초기화)
ON [저장] DO
VALIDATE 문서구분, 필수첨부파일명, 필수첨부여부
CALL 필수첨부파일 수정 (update)
CALL 필수첨부파일 목록 조회
CLOSE 다이얼로그
END ON
END ON
// 영역 B: 삭제
ON 행 [삭제] CLICK DO
CONFIRM "삭제하시겠습니까?"
CALL 필수첨부파일 삭제 (remove)
CALL 필수첨부파일 목록 조회
END ON
END
```
---
## 4. 영역 A: 검색 / 액션
문서구분 조건으로 목록을 조회하고, 신규 등록 다이얼로그를 연다.
### 4-1. 검색 필드
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
| -------- | ---------- | ---- | ------ | ----------------- | ------------------------------ |
| 문서구분 | CodeSelect | N | 전체 | `docSeComCd?` | "전체" 선택 시 파라미터 미전송 |
### 4-2. 버튼
| 버튼명 | 위치 | 동작 |
| ------ | -------------- | ----------------------------------------------- |
| 조회 | 검색 필드 우측 | 현재 검색 조건으로 영역 B 목록 갱신 |
| 등록 | 영역 우측 끝 | 필수첨부파일 등록 다이얼로그(다이얼로그 C) 오픈 |
---
## 5. 영역 B: 필수첨부파일 목록
필수 첨부파일 마스터를 테이블로 표시한다. 페이지네이션 UI 적용.
### 5-1. 헤더
| 항목 | 내용 |
| ----------- | ----------------- |
| 섹션 타이틀 | 필수첨부파일 목록 |
### 5-2. 그리드 컬럼
| 컬럼명 | Field | 타입 | 비고 |
| ------------------ | ------------------- | ------ | ------------------------------------- |
| 필수첨부파일아이디 | `esntlAtchFileId` | string | 예: `EAF-0001`, 클라이언트 생성/입력 |
| 문서구분 | `docSeComCd` | string | 코드명 표시 — 공통코드 (TBD 상위코드) |
| 필수첨부파일명 | `esntlAtchFileNm` | string | |
| 필수첨부여부 | `esntlAtchYn` | string | `Y`→예, `N`→아니오 |
| 비고 | `esntlAtchFileRmrk` | string | 없으면 `—` 표시 |
| 등록일시 | `regDt` | string | `yyyy-MM-dd HH:mm:ss` |
| 수정일시 | `mdfcnDt` | string | 미수정 시 빈 값 또는 `—` |
| 관리 | — | — | 행별 **[수정] · [삭제]** |
- 행 클릭 시 별도 상세 화면 없음. 편집은 **[수정]** 버튼으로 다이얼로그 C를 연다.
### 5-3. 버튼
| 버튼명 | 위치 | 동작 |
| ------ | ---- | -------------------------------------- |
| 수정 | 행별 | 필수첨부파일 수정 다이얼로그 (기존 값) |
| 삭제 | 행별 | 확인 후 삭제 API 호출 → 목록 갱신 |
---
## 6. 다이얼로그 C: 필수첨부파일 등록 / 수정
등록·수정 공용 폼 다이얼로그. 등록 시 제목 **「필수첨부파일 등록」**, 수정 시 **「필수첨부파일 수정」**.
### 6-1. 폼 필드
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
| ------------------ | ---------- | ---- | ------ | ------------------- | -------------------------------- |
| 필수첨부파일아이디 | (Readonly) | Y | — | `esntlAtchFileId` | 등록 시 미노출 |
| 문서구분 | CodeSelect | Y | — | `docSeComCd` | 예: 설치인가, 기본계획 |
| 필수첨부파일명 | Text | Y | — | `esntlAtchFileNm` | placeholder: "예: 사업계획서" |
| 필수첨부여부 | RadioGroup | Y | Y | `esntlAtchYn` | Y/N, UI 표시: 예/아니오 |
| 비고 | Textarea | N | — | `esntlAtchFileRmrk` | placeholder: "비고를 입력하세요" |
### 6-2. 버튼
| 버튼명 | 동작 |
| ------ | -------------------------------------- |
| 저장 | POST(등록) 또는 PUT(수정) 후 목록 갱신 |
| 취소 | 다이얼로그 닫기, 입력값 폐기 |
| 닫기 | 다이얼로그 닫기 (헤더 우측) |
---
## 7. API/DTO 명세
### 7-1. 공통
| 항목 | 내용 |
| ----------- | ----------------------------------------------------- |
| 서비스 | AS Common Service |
| Base prefix | `/as-service/api/v1/esntl-atch-file` |
| 인증 | JWT (`Authorization`), `X-Site-Id` 등 |
| 페이징 | Spring `Pageable` 사용 (`page`, `size`, `sort`) |
| 엔티티 | `@/entities/esntl` (`esntl-service.ts`, `queries.ts`) |
### 7-2. 필수첨부파일 API
| 기능 | Method | URL | Request DTO | Response DTO | API (클라이언트 함수명) |
| --------- | ------ | -------------------- | ----------------------------- | ----------------------------------- | ----------------------- |
| 트리 조회 | GET | `/list/tree` | Query: `docSeComCd`(필수) | `EsntlAtchFileMngTreeResponseDto[]` | `findTree` |
| 목록 조회 | GET | `/` | Query: Search + Pageable | `PageResponse<EsntlAtchFileMng>` | `findPage` |
| 단건 조회 | GET | `/{esntlAtchFileId}` | Path: `esntlAtchFileId` | `EsntlAtchFileMng` | `findById` |
| 등록 | POST | `/` | `EsntlAtchFileMngSaveRequest` | `string` (생성된 ID) | `create` |
| 수정 | PUT | `/{esntlAtchFileId}` | `EsntlAtchFileMngSaveRequest` | `string` (수정된 ID) | `update` |
| 삭제 | DELETE | `/{esntlAtchFileId}` | Path: `esntlAtchFileId` | `string` (삭제된 ID) | `remove` |
**응답 DTO**
| Field | 타입 | 설명 |
| ------------------- | ------- | -------------------------------- |
| `esntlAtchFileId` | string | 필수첨부파일아이디 |
| `docSeComCd` | string | 문서구분공통코드 |
| `esntlAtchFileNm` | string | 필수첨부파일명 |
| `esntlAtchYn` | string | 필수첨부여부 (Y/N) |
| `esntlAtchFileRmrk` | string? | 필수첨부파일비고 |
| `rgtr` | string? | 등록자 UUID |
| `rgtrNm` | string? | 등록자명 |
| `mdfr` | string? | 수정자 UUID |
| `regDt` | string? | 등록일시 (`yyyy-MM-dd HH:mm:ss`) |
| `mdfcnDt` | string? | 수정일시 |
**저장 Request**
| Field | 필수 | 설명 |
| ------------------- | ---- | ------------------ |
| `esntlAtchFileId` | Y | 필수첨부파일아이디 |
| `docSeComCd` | N | 문서구분 |
| `esntlAtchFileNm` | N | 필수첨부파일명 |
| `esntlAtchYn` | N | 필수첨부여부 |
| `esntlAtchFileRmrk` | N | 비고 |
**검색 Query**
| Field | 필수 | 설명 |
| ----------------- | ---- | ----------------------------- |
| `docSeComCd` | N | 문서구분 (전체 시 미전송) |
| `esntlAtchFileNm` | N | 필수첨부파일명 부분/일치 검색 |
| `esntlAtchYn` | N | 필수첨부여부 (`Y`/`N`) |
| `page` | N | 페이지 번호 (0-base) |
| `size` | N | 페이지 크기 (기본값: 100) |
| `sort` | N | 정렬 조건 (예: `regDt,desc`) |
### 7-3. 공통코드
| UI 필드 | 코드 그룹 | 예시 값 |
| ------------ | --------- | ------------------ |
| 문서구분 | ASDOCSECD | 설치인가, 기본계획 |
| 필수첨부여부 | 고정 | Y(예), N(아니오) |
---
## 8. UI 구현 가이드
### 8-1. 기술 스택·원칙
- **공통 컴포넌트**: `@common/components` (`Dialog`, `Button`, `Input`, `Select`, `Textarea`, `Label` 등)
- **테이블**: `@tanstack/react-table` + `useDataTable`
- **폼**: `react-hook-form`
- **데이터**: React Query — `@/entities/esntl/api/queries.ts` 훅 사용
- **공통코드**: `@/entities/code` — `CodeSelect`, `findCodeLabel`
- **확인**: `@frontend-opensource/use-react-hooks` — `useConfirm` (등록·삭제 확인)
### 8-2. 모듈·파일 배치
| 구분 | 경로 |
| ------ | ---------------------------------------------------------------------------------------------------- |
| 라우트 | `apps/as/app/(with-base-layout)/esntl-atch-file-mng/page.tsx` |
| 페이지 | `apps/as/src/pages/esntl-atch-file-mng/` (Public API: page만 export) |
| UI | `.../ui/esntl-atch-file-mng-page.tsx`, `esntl-atch-file-list.tsx`, `esntl-atch-file-form-dialog.tsx` |
| model | `.../model/esntl-atch-file-columns.tsx`, `esntl-atch-file-search-params.ts` |
| 엔티티 | `apps/as/src/entities/esntl/` (`esntl-service.ts`, `queries.ts`, `esntl-interface.ts`) |
| 문서 | `apps/as/docs/essential-attachment-file-management/SPEC.md` |
### 8-3. 데이터 흐름(요약)
- **진입**: 검색 params 기본값(`docSeComCd` = 전체) → 목록 query → 영역 B DataTable.
- **조회**: [조회] 클릭 시 search state 반영 → 목록 query refetch.
- **등록**: [등록] → Dialog `mode=create` → [저장] → POST → 목록 invalidate.
- **수정**: [수정] → Dialog `mode=edit` + row 초기값 → [저장] → PUT → 목록 invalidate.
- **삭제**: confirm → DELETE → 목록 invalidate.
@@ -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<T>` |
| 프론트 엔티티 | `@/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<RvwMnpwMngResponseDto>` | `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)
+24 -2
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { PORT, PROXY_HOST, SERVER_API_URL, SITE_ID } from "@/shared/config";
import { AUTH_USER_ID, CLAIM_NAME, PORT, PROXY_HOST, SERVER_API_URL, SITE_ID } from "@/shared/config";
const serverApiUrl = process.env.SERVER_API_URL;
@@ -34,6 +34,28 @@ export function middleware(req: NextRequest) {
return addSecurityHeaders(response);
}
// tus-js-client PATCH 요청: Location 헤더로 /server 없이 반환된 경로 직접 처리
// (예: /attach-service/api/v1/tus/upload/{id})
if (pathname.startsWith("/attach-service")) {
const requestHeaders = new Headers(req.headers);
const token = req.cookies.get(CLAIM_NAME)?.value;
const userId = req.cookies.get(AUTH_USER_ID)?.value;
const newUrl = `${serverApiUrl}${pathname}`;
if (token) requestHeaders.set(CLAIM_NAME, token);
if (userId) requestHeaders.set(AUTH_USER_ID, userId);
logMessages.push(` [TUS Proxy] Rewritten URL: ${newUrl}`);
logMessages.push("\n--------------------------------\n");
console.log(logMessages.join("\n"));
const response = NextResponse.rewrite(new URL(newUrl), {
headers: requestHeaders,
});
return addSecurityHeaders(response);
}
// if (pathname === "/") {
// const response = NextResponse.redirect(new URL("/dashboard", req.url));
// return addSecurityHeaders(response);
@@ -47,5 +69,5 @@ export function middleware(req: NextRequest) {
}
export const config = {
matcher: ["/server/:path*", "/"],
matcher: ["/server/:path*", "/attach-service/:path*", "/"],
};
-6
View File
@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+4 -1
View File
@@ -11,12 +11,14 @@
},
"dependencies": {
"@common/assets": "workspace:*",
"@common/auth": "workspace:*",
"@common/components": "workspace:*",
"@common/network": "workspace:*",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@faker-js/faker": "^9.9.0",
"@frontend-opensource/use-react-hooks": "^1.1.4",
"@hookform/resolvers": "^4.1.3",
"@tanstack/react-query": "^5.75.5",
"@tanstack/react-query-devtools": "^5.75.5",
@@ -25,8 +27,9 @@
"dayjs": "^1.11.13",
"js-cookie": "^3.0.5",
"lucide-react": "^0.474.0",
"next": "15.3.8",
"next": "15.5.18",
"react-hook-form": "^7.54.2",
"tus-js-client": "^4.3.1",
"zod": "^3.24.2"
},
"devDependencies": {
+9 -4
View File
@@ -1,11 +1,13 @@
"use client";
import { ConfirmProvider } from "@frontend-opensource/use-react-hooks";
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { queryClient } from "@/shared/lib";
import { StoreProvider } from "@/shared/providers";
import { AlertDialog } from "@/shared/ui/alert-dialog";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { AuthWrapper } from "@/entities/auth";
import { MenuWrapper } from "@/entities/menu";
@@ -17,10 +19,13 @@ export function Providers({ children }: ProvidersProps) {
return (
<QueryClientProvider client={queryClient}>
<StoreProvider>
<AuthWrapper>
<MenuWrapper>{children}</MenuWrapper>
<AlertDialog />
</AuthWrapper>
<ConfirmProvider>
<AuthWrapper>
<MenuWrapper>{children}</MenuWrapper>
<AlertDialog />
<ConfirmDialog />
</AuthWrapper>
</ConfirmProvider>
</StoreProvider>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
@@ -0,0 +1,58 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { PageResponse } from "@/shared/model";
import {
ACNTG_SE_SAVE_MODE,
type AcntgSeMngPutRequestDto,
type AcntgSeMngResponseDto,
type AcntgSeMngSaveRequestDto,
type AcntgSeMngSearchDto,
type AcntgSeMngService,
type AcntgSeMngTreeDto,
} from "../model/acntg-interface";
const BASE = ENDPOINT.AS_COMMON_SERVICE.ACNTG_SE;
async function findAll(params: AcntgSeMngSearchDto): Promise<PageResponse<AcntgSeMngResponseDto>> {
const response = await client.get<PageResponse<AcntgSeMngResponseDto>>(BASE, { params });
return response.data;
}
async function findTree(): Promise<AcntgSeMngTreeDto[]> {
const response = await client.get<AcntgSeMngTreeDto[]>(`${BASE}/list/tree`);
return response.data;
}
async function findById(acntgSeCd: string): Promise<AcntgSeMngResponseDto> {
const response = await client.get<AcntgSeMngResponseDto>(`${BASE}/${acntgSeCd}`);
return response.data;
}
async function saveMulti(body: AcntgSeMngSaveRequestDto[]): Promise<AcntgSeMngResponseDto[]> {
const response = await client.post<AcntgSeMngResponseDto[]>(BASE, body);
return response.data;
}
async function update(acntgSeCd: string, body: AcntgSeMngPutRequestDto): Promise<string> {
const response = await client.put<string>(`${BASE}/${acntgSeCd}`, {
...body,
acntgSeCd,
mode: ACNTG_SE_SAVE_MODE.UPDATE,
});
return response.data;
}
async function deleteAcntgSeCd(acntgSeCd: string): Promise<string> {
const response = await client.delete<string>(`${BASE}/${acntgSeCd}`);
return response.data;
}
export const acntgSeMngService = {
findAll,
findTree,
findById,
saveMulti,
update,
delete: deleteAcntgSeCd,
} satisfies AcntgSeMngService;
+73
View File
@@ -0,0 +1,73 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import type {
AcntgSeMngPutRequestDto,
AcntgSeMngSaveRequestDto,
AcntgSeMngSearchDto,
} from "../model/acntg-interface";
import { acntgSeMngService } from "./acntg-service";
const acntgSeMngBaseKey = [ENDPOINT.AS_COMMON_SERVICE.ACNTG_SE] as const;
export function useAcntgSeMngPage(params: AcntgSeMngSearchDto) {
return useQuery({
queryKey: [...acntgSeMngBaseKey, "findAll", params],
queryFn: () => acntgSeMngService.findAll(params),
});
}
export function useAcntgSeMngTree() {
return useQuery({
queryKey: [...acntgSeMngBaseKey, "findTree"],
queryFn: () => acntgSeMngService.findTree(),
staleTime: Infinity,
});
}
export function useAcntgSeMng(acntgSeCd: string | null | undefined) {
return useQuery({
queryKey: [...acntgSeMngBaseKey, "findById", acntgSeCd],
queryFn: () => acntgSeMngService.findById(acntgSeCd!),
enabled: Boolean(acntgSeCd),
});
}
export function useSaveMultiAcntgSeMng() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: AcntgSeMngSaveRequestDto[]) => acntgSeMngService.saveMulti(body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: acntgSeMngBaseKey });
},
});
}
export function useUpdateAcntgSeMng() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ acntgSeCd, body }: { acntgSeCd: string; body: AcntgSeMngPutRequestDto }) =>
acntgSeMngService.update(acntgSeCd, body),
onSuccess: (_data, { acntgSeCd }) => {
queryClient.invalidateQueries({ queryKey: acntgSeMngBaseKey });
queryClient.invalidateQueries({
queryKey: [...acntgSeMngBaseKey, "findById", acntgSeCd],
});
},
});
}
export function useDeleteAcntgSeMng() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (acntgSeCd: string) => acntgSeMngService.delete(acntgSeCd),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: acntgSeMngBaseKey });
},
});
}
+19
View File
@@ -0,0 +1,19 @@
export { acntgSeMngService } from "./api/acntg-service";
export {
useAcntgSeMng,
useAcntgSeMngPage,
useAcntgSeMngTree,
useDeleteAcntgSeMng,
useSaveMultiAcntgSeMng,
useUpdateAcntgSeMng,
} from "./api/queries";
export type {
AcntgSeMngResponseDto,
AcntgSeMngSaveRequestDto,
AcntgSeMngSearchDto,
AcntgSeMngService,
AcntgSeMngTreeDto,
AcntgSeSaveMode,
BizCstTreeDto,
} from "./model/acntg-interface";
export { ACNTG_SE_SAVE_MODE } from "./model/acntg-interface";
@@ -0,0 +1,86 @@
import type { PageableParams, PageResponse } from "@/shared/model";
export const ACNTG_SE_SAVE_MODE = {
CREATE: "C",
UPDATE: "U",
DELETE: "D",
} as const;
export type AcntgSeSaveMode = (typeof ACNTG_SE_SAVE_MODE)[keyof typeof ACNTG_SE_SAVE_MODE];
/** 백엔드 AcntgSeMngResponseDto */
export interface AcntgSeMngResponseDto {
acntgSeCd: string;
acntgSeCdNm: string;
upAcntgSeCd?: string;
cdLvNo: number;
useYn: string;
expln?: string;
rgtr?: string;
rgtrNm?: string;
mdfr?: string;
regDt?: string;
mdfcnDt?: string;
}
/** 백엔드 AcntgSeMngSearchDto + Pageable */
export interface AcntgSeMngSearchDto extends PageableParams {
acntgSeCd?: string;
acntgSeCdNm?: string;
useYn?: string;
}
/** 백엔드 AcntgSeMngSaveRequestDto */
export interface AcntgSeMngSaveRequestDto {
acntgSeCd: string;
mode: AcntgSeSaveMode;
acntgSeCdNm: string;
upAcntgSeCd?: string;
cdLvNo: number;
useYn?: string;
expln?: string;
}
/** PUT /{acntgSeCd} 요청 (mode는 서비스에서 U로 설정) */
export interface AcntgSeMngPutRequestDto {
acntgSeCd: string;
acntgSeCdNm: string;
upAcntgSeCd?: string;
cdLvNo: number;
useYn?: string;
expln?: string;
}
/** 백엔드 AcntgSeMngTreeDto */
export interface AcntgSeMngTreeDto {
indAcntgSeCdNm: string;
acntgSeCd: string;
acntgSeCdNm: string;
upAcntgSeCd?: string;
useYn: string;
treeLevel: number;
treePath: string;
}
/** 백엔드 BizCstTreeDto (InstlSncnMngDetResponseDto 등에서 참조) */
export interface BizCstTreeDto {
indAcntgSeCdNm: string;
acntgSeCd: string;
acntgSeCdNm: string;
useYn: string;
treeLevel: number;
treePath: string;
bscPlanAmt?: number;
bgtRfltAmt?: number;
tstmDesignAmt?: number;
}
/** Controller public 메서드명·시그니처와 동일 */
export interface AcntgSeMngService {
findAll(params: AcntgSeMngSearchDto): Promise<PageResponse<AcntgSeMngResponseDto>>;
findTree(): Promise<AcntgSeMngTreeDto[]>;
findById(acntgSeCd: string): Promise<AcntgSeMngResponseDto>;
saveMulti(body: AcntgSeMngSaveRequestDto[]): Promise<AcntgSeMngResponseDto[]>;
update(acntgSeCd: string, body: AcntgSeMngPutRequestDto): Promise<string>;
delete(acntgSeCd: string): Promise<string>;
}
@@ -0,0 +1,79 @@
import { ACCESS_TOKEN, AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "@/shared/config";
import { getRemainingMs } from "@common/auth";
import { clearAuthToken, setToken, setupRefreshTimer } from "../lib/token-manager";
import type { AuthPayload, AuthService } from "../model/auth-interface";
async function login(payload: AuthPayload): Promise<void> {
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await response.json();
const accessToken = data[ACCESS_TOKEN];
const tokenId = data[AUTH_USER_ID];
if (!accessToken || !tokenId) {
throw new Error("인증 토큰을 받지 못했습니다.");
}
setToken(CLAIM_NAME, accessToken);
setToken(AUTH_USER_ID, tokenId);
const remainingMs = getRemainingMs(accessToken);
if (remainingMs > 0) {
setupRefreshTimer(silentRefresh, remainingMs);
}
}
async function silentRefresh(): Promise<void> {
console.log(`[silentRefresh] Called at ${new Date().toISOString()}`);
try {
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`, {
method: "PUT",
});
const data = await response.json();
if (!response.ok) {
throw new Error(data?.message ?? "토큰 갱신에 실패했습니다.");
}
const accessToken = data[ACCESS_TOKEN];
const tokenId = data[AUTH_USER_ID];
if (!accessToken || !tokenId) {
throw new Error("갱신된 인증 토큰을 받지 못했습니다.");
}
setToken(CLAIM_NAME, accessToken);
setToken(AUTH_USER_ID, tokenId);
const remainingMs = getRemainingMs(accessToken);
if (remainingMs > 0) {
setupRefreshTimer(silentRefresh, remainingMs);
}
} catch (error) {
console.error(`[silentRefresh] Failed at ${new Date().toISOString()}`, error);
clearAuthToken();
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("auth:logout"));
}
}
}
async function logout(): Promise<void> {
clearAuthToken();
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("auth:logout"));
}
}
export const authService: AuthService = {
login,
silentRefresh,
logout,
};
@@ -1,92 +0,0 @@
import { AxiosError } from "axios";
import {
ACCESS_TOKEN,
AUTH_USER_ID,
CLAIM_NAME,
ENDPOINT,
JWT_EXPIRATION_TIME,
} from "@/shared/config";
import { tokenManager } from "../lib/token-manager";
import type { ILogin, LoginService } from "../model/login-interface";
export class LoginApiService implements LoginService {
private static instance: LoginApiService;
private constructor() {}
public static getInstance(): LoginApiService {
if (!LoginApiService.instance) {
LoginApiService.instance = new LoginApiService();
}
return LoginApiService.instance;
}
public async login(payload: ILogin): Promise<void> {
try {
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
method: "POST",
body: JSON.stringify({
userId: payload.userId,
password: payload.password,
}),
});
const data = await response.json();
console.log("data ", data);
const accessToken = data[ACCESS_TOKEN];
const tokenId = data[AUTH_USER_ID];
if (!accessToken || !tokenId) {
if (data.message == "Invalid Credentials") {
throw new Error("아이디와 비밀번호가 일치하지 않습니다.");
} else if (data.message == "User not found") {
throw new Error("존재하지 않는 아이디입니다.");
} else if (data.message == "User is not active") {
throw new Error("활성화되지 않은 계정입니다.");
} else if (data.message == "User is not verified") {
throw new Error("이메일 인증이 필요합니다.");
} else {
throw new Error(data.message);
}
}
tokenManager.setToken(CLAIM_NAME, accessToken);
tokenManager.setToken(AUTH_USER_ID, tokenId);
tokenManager.setupRefreshTimer(() => this.silentRefresh(), JWT_EXPIRATION_TIME);
} catch (error) {
if (error instanceof AxiosError) {
throw new Error(`Login 실패 ${error.response?.data?.message}`);
}
throw new Error(`Login 실패 ${error}`);
}
}
public async silentRefresh(): Promise<void> {
try {
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`, {
method: "PUT",
});
const data = await response.json();
const accessToken = data[ACCESS_TOKEN];
const tokenId = data[AUTH_USER_ID];
if (!accessToken || !tokenId) {
throw new Error("Missing required authentication tokens");
}
tokenManager.setToken(CLAIM_NAME, accessToken);
tokenManager.setToken(AUTH_USER_ID, tokenId);
} catch (error) {
tokenManager.clearAuthToken();
if (error instanceof AxiosError) {
throw new Error(`Silent refresh failed: ${error.response?.data?.message}`);
}
throw new Error(`Silent refresh failed: ${error}`);
}
}
}
export const LoginApi = LoginApiService.getInstance();
+2 -1
View File
@@ -1,2 +1,3 @@
export { LoginApi } from "./api/login-service";
export { authService } from "./api/auth-service";
export { AuthWrapper } from "./lib/auth-wrapper";
export { tokenManager } from "./lib/token-manager";
+56 -27
View File
@@ -1,42 +1,71 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { isAxiosError } from "axios";
import { client } from "@/shared/api";
import { AUTH_LOGIN_REDIRECT_URL, AUTH_USER_ID, ENDPOINT } from "@/shared/config";
import { getCookie, useUserStore } from "@/shared/lib";
import { AUTH_LOGIN_REDIRECT_URL, AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "@/shared/config";
import { useUserStore } from "@/shared/lib";
import type { IUserInfo } from "@/shared/store";
import { getRemainingMs } from "@common/auth";
import { isAtLoginRedirectUrl } from "@common/network";
import { authService } from "../api/auth-service";
import { getToken, setupRefreshTimer } from "./token-manager";
const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
const { userInfo, setUserInfo } = useUserStore((state) => state);
async function authCheck() {
if (isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) {
return;
}
const userId = getCookie(AUTH_USER_ID);
if (!userId) {
throw new Error("Missing required authentication tokens");
}
const response = await client.get<IUserInfo>(`${ENDPOINT.USER_SERVICE.USERS}/info/me`);
if (!userInfo) setUserInfo(response.data);
}
const router = useRouter();
const userInfo = useUserStore((state) => state.userInfo);
const setUserInfo = useUserStore((state) => state.setUserInfo);
useEffect(() => {
(async () => {
try {
await authCheck();
} catch (error) {
console.error(error);
setUserInfo(null);
if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) {
window.location.assign(AUTH_LOGIN_REDIRECT_URL);
}
const token = getToken(CLAIM_NAME);
if (token) {
const remainingMs = getRemainingMs(token);
if (remainingMs > 0) {
console.log(`[AuthWrapper] Refresh timer set: ${remainingMs}ms (${remainingMs / 1000}s)`);
setupRefreshTimer(authService.silentRefresh, remainingMs);
}
})();
}
if (isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) return;
async function authCheck() {
const userId = getToken(AUTH_USER_ID);
if (!userId) {
router.replace(AUTH_LOGIN_REDIRECT_URL);
return;
}
if (userInfo) return;
const response = await client.get<IUserInfo>(`${ENDPOINT.USER_SERVICE.USERS}/info/me`);
setUserInfo(response.data);
}
authCheck().catch((error) => {
if (isAxiosError(error) && error.response?.status === 401) return;
console.error(error);
setUserInfo(null);
if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) {
router.replace(AUTH_LOGIN_REDIRECT_URL);
}
});
}, []);
useEffect(() => {
function handleAuthLogout() {
setUserInfo(null);
if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) {
router.replace(AUTH_LOGIN_REDIRECT_URL);
}
}
window.addEventListener("auth:logout", handleAuthLogout);
return () => window.removeEventListener("auth:logout", handleAuthLogout);
}, []);
return <>{children}</>;
+13 -80
View File
@@ -1,82 +1,15 @@
import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config";
import { getCookie, removeCookie,setCookie } from "@/shared/lib";
import { authConfig } from "@/shared/config/auth";
import { createTokenManager } from "@common/auth";
export class TokenManager {
private static instance: TokenManager;
private refreshTimer: NodeJS.Timeout | null = null;
export const tokenManager = createTokenManager(authConfig);
private constructor() {}
public static getInstance(): TokenManager {
if (!TokenManager.instance) {
TokenManager.instance = new TokenManager();
}
return TokenManager.instance;
}
public getToken(key: string): string | undefined {
return getCookie(key);
}
public setToken(key: string, value: string): void {
const domain = this.getCookieDomain();
setCookie(key, value, {
secure: window.location.protocol === "https:",
sameSite: "lax",
domain: domain,
path: "/", // 모든 경로에서 접근 가능
});
}
private getCookieDomain(): string | undefined {
const hostname = window.location.hostname;
// 개발 환경에서는 localhost 도메인 사용
if (hostname === "localhost" || hostname === "127.0.0.1") {
return undefined; // localhost에서는 도메인 설정하지 않음
}
// 운영 환경에서는 서브도메인을 포함한 최상위 도메인 사용
if (hostname.includes("hasuon.or.kr")) {
return ".hasuon.or.kr";
}
// 기타 환경에서는 현재 호스트명 사용
return hostname;
}
public removeToken(key: string): void {
const domain = this.getCookieDomain();
removeCookie(key, { domain, path: "/" });
}
public setupRefreshTimer(
callback: () => Promise<void>,
expirationTime: number,
): void {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
}
this.refreshTimer = setTimeout(() => {
callback().catch((error) => {
console.error("Silent refresh failed:", error);
this.clearAuthToken();
});
}, expirationTime - 60000);
}
public clearAuthToken(): void {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
this.removeToken(CLAIM_NAME);
this.removeToken(AUTH_USER_ID);
}
}
export const tokenManager = TokenManager.getInstance();
export const {
getToken,
setToken,
removeToken,
setupRefreshTimer,
clearAuthToken,
getAccessToken,
getRefreshToken,
getUserId,
} = tokenManager;
@@ -0,0 +1,10 @@
export interface AuthPayload {
userId: string;
password: string;
}
export interface AuthService {
login(payload: AuthPayload): Promise<void>;
silentRefresh(): Promise<void>;
logout(): Promise<void>;
}
@@ -1,9 +0,0 @@
export interface ILogin {
userId: string;
password: string;
}
export interface LoginService {
login(payload: ILogin): Promise<void>;
silentRefresh(): Promise<void>;
}
@@ -7,6 +7,8 @@ import type {
CklstMngSaveRequest,
CklstMngSearchParams,
CklstMngService,
CklstTreeNode,
CklstTreeSearchParams,
} from "../model/cklst-mng-interface";
async function findPage(params: CklstMngSearchParams): Promise<PageResponse<CklstMng>> {
@@ -21,6 +23,13 @@ async function findById(cklstSn: number): Promise<CklstMng> {
return response.data;
}
async function findTree(params: CklstTreeSearchParams): Promise<CklstTreeNode[]> {
const response = await client.get<CklstTreeNode[]>(`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST}/list/tree`, {
params,
});
return response.data;
}
async function create(body: CklstMngSaveRequest): Promise<number> {
const response = await client.post<number>(ENDPOINT.AS_CHECKLIST_SERVICE.CKLST, body);
return response.data;
@@ -42,6 +51,7 @@ async function remove(cklstSn: number): Promise<number> {
export const cklstMngService = {
findPage,
findById,
findTree,
create,
update,
remove,
@@ -0,0 +1,79 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { PageResponse } from "@/shared/model";
import type {
CklstRvw,
CklstRvwId,
CklstRvwSaveRequest,
CklstRvwSearchParams,
CklstRvwService,
CklstRvwUpdateRequest,
} from "../model/cklst-rvw-interface";
async function findPage(params: CklstRvwSearchParams): Promise<PageResponse<CklstRvw>> {
const response = await client.get<PageResponse<CklstRvw>>(
ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW,
{ params },
);
return response.data;
}
async function findByArtcl(cklstSn: number, cklstArtclSn: number): Promise<CklstRvw[]> {
const response = await client.get<CklstRvw[]>(
`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW}/by-artcl/${cklstSn}/${cklstArtclSn}`,
);
return response.data;
}
async function findById(
cklstRvwSn: number,
cklstArtclSn: number,
cklstSn: number,
): Promise<CklstRvw> {
const response = await client.get<CklstRvw>(
`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW}/${cklstRvwSn}/${cklstArtclSn}/${cklstSn}`,
);
return response.data;
}
async function create(body: CklstRvwSaveRequest): Promise<CklstRvwId> {
const response = await client.post<CklstRvwId>(
ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW,
body,
);
return response.data;
}
async function update(
cklstRvwSn: number,
cklstArtclSn: number,
cklstSn: number,
body: CklstRvwUpdateRequest,
): Promise<CklstRvwId> {
const response = await client.put<CklstRvwId>(
`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW}/${cklstRvwSn}/${cklstArtclSn}/${cklstSn}`,
body,
);
return response.data;
}
async function remove(
cklstRvwSn: number,
cklstArtclSn: number,
cklstSn: number,
): Promise<CklstRvwId> {
const response = await client.delete<CklstRvwId>(
`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW}/${cklstRvwSn}/${cklstArtclSn}/${cklstSn}`,
);
return response.data;
}
export const cklstRvwService = {
findPage,
findByArtcl,
findById,
create,
update,
remove,
} satisfies CklstRvwService;
+114
View File
@@ -8,12 +8,19 @@ import type {
CklstArtclUpdateRequest,
} from "../model/cklst-artcl-interface";
import type { CklstMngSaveRequest, CklstMngSearchParams } from "../model/cklst-mng-interface";
import type {
CklstRvwSaveRequest,
CklstRvwSearchParams,
CklstRvwUpdateRequest,
} from "../model/cklst-rvw-interface";
import { cklstArtclService } from "./cklst-artcl-service";
import { cklstMngService } from "./cklst-mng-service";
import { cklstRvwService } from "./cklst-rvw-service";
const cklstMngBaseKey = [ENDPOINT.AS_CHECKLIST_SERVICE.CKLST] as const;
const cklstArtclBaseKey = [ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL] as const;
const cklstRvwBaseKey = [ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW] as const;
export function useCklstMngPage(params: CklstMngSearchParams) {
return useQuery({
@@ -165,3 +172,110 @@ export function useDeleteCklstArtcl() {
},
});
}
export function useCklstRvwPage(params: CklstRvwSearchParams) {
const { cklstSn } = params;
return useQuery({
queryKey: [...cklstRvwBaseKey, "page", params],
queryFn: () => cklstRvwService.findPage(params),
enabled: cklstSn != null,
});
}
export function useCklstRvwByArtcl(
cklstSn: number | null | undefined,
cklstArtclSn: number | null | undefined,
) {
return useQuery({
queryKey: [...cklstRvwBaseKey, "by-artcl", cklstSn, cklstArtclSn],
queryFn: () => cklstRvwService.findByArtcl(cklstSn!, cklstArtclSn!),
enabled: cklstSn != null && cklstArtclSn != null,
});
}
export function useCklstRvw(params: {
cklstRvwSn: number | null | undefined;
cklstArtclSn: number | null | undefined;
cklstSn: number | null | undefined;
}) {
const { cklstRvwSn, cklstArtclSn, cklstSn } = params;
return useQuery({
queryKey: [...cklstRvwBaseKey, cklstRvwSn, cklstArtclSn, cklstSn],
queryFn: () => cklstRvwService.findById(cklstRvwSn!, cklstArtclSn!, cklstSn!),
enabled: cklstRvwSn != null && cklstArtclSn != null && cklstSn != null,
});
}
export function useCreateCklstRvw() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: CklstRvwSaveRequest) => cklstRvwService.create(body),
onSuccess: (_data, { cklstSn, cklstArtclSn }) => {
queryClient.invalidateQueries({ queryKey: cklstRvwBaseKey });
queryClient.invalidateQueries({
queryKey: [...cklstRvwBaseKey, "page", { cklstSn }],
});
queryClient.invalidateQueries({
queryKey: [...cklstRvwBaseKey, "by-artcl", cklstSn, cklstArtclSn],
});
},
});
}
export function useUpdateCklstRvw() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
cklstRvwSn,
cklstArtclSn,
cklstSn,
body,
}: {
cklstRvwSn: number;
cklstArtclSn: number;
cklstSn: number;
body: CklstRvwUpdateRequest;
}) => cklstRvwService.update(cklstRvwSn, cklstArtclSn, cklstSn, body),
onSuccess: (_data, { cklstRvwSn, cklstArtclSn, cklstSn }) => {
queryClient.invalidateQueries({ queryKey: cklstRvwBaseKey });
queryClient.invalidateQueries({
queryKey: [...cklstRvwBaseKey, cklstRvwSn, cklstArtclSn, cklstSn],
});
queryClient.invalidateQueries({
queryKey: [...cklstRvwBaseKey, "page", { cklstSn }],
});
queryClient.invalidateQueries({
queryKey: [...cklstRvwBaseKey, "by-artcl", cklstSn, cklstArtclSn],
});
},
});
}
export function useDeleteCklstRvw() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
cklstRvwSn,
cklstArtclSn,
cklstSn,
}: {
cklstRvwSn: number;
cklstArtclSn: number;
cklstSn: number;
}) => cklstRvwService.remove(cklstRvwSn, cklstArtclSn, cklstSn),
onSuccess: (_data, { cklstSn, cklstArtclSn }) => {
queryClient.invalidateQueries({ queryKey: cklstRvwBaseKey });
queryClient.invalidateQueries({
queryKey: [...cklstRvwBaseKey, "page", { cklstSn }],
});
queryClient.invalidateQueries({
queryKey: [...cklstRvwBaseKey, "by-artcl", cklstSn, cklstArtclSn],
});
},
});
}
+17
View File
@@ -1,17 +1,24 @@
export { cklstArtclService } from "./api/cklst-artcl-service";
export { cklstMngService } from "./api/cklst-mng-service";
export { cklstRvwService } from "./api/cklst-rvw-service";
export {
useCklstArtcl,
useCklstArtclList,
useCklstArtclPage,
useCklstMng,
useCklstMngPage,
useCklstRvw,
useCklstRvwByArtcl,
useCklstRvwPage,
useCreateCklstArtcl,
useCreateCklstMng,
useCreateCklstRvw,
useDeleteCklstArtcl,
useDeleteCklstMng,
useDeleteCklstRvw,
useUpdateCklstArtcl,
useUpdateCklstMng,
useUpdateCklstRvw,
} from "./api/queries";
export type {
CklstArtcl,
@@ -26,4 +33,14 @@ export type {
CklstMngSaveRequest,
CklstMngSearchParams,
CklstMngService,
CklstTreeNode,
CklstTreeSearchParams,
} from "./model/cklst-mng-interface";
export type {
CklstRvw,
CklstRvwId,
CklstRvwSaveRequest,
CklstRvwSearchParams,
CklstRvwService,
CklstRvwUpdateRequest,
} from "./model/cklst-rvw-interface";
@@ -13,6 +13,26 @@ export interface CklstMng {
mdfcnDt?: string;
}
export interface CklstTreeNode {
cklstSn: number;
cklstNm: string;
cklstArtclSn: number;
cklstArtclNm: string;
cklstRvwSn: number;
upCklstRvwSn: number | null;
treeLevel: number;
rvwCn: string;
bchckUseYn: "Y" | "N";
sortSeq: number;
// optional: backend may include the taskSeComCd per node
taskSeComCd?: string;
}
export interface CklstTreeSearchParams {
docSeComCd?: string;
taskSeComCd?: string;
}
export interface CklstMngSearchParams extends PageableParams {
cklstNm?: string;
taskSeComCd?: string;
@@ -30,6 +50,7 @@ export interface CklstMngSaveRequest {
export interface CklstMngService {
findPage(params: CklstMngSearchParams): Promise<PageResponse<CklstMng>>;
findById(cklstSn: number): Promise<CklstMng>;
findTree(params: CklstTreeSearchParams): Promise<CklstTreeNode[]>;
create(body: CklstMngSaveRequest): Promise<number>;
update(cklstSn: number, body: CklstMngSaveRequest): Promise<number>;
remove(cklstSn: number): Promise<number>;
@@ -0,0 +1,64 @@
import type { PageableParams, PageResponse } from "@/shared/model";
export interface CklstRvwId {
cklstRvwSn: number;
cklstArtclSn: number;
cklstSn: number;
}
export interface CklstRvw {
cklstRvwSn: number;
cklstArtclSn: number;
cklstSn: number;
upCklstRvwSn?: number;
rvwCnLvNo?: number;
rvwCn?: string;
bchckUseYn?: "Y" | "N";
sortSeq?: number;
useYn?: "Y" | "N";
rgtr?: string;
rgtrNm?: string;
mdfr?: string;
regDt?: string;
mdfcnDt?: string;
}
export interface CklstRvwSearchParams extends PageableParams {
cklstSn?: number;
cklstArtclSn?: number;
useYn?: "Y" | "N";
}
export interface CklstRvwSaveRequest {
cklstArtclSn: number;
cklstSn: number;
upCklstRvwSn?: number;
rvwCnLvNo?: number;
rvwCn?: string;
bchckUseYn?: "Y" | "N";
sortSeq?: number;
useYn?: "Y" | "N";
}
export interface CklstRvwUpdateRequest {
upCklstRvwSn?: number;
rvwCnLvNo?: number;
rvwCn?: string;
bchckUseYn?: "Y" | "N";
sortSeq?: number;
useYn?: "Y" | "N";
}
export interface CklstRvwService {
findPage(params: CklstRvwSearchParams): Promise<PageResponse<CklstRvw>>;
findByArtcl(cklstSn: number, cklstArtclSn: number): Promise<CklstRvw[]>;
findById(cklstRvwSn: number, cklstArtclSn: number, cklstSn: number): Promise<CklstRvw>;
create(body: CklstRvwSaveRequest): Promise<CklstRvwId>;
update(
cklstRvwSn: number,
cklstArtclSn: number,
cklstSn: number,
body: CklstRvwUpdateRequest,
): Promise<CklstRvwId>;
remove(cklstRvwSn: number, cklstArtclSn: number, cklstSn: number): Promise<CklstRvwId>;
}
@@ -0,0 +1,58 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { PageResponse } from "@/shared/model";
import {
CNSTP_SE_SAVE_MODE,
type CnstpSeMngPutRequestDto,
type CnstpSeMngResponseDto,
type CnstpSeMngSaveRequestDto,
type CnstpSeMngSearchDto,
type CnstpSeMngService,
type CnstpSeMngTreeDto,
} from "../model/cnstp-interface";
const BASE = ENDPOINT.AS_COMMON_SERVICE.CNSTP_SE;
async function findAll(params: CnstpSeMngSearchDto): Promise<PageResponse<CnstpSeMngResponseDto>> {
const response = await client.get<PageResponse<CnstpSeMngResponseDto>>(BASE, { params });
return response.data;
}
async function findTree(): Promise<CnstpSeMngTreeDto[]> {
const response = await client.get<CnstpSeMngTreeDto[]>(`${BASE}/list/tree`);
return response.data;
}
async function findById(cnstpSeCd: string): Promise<CnstpSeMngResponseDto> {
const response = await client.get<CnstpSeMngResponseDto>(`${BASE}/${cnstpSeCd}`);
return response.data;
}
async function saveMulti(body: CnstpSeMngSaveRequestDto[]): Promise<CnstpSeMngResponseDto[]> {
const response = await client.post<CnstpSeMngResponseDto[]>(BASE, body);
return response.data;
}
async function update(cnstpSeCd: string, body: CnstpSeMngPutRequestDto): Promise<string> {
const response = await client.put<string>(`${BASE}/${cnstpSeCd}`, {
...body,
cnstpSeCd,
mode: CNSTP_SE_SAVE_MODE.UPDATE,
});
return response.data;
}
async function deleteCnstpSeCd(cnstpSeCd: string): Promise<string> {
const response = await client.delete<string>(`${BASE}/${cnstpSeCd}`);
return response.data;
}
export const cnstpSeMngService = {
findAll,
findTree,
findById,
saveMulti,
update,
delete: deleteCnstpSeCd,
} satisfies CnstpSeMngService;
+73
View File
@@ -0,0 +1,73 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import type {
CnstpSeMngPutRequestDto,
CnstpSeMngSaveRequestDto,
CnstpSeMngSearchDto,
} from "../model/cnstp-interface";
import { cnstpSeMngService } from "./cnstp-service";
const cnstpSeMngBaseKey = [ENDPOINT.AS_COMMON_SERVICE.CNSTP_SE] as const;
export function useCnstpSeMngPage(params: CnstpSeMngSearchDto) {
return useQuery({
queryKey: [...cnstpSeMngBaseKey, "findAll", params],
queryFn: () => cnstpSeMngService.findAll(params),
});
}
export function useCnstpSeMngTree() {
return useQuery({
queryKey: [...cnstpSeMngBaseKey, "findTree"],
queryFn: () => cnstpSeMngService.findTree(),
staleTime: Infinity,
});
}
export function useCnstpSeMng(cnstpSeCd: string | null | undefined) {
return useQuery({
queryKey: [...cnstpSeMngBaseKey, "findById", cnstpSeCd],
queryFn: () => cnstpSeMngService.findById(cnstpSeCd!),
enabled: Boolean(cnstpSeCd),
});
}
export function useSaveMultiCnstpSeMng() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: CnstpSeMngSaveRequestDto[]) => cnstpSeMngService.saveMulti(body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: cnstpSeMngBaseKey });
},
});
}
export function useUpdateCnstpSeMng() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ cnstpSeCd, body }: { cnstpSeCd: string; body: CnstpSeMngPutRequestDto }) =>
cnstpSeMngService.update(cnstpSeCd, body),
onSuccess: (_data, { cnstpSeCd }) => {
queryClient.invalidateQueries({ queryKey: cnstpSeMngBaseKey });
queryClient.invalidateQueries({
queryKey: [...cnstpSeMngBaseKey, "findById", cnstpSeCd],
});
},
});
}
export function useDeleteCnstpSeMng() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (cnstpSeCd: string) => cnstpSeMngService.delete(cnstpSeCd),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: cnstpSeMngBaseKey });
},
});
}
+19
View File
@@ -0,0 +1,19 @@
export { cnstpSeMngService } from "./api/cnstp-service";
export {
useCnstpSeMng,
useCnstpSeMngPage,
useCnstpSeMngTree,
useDeleteCnstpSeMng,
useSaveMultiCnstpSeMng,
useUpdateCnstpSeMng,
} from "./api/queries";
export type {
BizSclTreeDto,
CnstpSeMngResponseDto,
CnstpSeMngSaveRequestDto,
CnstpSeMngSearchDto,
CnstpSeMngService,
CnstpSeMngTreeDto,
CnstpSeSaveMode,
} from "./model/cnstp-interface";
export { CNSTP_SE_SAVE_MODE } from "./model/cnstp-interface";
@@ -0,0 +1,86 @@
import type { PageableParams, PageResponse } from "@/shared/model";
export const CNSTP_SE_SAVE_MODE = {
CREATE: "C",
UPDATE: "U",
DELETE: "D",
} as const;
export type CnstpSeSaveMode = (typeof CNSTP_SE_SAVE_MODE)[keyof typeof CNSTP_SE_SAVE_MODE];
/** 백엔드 CnstpSeMngResponseDto */
export interface CnstpSeMngResponseDto {
cnstpSeCd: string;
cnstpSeCdNm: string;
upCnstpCd?: string;
cdLvNo: number;
useYn: string;
expln?: string;
rgtr?: string;
rgtrNm?: string;
mdfr?: string;
regDt?: string;
mdfcnDt?: string;
}
/** 백엔드 CnstpSeMngSearchDto + Pageable */
export interface CnstpSeMngSearchDto extends PageableParams {
cnstpSeCd?: string;
cnstpSeCdNm?: string;
useYn?: string;
}
/** 백엔드 CnstpSeMngSaveRequestDto */
export interface CnstpSeMngSaveRequestDto {
cnstpSeCd: string;
mode: CnstpSeSaveMode;
cnstpSeCdNm?: string;
upCnstpCd?: string;
cdLvNo?: number;
useYn?: string;
expln?: string;
}
/** PUT /{cnstpSeCd} 요청 (mode는 서비스에서 U로 설정) */
export interface CnstpSeMngPutRequestDto {
cnstpSeCd: string;
cnstpSeCdNm?: string;
upCnstpCd?: string;
cdLvNo?: number;
useYn?: string;
expln?: string;
}
/** 백엔드 CnstpSeMngTreeDto */
export interface CnstpSeMngTreeDto {
indCnstpSeCdNm: string;
cnstpSeCd: string;
cnstpSeCdNm: string;
upCnstpCd?: string;
useYn: string;
treeLevel: number;
treePath: string;
}
/** 백엔드 BizSclTreeDto */
export interface BizSclTreeDto {
indCnstpSeCdNm: string;
cnstpSeCd: string;
cnstpSeCdNm: string;
useYn: string;
treeLevel: number;
treePath: string;
bscPlanQty?: number;
bgtRfltQty?: number;
tstmDesignQty?: number;
}
/** Controller public 메서드명·시그니처와 동일 */
export interface CnstpSeMngService {
findAll(params: CnstpSeMngSearchDto): Promise<PageResponse<CnstpSeMngResponseDto>>;
findTree(): Promise<CnstpSeMngTreeDto[]>;
findById(cnstpSeCd: string): Promise<CnstpSeMngResponseDto>;
saveMulti(body: CnstpSeMngSaveRequestDto[]): Promise<CnstpSeMngResponseDto[]>;
update(cnstpSeCd: string, body: CnstpSeMngPutRequestDto): Promise<string>;
delete(cnstpSeCd: string): Promise<string>;
}
@@ -1,48 +1,54 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { PageResponse } from "@/shared/model";
import type {
ElaprApln,
ElaprAplnListParams,
ElaprAplnSaveRequest,
ElaprAplnService,
ElaprAplnUpdateRequest,
} from "../model/elapr-apln-interface";
async function findPage(params: ElaprAplnListParams): Promise<PageResponse<ElaprApln>> {
const response = await client.get<PageResponse<ElaprApln>>(ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN, {
params,
});
return response.data;
}
async function findById(aplnId: string): Promise<ElaprApln> {
const response = await client.get<ElaprApln>(`${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN}/${aplnId}`);
return response.data;
}
async function create(body: ElaprAplnSaveRequest): Promise<ElaprApln> {
const response = await client.post<ElaprApln>(ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN, body);
return response.data;
}
async function update(aplnId: string, body: ElaprAplnUpdateRequest): Promise<ElaprApln> {
const response = await client.put<ElaprApln>(
`${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN}/${aplnId}`,
body,
);
return response.data;
}
async function remove(aplnId: string): Promise<void> {
await client.delete(`${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN}/${aplnId}`);
}
export const elaprAplnService = {
findPage,
findById,
create,
update,
remove,
} satisfies ElaprAplnService;
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { PageResponse } from "@/shared/model";
import type {
ElaprApln,
ElaprAplnListParams,
ElaprAplnReorderRequest,
ElaprAplnSaveRequest,
ElaprAplnService,
ElaprAplnUpdateRequest,
} from "../model/elapr-apln-interface";
async function findPage(params: ElaprAplnListParams): Promise<PageResponse<ElaprApln>> {
const response = await client.get<PageResponse<ElaprApln>>(ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN, {
params,
});
return response.data;
}
async function findById(aplnId: string): Promise<ElaprApln> {
const response = await client.get<ElaprApln>(`${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN}/${aplnId}`);
return response.data;
}
async function saveMulti(body: ElaprAplnSaveRequest[]): Promise<ElaprApln[]> {
const response = await client.post<ElaprApln[]>(ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN, body);
return response.data;
}
async function update(aplnId: string, body: ElaprAplnUpdateRequest): Promise<ElaprApln> {
const response = await client.put<ElaprApln>(
`${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN}/${aplnId}`,
body,
);
return response.data;
}
async function remove(aplnId: string): Promise<void> {
await client.delete(`${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN}/${aplnId}`);
}
async function reorder(body: ElaprAplnReorderRequest): Promise<void> {
await client.put(`${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN}/reorder`, body);
}
export const elaprAplnService = {
findPage,
findById,
saveMulti,
update,
remove,
reorder,
} satisfies ElaprAplnService;
@@ -1,51 +1,51 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { PageResponse } from "@/shared/model";
import type {
ElaprTmplt,
ElaprTmpltSaveRequest,
ElaprTmpltSearchParams,
ElaprTmpltService,
ElaprTmpltUpdateRequest,
} from "../model/elapr-tmplt-interface";
async function findPage(params: ElaprTmpltSearchParams): Promise<PageResponse<ElaprTmplt>> {
const response = await client.get<PageResponse<ElaprTmplt>>(
ENDPOINT.AS_ELAPR_SERVICE.ELAPR_TMPLT,
{ params },
);
return response.data;
}
async function findById(elaprTmpltId: string): Promise<ElaprTmplt> {
const response = await client.get<ElaprTmplt>(
`${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_TMPLT}/${elaprTmpltId}`,
);
return response.data;
}
async function create(body: ElaprTmpltSaveRequest): Promise<ElaprTmplt> {
const response = await client.post<ElaprTmplt>(ENDPOINT.AS_ELAPR_SERVICE.ELAPR_TMPLT, body);
return response.data;
}
async function update(elaprTmpltId: string, body: ElaprTmpltUpdateRequest): Promise<ElaprTmplt> {
const response = await client.put<ElaprTmplt>(
`${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_TMPLT}/${elaprTmpltId}`,
body,
);
return response.data;
}
async function remove(elaprTmpltId: string): Promise<void> {
await client.delete(`${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_TMPLT}/${elaprTmpltId}`);
}
export const elaprTmpltService = {
findPage,
findById,
create,
update,
remove,
} satisfies ElaprTmpltService;
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { PageResponse } from "@/shared/model";
import type {
ElaprTmplt,
ElaprTmpltSaveRequest,
ElaprTmpltSearchParams,
ElaprTmpltService,
ElaprTmpltUpdateRequest,
} from "../model/elapr-tmplt-interface";
async function findPage(params: ElaprTmpltSearchParams): Promise<PageResponse<ElaprTmplt>> {
const response = await client.get<PageResponse<ElaprTmplt>>(
ENDPOINT.AS_ELAPR_SERVICE.ELAPR_TMPLT,
{ params },
);
return response.data;
}
async function findById(elaprTmpltId: string): Promise<ElaprTmplt> {
const response = await client.get<ElaprTmplt>(
`${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_TMPLT}/${elaprTmpltId}`,
);
return response.data;
}
async function create(body: ElaprTmpltSaveRequest): Promise<ElaprTmplt> {
const response = await client.post<ElaprTmplt>(ENDPOINT.AS_ELAPR_SERVICE.ELAPR_TMPLT, body);
return response.data;
}
async function update(elaprTmpltId: string, body: ElaprTmpltUpdateRequest): Promise<ElaprTmplt> {
const response = await client.put<ElaprTmplt>(
`${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_TMPLT}/${elaprTmpltId}`,
body,
);
return response.data;
}
async function remove(elaprTmpltId: string): Promise<void> {
await client.delete(`${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_TMPLT}/${elaprTmpltId}`);
}
export const elaprTmpltService = {
findPage,
findById,
create,
update,
remove,
} satisfies ElaprTmpltService;
+27 -2
View File
@@ -4,6 +4,7 @@ import { ENDPOINT } from "@/shared/config";
import type {
ElaprAplnListParams,
ElaprAplnReorderRequest,
ElaprAplnSaveRequest,
ElaprAplnUpdateRequest,
} from "../model/elapr-apln-interface";
@@ -94,11 +95,16 @@ export function useElaprApln(aplnId: string | null | undefined) {
});
}
export function useCreateElaprApln() {
export function useSaveMultiElaprApln() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: ElaprAplnSaveRequest) => elaprAplnService.create(body),
mutationFn: ({
body,
}: {
body: ElaprAplnSaveRequest[];
elaprTmpltId: string;
}) => elaprAplnService.saveMulti(body),
onSuccess: (_data, { elaprTmpltId }) => {
queryClient.invalidateQueries({ queryKey: elaprAplnBaseKey });
queryClient.invalidateQueries({
@@ -149,3 +155,22 @@ export function useDeleteElaprApln() {
},
});
}
export function useReorderElaprApln() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
body,
}: {
body: ElaprAplnReorderRequest;
elaprTmpltId: string;
}) => elaprAplnService.reorder(body),
onSuccess: (_data, { elaprTmpltId }) => {
queryClient.invalidateQueries({ queryKey: elaprAplnBaseKey });
queryClient.invalidateQueries({
queryKey: [...elaprAplnBaseKey, "page", { elaprTmpltId }],
});
},
});
}
+12 -7
View File
@@ -1,7 +1,6 @@
export { elaprAplnService } from "./api/elapr-apln-service";
export { elaprTmpltService } from "./api/elapr-tmplt-service";
export {
useCreateElaprApln,
useCreateElaprTmplt,
useDeleteElaprApln,
useDeleteElaprTmplt,
@@ -9,15 +8,21 @@ export {
useElaprAplnPage,
useElaprTmplt,
useElaprTmpltPage,
useReorderElaprApln,
useSaveMultiElaprApln,
useUpdateElaprApln,
useUpdateElaprTmplt,
} from "./api/queries";
export type {
ElaprApln,
ElaprAplnListParams,
ElaprAplnSaveRequest,
ElaprAplnService,
ElaprAplnUpdateRequest,
export {
ELAPR_APLN_SAVE_MODE,
type ElaprApln,
type ElaprAplnListParams,
type ElaprAplnReorderItem,
type ElaprAplnReorderRequest,
type ElaprAplnSaveMode,
type ElaprAplnSaveRequest,
type ElaprAplnService,
type ElaprAplnUpdateRequest,
} from "./model/elapr-apln-interface";
export type {
ElaprTmplt,
@@ -1,9 +1,19 @@
import type { PageableParams, PageResponse } from "@/shared/model";
export const ELAPR_APLN_SAVE_MODE = {
CREATE: "C",
UPDATE: "U",
DELETE: "D",
} as const;
export type ElaprAplnSaveMode =
(typeof ELAPR_APLN_SAVE_MODE)[keyof typeof ELAPR_APLN_SAVE_MODE];
export interface ElaprApln {
aplnId: string;
elaprTmpltId: string;
aprvrUuid?: string;
aprvrNm?: string;
atrzSeComCd?: string;
atrzSeComNm?: string;
atrzSeqNo?: number;
@@ -18,7 +28,9 @@ export interface ElaprAplnListParams extends PageableParams {
}
export interface ElaprAplnSaveRequest {
elaprTmpltId: string;
mode: ElaprAplnSaveMode;
aplnId?: string;
elaprTmpltId?: string;
aprvrUuid?: string;
atrzSeComCd?: string;
atrzSeqNo?: number;
@@ -31,10 +43,20 @@ export interface ElaprAplnUpdateRequest {
atrzSeqNo?: number;
}
export interface ElaprAplnReorderItem {
aplnId: string;
atrzSeqNo: number;
}
export interface ElaprAplnReorderRequest {
items: ElaprAplnReorderItem[];
}
export interface ElaprAplnService {
findPage(params: ElaprAplnListParams): Promise<PageResponse<ElaprApln>>;
findById(aplnId: string): Promise<ElaprApln>;
create(body: ElaprAplnSaveRequest): Promise<ElaprApln>;
saveMulti(body: ElaprAplnSaveRequest[]): Promise<ElaprApln[]>;
update(aplnId: string, body: ElaprAplnUpdateRequest): Promise<ElaprApln>;
remove(aplnId: string): Promise<void>;
reorder(body: ElaprAplnReorderRequest): Promise<void>;
}
@@ -0,0 +1,68 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { PageResponse } from "@/shared/model";
import type {
EsntlAtchFileMng,
EsntlAtchFileMngSaveRequest,
EsntlAtchFileMngSearchParams,
EsntlAtchFileMngService,
EsntlAtchFileMngTreeResponseDto,
EsntlAtchFileMngTreeSearchParams,
} from "../model/esntl-interface";
async function findTree(
params: EsntlAtchFileMngTreeSearchParams,
): Promise<EsntlAtchFileMngTreeResponseDto[]> {
const response = await client.get<EsntlAtchFileMngTreeResponseDto[]>(
`${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/list/tree`,
{ params },
);
return response.data;
}
async function findPage(
params: EsntlAtchFileMngSearchParams,
): Promise<PageResponse<EsntlAtchFileMng>> {
const response = await client.get<PageResponse<EsntlAtchFileMng>>(
ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE,
{ params },
);
return response.data;
}
async function findById(esntlAtchFileId: string): Promise<EsntlAtchFileMng> {
const response = await client.get<EsntlAtchFileMng>(
`${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/${esntlAtchFileId}`,
);
return response.data;
}
async function create(body: EsntlAtchFileMngSaveRequest): Promise<string> {
const response = await client.post<string>(ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE, body);
return response.data;
}
async function update(esntlAtchFileId: string, body: EsntlAtchFileMngSaveRequest): Promise<string> {
const response = await client.put<string>(
`${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/${esntlAtchFileId}`,
body,
);
return response.data;
}
async function remove(esntlAtchFileId: string): Promise<string> {
const response = await client.delete<string>(
`${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/${esntlAtchFileId}`,
);
return response.data;
}
export const esntlAtchFileMngService = {
findTree,
findPage,
findById,
create,
update,
remove,
} satisfies EsntlAtchFileMngService;
+79
View File
@@ -0,0 +1,79 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import type {
EsntlAtchFileMngSaveRequest,
EsntlAtchFileMngSearchParams,
EsntlAtchFileMngTreeSearchParams,
} from "../model/esntl-interface";
import { esntlAtchFileMngService } from "./esntl-service";
const esntlAtchFileMngBaseKey = [ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE] as const;
export function useEsntlAtchFileMngTree(params: EsntlAtchFileMngTreeSearchParams) {
return useQuery({
queryKey: [...esntlAtchFileMngBaseKey, "tree", params],
queryFn: () => esntlAtchFileMngService.findTree(params),
enabled: Boolean(params.docSeComCd),
staleTime: Infinity,
});
}
export function useEsntlAtchFileMngPage(params: EsntlAtchFileMngSearchParams) {
return useQuery({
queryKey: [...esntlAtchFileMngBaseKey, "page", params],
queryFn: () => esntlAtchFileMngService.findPage(params),
});
}
export function useEsntlAtchFileMng(esntlAtchFileId: string | null | undefined) {
return useQuery({
queryKey: [...esntlAtchFileMngBaseKey, esntlAtchFileId],
queryFn: () => esntlAtchFileMngService.findById(esntlAtchFileId!),
enabled: Boolean(esntlAtchFileId),
});
}
export function useCreateEsntlAtchFileMng() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: EsntlAtchFileMngSaveRequest) => esntlAtchFileMngService.create(body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: esntlAtchFileMngBaseKey });
},
});
}
export function useUpdateEsntlAtchFileMng() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
esntlAtchFileId,
body,
}: {
esntlAtchFileId: string;
body: EsntlAtchFileMngSaveRequest;
}) => esntlAtchFileMngService.update(esntlAtchFileId, body),
onSuccess: (_data, { esntlAtchFileId }) => {
queryClient.invalidateQueries({ queryKey: esntlAtchFileMngBaseKey });
queryClient.invalidateQueries({
queryKey: [...esntlAtchFileMngBaseKey, esntlAtchFileId],
});
},
});
}
export function useDeleteEsntlAtchFileMng() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (esntlAtchFileId: string) => esntlAtchFileMngService.remove(esntlAtchFileId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: esntlAtchFileMngBaseKey });
},
});
}
+17
View File
@@ -0,0 +1,17 @@
export { esntlAtchFileMngService } from "./api/esntl-service";
export {
useCreateEsntlAtchFileMng,
useDeleteEsntlAtchFileMng,
useEsntlAtchFileMng,
useEsntlAtchFileMngPage,
useEsntlAtchFileMngTree,
useUpdateEsntlAtchFileMng,
} from "./api/queries";
export type {
EsntlAtchFileMng,
EsntlAtchFileMngSaveRequest,
EsntlAtchFileMngSearchParams,
EsntlAtchFileMngService,
EsntlAtchFileMngTreeResponseDto,
EsntlAtchFileMngTreeSearchParams,
} from "./model/esntl-interface";
@@ -0,0 +1,83 @@
import type { PageableParams, PageResponse } from "@/shared/model";
/** 필수첨부파일관리 트리 조회 응답 DTO */
export interface EsntlAtchFileMngTreeResponseDto {
/** 필수첨부파일아이디 */
esntlAtchFileId: string;
/** 문서구분공통코드 */
docSeComCd: string;
/** 필수첨부파일명 */
esntlAtchFileNm: string;
/** 필수첨부여부 ("Y" | "N") */
esntlAtchYn: "Y" | "N";
/** 필수첨부파일비고 (파일형식 등) */
esntlAtchFileRmrk: string;
}
/** 필수첨부파일관리 엔티티 (목록/상세 공용 응답 DTO) */
export interface EsntlAtchFileMng {
/** 필수첨부파일아이디 */
esntlAtchFileId: string;
/** 문서구분공통코드 */
docSeComCd: string;
/** 필수첨부파일명 */
esntlAtchFileNm: string;
/** 필수첨부여부 ("Y" | "N") */
esntlAtchYn: "Y" | "N";
/** 필수첨부파일비고 */
esntlAtchFileRmrk?: string;
/** 등록자UUID */
rgtr?: string;
/** 등록자명 */
rgtrNm?: string;
/** 수정자UUID */
mdfr?: string;
/** 등록일시 (yyyy-MM-dd HH:mm:ss) */
regDt?: string;
/** 수정일시 */
mdfcnDt?: string;
}
/** 필수첨부파일관리 트리 조회 파라미터 */
export interface EsntlAtchFileMngTreeSearchParams {
/** 문서구분공통코드 (예: "INSTL") */
docSeComCd: string;
}
/** 필수첨부파일관리 목록 조회 파라미터 (페이징 포함) */
export interface EsntlAtchFileMngSearchParams extends PageableParams {
/** 문서구분공통코드 (예: "INSTL") */
docSeComCd?: string;
/** 필수첨부파일명 */
esntlAtchFileNm?: string;
/** 필수첨부여부 ("Y" | "N") */
esntlAtchYn?: "Y" | "N";
}
/** 필수첨부파일관리 서비스 인터페이스 */
export interface EsntlAtchFileMngService {
/** 필수첨부파일 트리 조회 */
findTree(params: EsntlAtchFileMngTreeSearchParams): Promise<EsntlAtchFileMngTreeResponseDto[]>;
/** 필수첨부파일관리 목록 조회 (페이징) */
findPage(params: EsntlAtchFileMngSearchParams): Promise<PageResponse<EsntlAtchFileMng>>;
/** 필수첨부파일관리 상세 조회 */
findById(esntlAtchFileId: string): Promise<EsntlAtchFileMng>;
/** 필수첨부파일관리 등록 */
create(body: EsntlAtchFileMngSaveRequest): Promise<string>;
/** 필수첨부파일관리 수정 */
update(esntlAtchFileId: string, body: EsntlAtchFileMngSaveRequest): Promise<string>;
/** 필수첨부파일관리 삭제 */
remove(esntlAtchFileId: string): Promise<string>;
}
/** 필수첨부파일관리 저장 요청 DTO */
export interface EsntlAtchFileMngSaveRequest {
/** 문서구분공통코드 */
docSeComCd: string;
/** 필수첨부파일명 */
esntlAtchFileNm?: string;
/** 필수첨부여부 ("Y" | "N") */
esntlAtchYn?: "Y" | "N";
/** 필수첨부파일비고 */
esntlAtchFileRmrk?: string;
}
@@ -1,10 +1,12 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
InstlSncnApplySaveRequestDto,
InstlSncnMng,
InstlSncnMngSearchParams,
InstlSncnMngService,
SpringPage,
InstlSncnMngDetResponse,
} from "../model/instl-sncn-interface";
export class InstlSncnApiService implements InstlSncnMngService {
@@ -15,8 +17,23 @@ export class InstlSncnApiService implements InstlSncnMngService {
return response.data;
}
public async getById(id: string): Promise<InstlSncnMng> {
const response = await client.get<InstlSncnMng>(`${ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN}/${id}`);
public async getById(id: string): Promise<InstlSncnMngDetResponse> {
const response = await client.get<InstlSncnMngDetResponse>(`${ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN}/${id}`);
return response.data;
}
public async saveApply(payload: InstlSncnApplySaveRequestDto): Promise<string> {
const response = await client.post<string>(
`${ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN}/save-apply`,
payload,
);
return response.data;
}
public async techReview(payload: InstlSncnApplySaveRequestDto): Promise<string> {
const response = await client.post<string>(
`${ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN}/tech-review`,
payload,
);
return response.data;
}
}
+9 -4
View File
@@ -1,6 +1,11 @@
export { InstlSncnApi } from "./api/instl-sncn-service";
export type {
InstlSncnMng,
InstlSncnMngSearchParams,
SpringPage,
export {
INSTL_SNCN_REGISTER_SELECTION_KEY,
type InstlSncnApplySaveRequestDto,
type InstlSncnBizCstSaveRequestDto,
type InstlSncnBzScaleSaveDto,
type InstlSncnCklstResultSaveDto,
type InstlSncnMng,
type InstlSncnMngSearchParams,
type SpringPage,
} from "./model/instl-sncn-interface";
@@ -1,3 +1,5 @@
export const INSTL_SNCN_REGISTER_SELECTION_KEY = "instlSncnRegisterSelection";
export interface SpringPage<T> {
content: T[];
totalElements: number;
@@ -21,12 +23,13 @@ export interface InstlSncnMngSearchParams {
export interface InstlSncnMng {
instlSncnDocNo: string;
dongId: string;
admdstCd: string;
sidoNm?: string;
sggNm?: string;
chrgrNm?: string;
instNm?: string;
envoSeComCd: string;
sncnSeComCd: string;
envoSeCdNm: string;
taskSeComCd: string;
taskSeCdNm: string;
@@ -52,7 +55,70 @@ export interface InstlSncnMng {
rgtrNm: string;
}
/** 설치인가 신청 - 사업규모 저장 DTO(배열 원소) */
export interface InstlSncnBzScaleSaveDto {
/** 공종구분코드 */
cnstpSeCd: string;
/** 기본계획(A) */
bscPl: number;
/** 예산반영(B) */
budgetReflct: number;
/** 금회설계시(C) */
thtiDsgn: number;
/** 증감(C-B) */
diff: number;
}
/** 설치인가 신청 - 총사업비 저장 DTO(배열 원소) */
export interface InstlSncnBizCstSaveRequestDto {
/** 회계구분코드 */
acntgSeCd: string;
/** 기본계획금액 */
bscPlanAmt: number;
/** 예산반영금액 */
bgtRfltAmt: number;
/** 금회설계금액 */
tstmDesignAmt: number;
}
/** 설치인가 신청 - 체크리스트 결과 저장 DTO(배열 원소) */
export interface InstlSncnCklstResultSaveDto {
cklstSn: number;
cklstArtclSn: number;
cklstRvwSn: number;
rvwStpComCd: string;
selected: "Y" | "N" | null;
picRvwCn?: string | null;
}
/** 설치인가 신청 - 필수첨부파일 저장 DTO(배열 원소) */
export interface InstlSncnEsntlAtchFileSaveDto {
esntlAtchFileId: string;
atchFileGroupId: string;
}
/** 설치인가 신청 - 통합 저장 요청 DTO */
export interface InstlSncnApplySaveRequestDto {
instlSncnMng: InstlSncnMng;
bzScaleList: InstlSncnBzScaleSaveDto[];
bizCstList: InstlSncnBizCstSaveRequestDto[];
cklstResultList: InstlSncnCklstResultSaveDto[];
esntlAtchFileList: InstlSncnEsntlAtchFileSaveDto[];
}
export type ReviewStepCode = "LOCAL" | "ENV" | "KECO";
export interface InstlSncnMngDetResponse {
detDto: InstlSncnMng;
bizSclList: any[];
bizCstList: any[];
cklstResultList?: any[];
esntlFileList: any[];
etcFileList: any[];
}
export interface InstlSncnMngService {
getPage: (params: InstlSncnMngSearchParams) => Promise<SpringPage<InstlSncnMng>>;
getById: (id: string) => Promise<InstlSncnMng>;
getById: (id: string) => Promise<InstlSncnMngDetResponse>;
saveApply: (payload: InstlSncnApplySaveRequestDto) => Promise<string>;
}
@@ -0,0 +1,87 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import type {
RvwMnpwMngSaveRequestDto,
RvwMnpwMngSearchDto,
RvwMnpwMngUpdateRequestDto,
} from "../model/rvw-mnpw-interface";
import { rvwMnpwMngService } from "./rvw-mnpw-service";
const rvwMnpwMngBaseKey = [ENDPOINT.AS_COMMON_SERVICE.RVW_MNPW] as const;
export function useRvwMnpwMngPage(params: RvwMnpwMngSearchDto, options?: { enabled?: boolean }) {
return useQuery({
queryKey: [...rvwMnpwMngBaseKey, "findPage", params],
queryFn: () => rvwMnpwMngService.findPage(params),
enabled: options?.enabled,
});
}
export function useRvwMnpwMng(
atrzSeComCd: string | null | undefined,
ognzSn: number | null | undefined,
userUuid: string | null | undefined,
) {
return useQuery({
queryKey: [...rvwMnpwMngBaseKey, "findById", atrzSeComCd, ognzSn, userUuid],
queryFn: () => rvwMnpwMngService.findById(atrzSeComCd!, ognzSn!, userUuid!),
enabled: Boolean(atrzSeComCd && ognzSn != null && userUuid),
});
}
export function useSaveRvwMnpwMng() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: RvwMnpwMngSaveRequestDto) => rvwMnpwMngService.save(body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: rvwMnpwMngBaseKey });
},
});
}
export function useUpdateRvwMnpwMng() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
atrzSeComCd,
ognzSn,
userUuid,
body,
}: {
atrzSeComCd: string;
ognzSn: number;
userUuid: string;
body: RvwMnpwMngUpdateRequestDto;
}) => rvwMnpwMngService.update(atrzSeComCd, ognzSn, userUuid, body),
onSuccess: (_data, { atrzSeComCd, ognzSn, userUuid }) => {
queryClient.invalidateQueries({ queryKey: rvwMnpwMngBaseKey });
queryClient.invalidateQueries({
queryKey: [...rvwMnpwMngBaseKey, "findById", atrzSeComCd, ognzSn, userUuid],
});
},
});
}
export function useDeleteRvwMnpwMng() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
atrzSeComCd,
ognzSn,
userUuid,
}: {
atrzSeComCd: string;
ognzSn: number;
userUuid: string;
}) => rvwMnpwMngService.delete(atrzSeComCd, ognzSn, userUuid),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: rvwMnpwMngBaseKey });
},
});
}
@@ -0,0 +1,66 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { PageResponse } from "@/shared/model";
import type {
RvwMnpwMngResponseDto,
RvwMnpwMngSaveRequestDto,
RvwMnpwMngSearchDto,
RvwMnpwMngService,
RvwMnpwMngUpdateRequestDto,
} from "../model/rvw-mnpw-interface";
const BASE = ENDPOINT.AS_COMMON_SERVICE.RVW_MNPW;
function pkPath(atrzSeComCd: string, ognzSn: number, userUuid: string): string {
return `${BASE}/${atrzSeComCd}/${ognzSn}/${userUuid}`;
}
async function findPage(params: RvwMnpwMngSearchDto): Promise<PageResponse<RvwMnpwMngResponseDto>> {
const response = await client.get<PageResponse<RvwMnpwMngResponseDto>>(BASE, { params });
return response.data;
}
async function findById(
atrzSeComCd: string,
ognzSn: number,
userUuid: string,
): Promise<RvwMnpwMngResponseDto> {
const response = await client.get<RvwMnpwMngResponseDto>(pkPath(atrzSeComCd, ognzSn, userUuid));
return response.data;
}
async function save(body: RvwMnpwMngSaveRequestDto): Promise<RvwMnpwMngResponseDto> {
const response = await client.post<RvwMnpwMngResponseDto>(BASE, body);
return response.data;
}
async function update(
atrzSeComCd: string,
ognzSn: number,
userUuid: string,
body: RvwMnpwMngUpdateRequestDto,
): Promise<RvwMnpwMngResponseDto> {
const response = await client.put<RvwMnpwMngResponseDto>(
pkPath(atrzSeComCd, ognzSn, userUuid),
body,
);
return response.data;
}
async function deleteRvwMnpw(
atrzSeComCd: string,
ognzSn: number,
userUuid: string,
): Promise<string> {
const response = await client.delete<string>(pkPath(atrzSeComCd, ognzSn, userUuid));
return response.data;
}
export const rvwMnpwMngService = {
findPage,
findById,
save,
update,
delete: deleteRvwMnpw,
} satisfies RvwMnpwMngService;
+15
View File
@@ -0,0 +1,15 @@
export {
useDeleteRvwMnpwMng,
useRvwMnpwMng,
useRvwMnpwMngPage,
useSaveRvwMnpwMng,
useUpdateRvwMnpwMng,
} from "./api/queries";
export { rvwMnpwMngService } from "./api/rvw-mnpw-service";
export type {
RvwMnpwMngResponseDto,
RvwMnpwMngSaveRequestDto,
RvwMnpwMngSearchDto,
RvwMnpwMngService,
RvwMnpwMngUpdateRequestDto,
} from "./model/rvw-mnpw-interface";
@@ -0,0 +1,48 @@
import type { PageableParams, PageResponse } from "@/shared/model";
/** 백엔드 RvwMnpwMngResponseDto */
export interface RvwMnpwMngResponseDto {
atrzSeComCd: string;
atrzSeComCdNm?: string;
ognzSn: number;
userUuid: string;
userUuidNm?: string;
ognzNm?: string;
jbpsSeNm?: string;
jbgdSeNm?: string;
rgtr?: string;
regDt?: string;
}
/** 백엔드 RvwMnpwMngSearchDto + Pageable */
export interface RvwMnpwMngSearchDto extends PageableParams {
atrzSeComCd?: string;
ognzSn?: number;
userUuid?: string;
}
/** 백엔드 RvwMnpwMngSaveRequestDto */
export interface RvwMnpwMngSaveRequestDto {
atrzSeComCd: string;
ognzSn: number;
userUuid: string;
}
/** 백엔드 RvwMnpwMngUpdateRequestDto */
export interface RvwMnpwMngUpdateRequestDto {
newAtrzSeComCd: string;
}
/** Controller public 메서드명·시그니처와 동일 */
export interface RvwMnpwMngService {
findPage(params: RvwMnpwMngSearchDto): Promise<PageResponse<RvwMnpwMngResponseDto>>;
findById(atrzSeComCd: string, ognzSn: number, userUuid: string): Promise<RvwMnpwMngResponseDto>;
save(body: RvwMnpwMngSaveRequestDto): Promise<RvwMnpwMngResponseDto>;
update(
atrzSeComCd: string,
ognzSn: number,
userUuid: string,
body: RvwMnpwMngUpdateRequestDto,
): Promise<RvwMnpwMngResponseDto>;
delete(atrzSeComCd: string, ognzSn: number, userUuid: string): Promise<string>;
}
@@ -0,0 +1,15 @@
import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import { userOgnzService } from "./user-ognz-service";
const userOgnzTreeBaseKey = [ENDPOINT.CMS_SERVICE.USER_ORGANIZATION, "treeuser"] as const;
export function useOgnzUserTree(options?: { enabled?: boolean }) {
return useQuery({
queryKey: userOgnzTreeBaseKey,
queryFn: () => userOgnzService.findOgnzUserTree(),
enabled: options?.enabled ?? true,
});
}
@@ -0,0 +1,18 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
OgnzUserTreeResponseDto,
UserOgnzService,
} from "../model/ognz-user-tree-interface";
async function findOgnzUserTree(): Promise<OgnzUserTreeResponseDto[]> {
const response = await client.get<OgnzUserTreeResponseDto[]>(
`${ENDPOINT.CMS_SERVICE.USER_ORGANIZATION}/treeuser`,
);
return response.data;
}
export const userOgnzService = {
findOgnzUserTree,
} satisfies UserOgnzService;
@@ -0,0 +1,7 @@
export { useOgnzUserTree } from "./api/queries";
export { userOgnzService } from "./api/user-ognz-service";
export type {
OgnzUserTreeResponseDto,
OgnzUserTreeUserDto,
UserOgnzService,
} from "./model/ognz-user-tree-interface";
@@ -0,0 +1,25 @@
/** OgnzUserTreeResponseDto.UserDto */
export interface OgnzUserTreeUserDto {
userUnqId: string;
userNm: string;
jbpsSeCd: string;
jbgdSeCd: string;
jbpsSeNm: string;
jbgdSeNm: string;
}
/** OgnzUserTreeResponseDto */
export interface OgnzUserTreeResponseDto {
ognzSn: number;
upOgnzSn: number | null;
ognzNm: string;
ognzExpln?: string;
sortSeq?: number;
useYn?: string;
users: OgnzUserTreeUserDto[];
children: OgnzUserTreeResponseDto[];
}
export interface UserOgnzService {
findOgnzUserTree(): Promise<OgnzUserTreeResponseDto[]>;
}
@@ -0,0 +1 @@
export { AcntgCodeManagementPage } from "./ui/acntg-code-management-page";
@@ -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<string, AcntgSeBaseEntry>,
): 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<string, AcntgSeBaseEntry> = {};
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<string> {
const result = new Set<string>([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<string>,
): AcntgDisplaySlot[] {
return structure.filter((slot) => !rowKeysToRemove.has(slot.rowKey));
}
export function patchAcntgGridRow(
state: AcntgSeGridState,
rowKey: string,
patch: Partial<AcntgSeRow>,
): 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),
};
}
@@ -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;
}
@@ -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);
}
@@ -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<typeof buildSaveMultiPayload>) => Promise<void>;
}
export function useAcntgSeRows({ serverContent, onAlert, onSave }: UseAcntgSeRowsOptions) {
const [grid, setGrid] = useState<AcntgSeGridState>(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<AcntgSeRow>) => {
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,
};
}
@@ -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<string, AcntgSeBaseEntry>;
structure: AcntgDisplaySlot[];
drafts: Record<string, AcntgSeDraft>;
patches: Record<string, Partial<AcntgSeMngResponseDto>>;
deletedKeys: Set<string>;
}
export interface AcntgSeDisplayItem {
rowKey: string;
depth: number;
}
@@ -0,0 +1,8 @@
import type { AcntgSeMngResponseDto } from "@/entities/acntg";
export interface AcntgSeRow extends AcntgSeMngResponseDto {
rowKey: string;
isNew: boolean;
isDirty: boolean;
isDeleted: boolean;
}
@@ -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<string>,
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<string>();
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;
}
@@ -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<typeof saveMultiMutation.mutateAsync>[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 (
<div className="space-y-6 px-[1.8rem] pb-6">
<section className="card card-border mx-0 bg-background pb-6 pt-0">
<div className="card-header flex flex-wrap items-center justify-between gap-3">
<h3 className="text-2xl font-semibold">회계코드 관리</h3>
<div className="flex flex-wrap items-center gap-3">
<Button type="button" color="green" onClick={addRootRow}>
코드 추가
</Button>
<Button type="button" disabled={!canSave || isSaving} onClick={handleSave}>
저장
</Button>
</div>
</div>
{pageQuery.isPending ? (
<div className="flex flex-col gap-2 px-4 pb-4">
<Skeleton className="h-[4rem]" />
<Skeleton className="h-[16rem]" />
</div>
) : displayRows.length === 0 ? (
<p className="py-8 text-center text-lg text-muted-foreground">
등록된 코드가 없습니다. [코드 추가]로 1단계 코드를 등록하세요.
</p>
) : (
<div className="max-h-[60rem] overflow-y-auto pt-3">
<Table className="w-full">
<colgroup>
<col width="15%" />
<col width="20%" />
<col width="35%" />
<col width="12%" />
<col width="8%" />
<col width="10%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>코드</TableHead>
<TableHead>코드명</TableHead>
<TableHead>설명</TableHead>
<TableHead>사용 여부</TableHead>
<TableHead>구분</TableHead>
<TableHead>관리</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{displayRows.map(({ row, depth }) => (
<AcntgSeRowComponent
key={row.rowKey}
row={row}
depth={depth}
onChange={(patch) => updateRow(row.rowKey, patch)}
onAddChild={() => addChildRow(row)}
onDelete={() => handleDelete(row)}
/>
))}
</TableBody>
</Table>
</div>
)}
</section>
</div>
);
}
export { AcntgCodeManagementPage };
@@ -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<AcntgSeRow>) => void;
onAddChild: () => void;
onDelete: () => void;
}
function AcntgSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: AcntgSeRowProps) {
const levelLabel = formatCdLvNo(row.cdLvNo);
const branchPrefix = formatTreeBranchPrefix(depth);
return (
<TableRow>
<TableCell>
<div className="flex min-w-[10rem] items-center gap-1">
{branchPrefix ? (
<span className="shrink-0 font-mono text-muted">{branchPrefix}</span>
) : null}
<Input
value={row.acntgSeCd}
onChange={(e) => onChange({ acntgSeCd: e.target.value })}
placeholder="코드"
maxLength={10}
disabled={!row.isNew}
className="flex-1"
/>
</div>
</TableCell>
<TableCell>
<Input
value={row.acntgSeCdNm ?? ""}
onChange={(e) => onChange({ acntgSeCdNm: e.target.value })}
placeholder="코드명"
maxLength={100}
/>
</TableCell>
<TableCell>
<Input
value={row.expln ?? ""}
onChange={(e) => onChange({ expln: e.target.value })}
placeholder="설명"
maxLength={1000}
/>
</TableCell>
<TableCell align="center">
<RadioGroup
value={row.useYn === "N" ? "N" : "Y"}
onValueChange={(v) => onChange({ useYn: v })}
className="flex justify-center gap-4"
>
<div className="flex items-center gap-2">
<RadioGroupItem value="Y" id={`${row.rowKey}-use-y`} />
<Label htmlFor={`${row.rowKey}-use-y`}>예</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="N" id={`${row.rowKey}-use-n`} />
<Label htmlFor={`${row.rowKey}-use-n`}>아니오</Label>
</div>
</RadioGroup>
</TableCell>
<TableCell align="center">{levelLabel}</TableCell>
<TableCell align="center">
<div className="flex justify-center gap-2">
<Button
type="button"
size="sm"
color="white"
disabled={!canAddChildRow()}
onClick={onAddChild}
>
하위추가
</Button>
<Button type="button" size="sm" color="gray" onClick={onDelete}>
삭제
</Button>
</div>
</TableCell>
</TableRow>
);
}
export { AcntgSeRowComponent };
@@ -0,0 +1,70 @@
import { ELAPR_APLN_SAVE_MODE, type ElaprApln, type ElaprAplnSaveRequest } from "@/entities/elapr";
import { assignAtrzSeqNumbers } from "./assign-atrz-seq";
import { getVisibleRows } from "./validators";
import type { ElaprAplnRow } from "../model/apln-row-types";
function rowDiffersFromServer(row: ElaprAplnRow, serverRow: ElaprApln): boolean {
return (
row.aprvrUuid !== serverRow.aprvrUuid ||
row.atrzSeComCd !== serverRow.atrzSeComCd ||
row.atrzSeqNo !== serverRow.atrzSeqNo
);
}
function shouldIncludeUpdate(row: ElaprAplnRow, serverRows: ElaprApln[]): boolean {
if (row.isDirty) return true;
if (!row.aplnId) return false;
const serverRow = serverRows.find((s) => s.aplnId === row.aplnId);
if (!serverRow) return false;
return rowDiffersFromServer(row, serverRow);
}
export function buildSaveMultiPayload(
rows: ElaprAplnRow[],
elaprTmpltId: string,
serverRows: ElaprApln[],
): ElaprAplnSaveRequest[] {
const payload: ElaprAplnSaveRequest[] = [];
for (const row of rows) {
if (row.isDeleted && row.aplnId) {
payload.push({
mode: ELAPR_APLN_SAVE_MODE.DELETE,
aplnId: row.aplnId,
});
}
}
const visibleWithSeq = assignAtrzSeqNumbers(getVisibleRows(rows));
for (const row of visibleWithSeq) {
if (row.isNew) {
payload.push({
mode: ELAPR_APLN_SAVE_MODE.CREATE,
elaprTmpltId,
aprvrUuid: row.aprvrUuid,
atrzSeComCd: row.atrzSeComCd,
atrzSeqNo: row.atrzSeqNo,
});
continue;
}
if (!row.aplnId) continue;
if (shouldIncludeUpdate(row, serverRows)) {
payload.push({
mode: ELAPR_APLN_SAVE_MODE.UPDATE,
aplnId: row.aplnId,
elaprTmpltId,
aprvrUuid: row.aprvrUuid,
atrzSeComCd: row.atrzSeComCd,
atrzSeqNo: row.atrzSeqNo,
});
}
}
return payload;
}
@@ -0,0 +1,32 @@
import type { ElaprApln } from "@/entities/elapr";
import { assignAtrzSeqNumbers } from "./assign-atrz-seq";
import { getVisibleRows } from "./validators";
import type { ElaprAplnRow } from "../model/apln-row-types";
function rowDiffersFromServer(row: ElaprAplnRow, serverRow: ElaprApln): boolean {
return (
row.aprvrUuid !== serverRow.aprvrUuid ||
row.atrzSeComCd !== serverRow.atrzSeComCd ||
row.atrzSeqNo !== serverRow.atrzSeqNo
);
}
export function hasUnsavedChanges(rows: ElaprAplnRow[], serverRows: ElaprApln[]): boolean {
if (rows.some((row) => row.isDeleted && row.aplnId)) return true;
if (rows.some((row) => row.isNew && !row.isDeleted)) return true;
const visibleWithSeq = assignAtrzSeqNumbers(getVisibleRows(rows));
for (const row of visibleWithSeq) {
if (row.isNew) continue;
if (!row.aplnId) continue;
if (row.isDirty) return true;
const serverRow = serverRows.find((s) => s.aplnId === row.aplnId);
if (!serverRow) continue;
if (rowDiffersFromServer(row, serverRow)) return true;
}
return false;
}
@@ -9,10 +9,17 @@ import type { ElaprApln } from "@/entities/elapr";
import type { ElaprAplnRow } from "../model/apln-row-types";
import { assignAtrzSeqNumbers } from "./assign-atrz-seq";
import { validateAtrzOrder } from "./validators";
import { getVisibleRows } from "./validators";
function toRow(item: ElaprApln): ElaprAplnRow {
return { ...item, rowKey: item.aplnId, isNew: false, isDirty: false };
return {
...item,
rowKey: item.aplnId,
aprvrNm: item.aprvrNm,
isNew: false,
isDirty: false,
isDeleted: false,
};
}
interface UseAplnRowsOptions {
@@ -39,6 +46,7 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) {
}, [elaprTmpltId, serverRows]);
const isEditingLocked = editingRowKey != null;
const visibleRows = getVisibleRows(rows);
const addRow = useCallback(() => {
if (!elaprTmpltId || isEditingLocked) return;
@@ -55,6 +63,7 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) {
aprvrNm: "",
isNew: true,
isDirty: true,
isDeleted: false,
},
]);
setEditingRowKey(rowKey);
@@ -92,8 +101,8 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) {
return prev.map((row) => {
if (row.rowKey !== editingRowKey) return row;
if (row.snapshot) {
const { snapshot: _s, isNew: _n, isDirty: _d, ...restored } = row.snapshot;
return { ...restored, isNew: false, isDirty: false };
const { snapshot: _s, isNew: _n, isDirty: _d, isDeleted: _del, ...restored } = row.snapshot;
return { ...restored, isNew: false, isDirty: false, isDeleted: false };
}
return { ...row, isDirty: false };
});
@@ -101,52 +110,68 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) {
setEditingRowKey(null);
}, [editingRowKey]);
const clearEditing = useCallback(() => {
setEditingRowKey(null);
}, []);
const commitEdit = useCallback(
(rowKey: string) => {
setRows((prev) =>
prev.map((row) => {
if (row.rowKey !== rowKey) return row;
const { snapshot: _s, ...committed } = row;
return { ...committed, isDirty: true };
}),
);
setEditingRowKey(null);
},
[],
);
const removeRowLocal = useCallback((rowKey: string) => {
setRows((prev) => prev.filter((r) => r.rowKey !== rowKey));
const markDeleted = useCallback((rowKey: string) => {
setRows((prev) => {
const target = prev.find((r) => r.rowKey === rowKey);
if (!target) return prev;
if (target.isNew) {
return prev.filter((r) => r.rowKey !== rowKey);
}
return prev.map((row) =>
row.rowKey === rowKey ? { ...row, isDeleted: true, isDirty: true } : row,
);
});
setEditingRowKey((key) => (key === rowKey ? null : key));
}, []);
const reorderRows = useCallback(
(
activeKey: string,
overKey: string,
): { payloadRows?: ElaprAplnRow[]; orderError?: string | null } => {
if (isEditingLocked) return {};
(activeKey: string, overKey: string): ElaprAplnRow[] => {
if (isEditingLocked) return [];
let payloadRows: ElaprAplnRow[] = [];
let orderError: string | null = null;
setRows((prev) => {
const oldIndex = prev.findIndex((r) => r.rowKey === activeKey);
const newIndex = prev.findIndex((r) => r.rowKey === overKey);
const visible = getVisibleRows(prev);
const oldIndex = visible.findIndex((r) => r.rowKey === activeKey);
const newIndex = visible.findIndex((r) => r.rowKey === overKey);
if (oldIndex < 0 || newIndex < 0) return prev;
const moved = assignAtrzSeqNumbers(arrayMove(prev, oldIndex, newIndex)).map((row) => ({
...row,
isDirty: true,
}));
const reorderedVisible = assignAtrzSeqNumbers(arrayMove(visible, oldIndex, newIndex)).map(
(row) => ({ ...row, isDirty: true }),
);
const error = validateAtrzOrder(moved);
if (error) {
orderError = error;
return prev;
}
const visibleKeys = new Set(reorderedVisible.map((r) => r.rowKey));
const deletedRows = prev.filter((r) => r.isDeleted);
const merged = [...reorderedVisible, ...deletedRows.filter((r) => !visibleKeys.has(r.rowKey))];
payloadRows = moved;
return moved;
payloadRows = reorderedVisible;
return merged;
});
return { payloadRows, orderError };
return payloadRows;
},
[isEditingLocked],
);
return {
rows,
visibleRows,
setRows,
editingRowKey,
isEditingLocked,
@@ -154,8 +179,8 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) {
startEdit,
updateRow,
cancelEdit,
clearEditing,
removeRowLocal,
commitEdit,
markDeleted,
reorderRows,
};
}
@@ -1,37 +1,40 @@
import type { ElaprAplnRow } from "../model/apln-row-types";
export const ATRZ_REVIEWER_CD = "ATRZ01";
export const ATRZ_FINAL_CD = "ATRZ03";
const ATRZ_RECEIVER_CD = "ATRZ01";
const ATRZ_FINAL_CD = "ATRZ99";
/** 검토자 최상단 · 최종승인자 최하단 순서 검증 */
export function getVisibleRows(rows: ElaprAplnRow[]): ElaprAplnRow[] {
return rows.filter((row) => !row.isDeleted);
}
/** 접수자·최종승인자 위치·중복 검증 (최종 저장 시점) */
export function validateAtrzOrder(rows: ElaprAplnRow[]): string | null {
const reviewerIndices = rows
.map((row, index) => (row.atrzSeComCd === ATRZ_REVIEWER_CD ? index : -1))
.filter((index) => index >= 0);
const visibleRows = getVisibleRows(rows);
if (reviewerIndices.length > 0) {
const lastReviewerIndex = Math.max(...reviewerIndices);
const hasNonReviewerAbove = rows
.slice(0, lastReviewerIndex)
.some((row) => row.atrzSeComCd && row.atrzSeComCd !== ATRZ_REVIEWER_CD);
if (hasNonReviewerAbove) {
return "검토자는 결재선 최상단에만 배치할 수 있습니다.";
}
const receiverCount = visibleRows.filter((row) => row.atrzSeComCd === ATRZ_RECEIVER_CD).length;
if (receiverCount === 0) {
return "접수자는 반드시 등록해야 합니다.";
}
if (receiverCount > 1) {
return "접수자는 한 명만 등록할 수 있습니다.";
}
const finalIndices = rows
.map((row, index) => (row.atrzSeComCd === ATRZ_FINAL_CD ? index : -1))
.filter((index) => index >= 0);
const finalCount = visibleRows.filter((row) => row.atrzSeComCd === ATRZ_FINAL_CD).length;
if (finalCount === 0) {
return "최종승인자는 반드시 등록해야 합니다.";
}
if (finalCount > 1) {
return "최종승인자는 한 명만 등록할 수 있습니다.";
}
if (finalIndices.length > 0) {
const firstFinalIndex = Math.min(...finalIndices);
const hasNonFinalBelow = rows
.slice(firstFinalIndex + 1)
.some((row) => row.atrzSeComCd && row.atrzSeComCd !== ATRZ_FINAL_CD);
if (receiverCount === 1 && visibleRows[0]?.atrzSeComCd !== ATRZ_RECEIVER_CD) {
return "접수자는 결재선 첫 번째에 배치해야 합니다.";
}
if (hasNonFinalBelow) {
return "최종승인자는 결재선 최하단에만 배치할 수 있습니다.";
if (finalCount === 1) {
const lastRow = visibleRows[visibleRows.length - 1];
if (lastRow?.atrzSeComCd !== ATRZ_FINAL_CD) {
return "최종승인자는 결재선 마지막에 배치해야 합니다.";
}
}
@@ -48,9 +51,30 @@ export function validateAplnRow(row: ElaprAplnRow): string | null {
return null;
}
export function validateAllRowsForSave(
rows: ElaprAplnRow[],
isEditingLocked: boolean,
): string | null {
if (isEditingLocked) {
return "편집 중인 행을 먼저 저장하거나 취소해 주세요.";
}
const visibleRows = getVisibleRows(rows);
if (visibleRows.length === 0) {
return "저장할 결재선이 없습니다.";
}
for (const row of visibleRows) {
const fieldError = validateAplnRow(row);
if (fieldError) return fieldError;
}
return null;
}
export function validateUniqueSeqNo(rows: ElaprAplnRow[]): string | null {
const seqSet = new Set<number>();
for (const row of rows) {
for (const row of getVisibleRows(rows)) {
if (row.atrzSeqNo == null) continue;
if (seqSet.has(row.atrzSeqNo)) {
return "동일 서식 내 결재순서번호가 중복됩니다.";
@@ -5,10 +5,12 @@ export interface ElaprAplnRow extends Omit<ElaprApln, "aplnId"> {
/** React key · DnD · 편집 잠금용 — 서버 aplnId와 분리 */
rowKey: string;
aplnId?: string;
/** 결재자 표시명 — 응답 DTO에 없음, 선택 시 로컬 유지 */
/** 결재자 표시명 — 응답 DTO 또는 선택 시 로컬 유지 */
aprvrNm?: string;
isNew?: boolean;
isDirty?: boolean;
/** 소프트 삭제 — 최종 저장 시 mode:D */
isDeleted?: boolean;
/** 수정 취소용 스냅샷 */
snapshot?: ElaprAplnRow;
}
@@ -35,10 +35,10 @@ export function createTmpltColumns({ onEdit, onDelete }: CreateTmpltColumnsOptio
header: "관리",
cell: ({ row }) => (
<div className="flex justify-center gap-2" onClick={(e) => e.stopPropagation()}>
<Button type="button" size="sm" onClick={() => onEdit(row.original)}>
<Button type="button" size="sm" color="white" onClick={() => onEdit(row.original)}>
수정
</Button>
<Button type="button" size="sm" color="red" onClick={() => onDelete(row.original)}>
<Button type="button" size="sm" color="gray" onClick={() => onDelete(row.original)}>
삭제
</Button>
</div>
@@ -1,17 +0,0 @@
import { fakerKO as faker } from "@faker-js/faker";
export interface ReviewerOption {
uuid: string;
name: string;
deptNm: string;
}
/** TBD: 검토인력 목록 API 연동 전 목 데이터 */
export function createMockReviewers(count = 12): ReviewerOption[] {
faker.seed(42);
return Array.from({ length: count }, () => ({
uuid: faker.string.uuid(),
name: faker.person.fullName(),
deptNm: faker.commerce.department(),
}));
}
@@ -0,0 +1,15 @@
import type { RvwMnpwMngResponseDto } from "@/entities/rvw-mnpw";
export interface ReviewerOption {
uuid: string;
name: string;
deptNm: string;
}
export function fromRvwMnpwMng(item: RvwMnpwMngResponseDto): ReviewerOption {
return {
uuid: item.userUuid,
name: item.userUuidNm ?? "",
deptNm: item.ognzNm ?? "",
};
}
@@ -1,90 +1,120 @@
"use client";
import { useMemo, useState } from "react";
import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { useRvwMnpwMngPage } from "@/entities/rvw-mnpw";
import {
Button,
DataTable,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Skeleton,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
import type { ReviewerOption } from "../model/mock-reviewers";
import { createMockReviewers } from "../model/mock-reviewers";
import { fromRvwMnpwMng, type ReviewerOption } from "../model/reviewer-option";
const PAGE_SIZE = 500;
interface AprvrPickerDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
atrzSeComCd: string;
onSelect: (reviewer: ReviewerOption) => void;
}
const columnHelper = createColumnHelper<ReviewerOption>();
function AprvrPickerDialog({ open, onOpenChange, onSelect }: AprvrPickerDialogProps) {
const [keyword, setKeyword] = useState("");
const reviewers = useMemo(() => createMockReviewers(), []);
const filtered = useMemo(() => {
const q = keyword.trim().toLowerCase();
if (!q) return reviewers;
return reviewers.filter(
(r) => r.name.toLowerCase().includes(q) || r.deptNm.toLowerCase().includes(q),
);
}, [keyword, reviewers]);
const columns = useMemo(
() => [
columnHelper.accessor("name", { header: "성명" }),
columnHelper.accessor("deptNm", { header: "부서" }),
columnHelper.display({
id: "actions",
header: "선택",
cell: ({ row }) => (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
onSelect(row.original);
onOpenChange(false);
}}
>
선택
</Button>
),
}),
],
[onOpenChange, onSelect],
function AprvrPickerDialog({
open,
onOpenChange,
atrzSeComCd,
onSelect,
}: AprvrPickerDialogProps) {
const { data, isPending, isError } = useRvwMnpwMngPage(
{
atrzSeComCd,
page: 0,
size: PAGE_SIZE,
sort: "userUuidNm,asc",
},
{ enabled: open },
);
const table = useReactTable({
data: filtered,
columns,
getCoreRowModel: getCoreRowModel(),
});
const items = data?.content ?? [];
const handleSelect = (item: (typeof items)[number]) => {
onSelect(fromRvwMnpwMng(item));
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogContent className="max-w-[56rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>검토인력 선택</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<Input
placeholder="성명·부서 검색"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
<DataTable table={table} variant="line" />
<div className="h-[36rem] overflow-auto">
{isPending ? (
<div className="flex flex-col gap-2">
<Skeleton className="h-[4rem]" />
<Skeleton className="h-[16rem]" />
</div>
) : isError ? (
<p className="py-8 text-center text-lg text-muted-foreground">
검토인력 목록을 불러오지 못했습니다.
</p>
) : items.length === 0 ? (
<p className="py-8 text-center text-lg text-muted-foreground">
배치된 검토인력이 없습니다.
</p>
) : (
<Table className="w-full">
<colgroup>
<col width="25%" />
<col width="25%" />
<col width="15%" />
<col width="15%" />
<col width="20%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>성명</TableHead>
<TableHead>부서</TableHead>
<TableHead>직위</TableHead>
<TableHead>직급</TableHead>
<TableHead>선택</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow
key={`${item.atrzSeComCd}-${item.ognzSn}-${item.userUuid}`}
>
<TableCell align="center">
{item.userUuidNm || item.userUuid}
</TableCell>
<TableCell align="center">{item.ognzNm || "-"}</TableCell>
<TableCell align="center">{item.jbpsSeNm || "-"}</TableCell>
<TableCell align="center">{item.jbgdSeNm || "-"}</TableCell>
<TableCell align="center">
<Button type="button" size="sm" onClick={() => handleSelect(item)}>
선택
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
<Button type="button" color="white" onClick={() => onOpenChange(false)}>
닫기
</Button>
</DialogFooter>
@@ -4,14 +4,10 @@ import { useState } from "react";
import { closestCenter, DndContext, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useAlertStore } from "@/shared/lib";
import {
useCreateElaprApln,
useDeleteElaprApln,
useElaprAplnPage,
useUpdateElaprApln,
} from "@/entities/elapr";
import { useElaprAplnPage, useSaveMultiElaprApln } from "@/entities/elapr";
import {
Button,
Skeleton,
@@ -22,12 +18,18 @@ import {
TableRow,
} from "@common/components";
import { assignAtrzSeqNumbers } from "../lib/assign-atrz-seq";
import { buildSaveMultiPayload } from "../lib/build-save-multi-payload";
import { hasUnsavedChanges } from "../lib/has-unsaved-changes";
import { useAplnRows } from "../lib/use-apln-rows";
import { useDndSensors } from "../lib/use-dnd-sensors";
import { validateAplnRow, validateAtrzOrder } from "../lib/validators";
import {
validateAllRowsForSave,
validateAplnRow,
validateAtrzOrder,
validateUniqueSeqNo,
} from "../lib/validators";
import type { ElaprAplnRow } from "../model/apln-row-types";
import type { ReviewerOption } from "../model/mock-reviewers";
import type { ReviewerOption } from "../model/reviewer-option";
import { AprvrPickerDialog } from "./aprvr-picker-dialog";
import { SortableAplnRow } from "./sortable-apln-row";
@@ -37,6 +39,7 @@ interface ElaprAplnGridProps {
}
function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
const { confirm } = useConfirm();
const [pickerOpen, setPickerOpen] = useState(false);
const [pickingRowKey, setPickingRowKey] = useState<string | null>(null);
@@ -49,20 +52,19 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
sort: "atrzSeqNo,asc",
});
const createMutation = useCreateElaprApln();
const updateMutation = useUpdateElaprApln();
const deleteMutation = useDeleteElaprApln();
const saveMultiMutation = useSaveMultiElaprApln();
const {
rows,
visibleRows,
editingRowKey,
isEditingLocked,
addRow,
startEdit,
updateRow,
cancelEdit,
clearEditing,
removeRowLocal,
commitEdit,
markDeleted,
reorderRows,
} = useAplnRows({
elaprTmpltId,
@@ -71,116 +73,65 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
const sensors = useDndSensors();
const isDisabled = !elaprTmpltId;
const serverRows = aplnQuery.data?.content ?? [];
const canSave = hasUnsavedChanges(rows, serverRows);
const pickingRow = pickingRowKey ? rows.find((r) => r.rowKey === pickingRowKey) : undefined;
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const { orderError } = reorderRows(String(active.id), String(over.id));
if (orderError) {
alert(orderError);
}
reorderRows(String(active.id), String(over.id));
};
const handleRowChange = (row: ElaprAplnRow, patch: Partial<ElaprAplnRow>) => {
updateRow(row.rowKey, patch);
};
const handleRowSave = async (row: ElaprAplnRow) => {
const handleRowSave = (row: ElaprAplnRow) => {
const fieldError = validateAplnRow(row);
if (fieldError) {
alert(fieldError);
return;
}
const rowsWithSeq = assignAtrzSeqNumbers(rows.map((r) => (r.rowKey === row.rowKey ? row : r)));
const savedRow = rowsWithSeq.find((r) => r.rowKey === row.rowKey);
if (!savedRow) return;
const orderError = validateAtrzOrder(rowsWithSeq);
if (orderError) {
alert(orderError);
return;
}
if (!elaprTmpltId) return;
if (savedRow.isNew) {
await createMutation.mutateAsync({
elaprTmpltId,
aprvrUuid: savedRow.aprvrUuid,
atrzSeComCd: savedRow.atrzSeComCd,
atrzSeqNo: savedRow.atrzSeqNo,
});
} else if (savedRow.aplnId) {
await updateMutation.mutateAsync({
aplnId: savedRow.aplnId,
body: {
elaprTmpltId,
aprvrUuid: savedRow.aprvrUuid,
atrzSeComCd: savedRow.atrzSeComCd,
atrzSeqNo: savedRow.atrzSeqNo,
},
});
}
clearEditing();
await aplnQuery.refetch();
commitEdit(row.rowKey);
};
const handleRowDelete = async (row: ElaprAplnRow) => {
if (!window.confirm("선택한 결재선을 삭제하시겠습니까?")) return;
markDeleted(row.rowKey);
};
if (row.isNew) {
removeRowLocal(row.rowKey);
const handleSaveAll = async () => {
if (!elaprTmpltId) return;
const rowsError = validateAllRowsForSave(rows, isEditingLocked);
if (rowsError) {
alert(rowsError);
return;
}
if (!elaprTmpltId || !row.aplnId) return;
await deleteMutation.mutateAsync({ aplnId: row.aplnId, elaprTmpltId });
await aplnQuery.refetch();
};
const handleBulkSave = async () => {
if (!elaprTmpltId || isEditingLocked) return;
const rowsWithSeq = assignAtrzSeqNumbers(rows);
const orderError = validateAtrzOrder(rowsWithSeq);
const orderError = validateAtrzOrder(rows);
if (orderError) {
alert(orderError);
return;
}
const dirtyRows = rowsWithSeq.filter((r) => r.isDirty || r.isNew);
for (const row of dirtyRows) {
const fieldError = validateAplnRow(row);
if (fieldError) {
alert(fieldError);
return;
}
if (row.isNew) {
await createMutation.mutateAsync({
elaprTmpltId,
aprvrUuid: row.aprvrUuid,
atrzSeComCd: row.atrzSeComCd,
atrzSeqNo: row.atrzSeqNo,
});
} else if (row.aplnId) {
await updateMutation.mutateAsync({
aplnId: row.aplnId,
body: {
elaprTmpltId,
aprvrUuid: row.aprvrUuid,
atrzSeComCd: row.atrzSeComCd,
atrzSeqNo: row.atrzSeqNo,
},
});
}
const seqError = validateUniqueSeqNo(rows);
if (seqError) {
alert(seqError);
return;
}
const payload = buildSaveMultiPayload(rows, elaprTmpltId, serverRows);
if (payload.length === 0) {
alert("저장할 변경 사항이 없습니다.");
return;
}
if (!(await confirm("결재선을 저장하시겠습니까?"))) return;
await saveMultiMutation.mutateAsync({ body: payload, elaprTmpltId });
alert("결재선이 저장되었습니다.");
await aplnQuery.refetch();
};
@@ -201,13 +152,17 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
color="white"
color="green"
disabled={isDisabled || isEditingLocked}
onClick={addRow}
>
+ 행 추가
결재선 추가
</Button>
<Button type="button" disabled={isDisabled || isEditingLocked} onClick={handleBulkSave}>
<Button
type="button"
disabled={isDisabled || isEditingLocked || !canSave || saveMultiMutation.isPending}
onClick={handleSaveAll}
>
결재선 저장
</Button>
</div>
@@ -222,13 +177,22 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
<p className="py-8 text-center text-lg text-muted-foreground">
상단 목록에서 결재서식을 선택해 주세요.
</p>
) : rows.length === 0 ? (
) : visibleRows.length === 0 ? (
<p className="py-8 text-center text-lg text-muted-foreground">
등록된 결재선이 없습니다. [행 추가]로 결재자를 등록하세요.
등록된 결재선이 없습니다. [결재선 추가]로 결재자를 등록하세요.
</p>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<Table className="w-full">
<colgroup>
<col width="10%" />
<col width="30%" />
<col width="10%" />
<col width="10%" />
<col width="15%" />
<col width="15%" />
<col width="10%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead className="w-10" />
@@ -242,10 +206,10 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
</TableHeader>
<TableBody>
<SortableContext
items={rows.map((r) => r.rowKey)}
items={visibleRows.map((r) => r.rowKey)}
strategy={verticalListSortingStrategy}
>
{rows.map((row, index) => (
{visibleRows.map((row, index) => (
<SortableAplnRow
key={row.rowKey}
row={row}
@@ -273,6 +237,7 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
<AprvrPickerDialog
open={pickerOpen}
onOpenChange={setPickerOpen}
atrzSeComCd={pickingRow?.atrzSeComCd ?? ""}
onSelect={handleReviewerSelect}
/>
</section>
@@ -2,6 +2,7 @@
import { useMemo, useState } from "react";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { getCoreRowModel, type RowSelectionState } from "@tanstack/react-table";
import { useDataTable } from "@/shared/lib";
@@ -24,6 +25,7 @@ interface ElaprTmpltListProps {
}
function ElaprTmpltList({ selectedTmpltId, onSelectTmplt }: ElaprTmpltListProps) {
const { confirm } = useConfirm();
const [dialogMode, setDialogMode] = useState<"create" | "edit" | null>(null);
const [editingTmplt, setEditingTmplt] = useState<ElaprTmplt | null>(null);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
@@ -46,14 +48,14 @@ function ElaprTmpltList({ selectedTmpltId, onSelectTmplt }: ElaprTmpltListProps)
setDialogMode("edit");
},
onDelete: async (row) => {
if (!window.confirm("선택한 결재서식을 삭제하시겠습니까?")) return;
if (!(await confirm("선택한 결재서식을 삭제하시겠습니까?"))) return;
await deleteMutation.mutateAsync(row.elaprTmpltId);
if (selectedTmpltId === row.elaprTmpltId) {
onSelectTmplt(null);
}
},
}),
[deleteMutation, onSelectTmplt, selectedTmpltId],
[confirm, deleteMutation, onSelectTmplt, selectedTmpltId],
);
const { tableComponent } = useDataTable({
@@ -112,7 +114,7 @@ function ElaprTmpltList({ selectedTmpltId, onSelectTmplt }: ElaprTmpltListProps)
<section className="card card-border mx-0 bg-background pt-0">
<div className="card-header flex items-center justify-between">
<h3 className="text-2xl font-semibold">전자결재선 관리 목록</h3>
<Button type="button" onClick={() => setDialogMode("create")}>
<Button type="button" color="green" onClick={() => setDialogMode("create")}>
서식 등록
</Button>
</div>
@@ -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 (
<TableRow ref={setNodeRef} style={style} className={isEditing ? "bg-accent/30" : undefined}>
@@ -72,7 +72,7 @@ function SortableAplnRow({
<TableCell align="center">
{isEditing ? (
<CodeSelect
upCd="ATRZSECD"
upCd="ATRZSE"
className="min-w-[120px]"
value={row.atrzSeComCd ?? ""}
onValueChange={(v) =>
@@ -103,7 +103,7 @@ function SortableAplnRow({
{isEditing ? (
<>
<Button type="button" size="sm" onClick={onSave}>
저장
확인
</Button>
<Button type="button" size="sm" color="white" onClick={onCancel}>
취소
@@ -111,10 +111,22 @@ function SortableAplnRow({
</>
) : (
<>
<Button type="button" size="sm" disabled={rowDisabled} onClick={onStartEdit}>
<Button
type="button"
size="sm"
color="white"
disabled={rowDisabled}
onClick={onStartEdit}
>
수정
</Button>
<Button type="button" size="sm" color="red" disabled={rowDisabled} onClick={onDelete}>
<Button
type="button"
size="sm"
color="gray"
disabled={rowDisabled}
onClick={onDelete}
>
삭제
</Button>
</>
@@ -3,7 +3,7 @@
import { useState } from "react";
import Image from "next/image";
import { LoginApi } from "@/entities/auth";
import { authService } from "@/entities/auth";
import { images } from "@common/assets";
import "./login.css";
@@ -20,7 +20,7 @@ function LoginPage() {
const password = formData.get("password") as string;
try {
await LoginApi.login({ userId, password });
await authService.login({ userId, password });
window.location.href = "/";
} catch (error) {
const message = error instanceof Error ? error.message : "로그인에 실패했습니다.";
@@ -0,0 +1 @@
export { ChecklistManagementPage } from "./ui/checklist-management-page";
@@ -0,0 +1,90 @@
import type { CklstRvwRow } from "./cklst-rvw-row-types";
export const MAX_RVW_CN_LV_NO = 3;
export interface CklstRvwDisplayRow {
row: CklstRvwRow;
depth: number;
}
export function formatRvwCnLvNo(lvNo?: number): string {
if (lvNo === 3) return "3단계";
if (lvNo === 2) return "2단계";
return "1단계";
}
export function canAddChildRow(lvNo?: number): boolean {
return (lvNo ?? 1) < MAX_RVW_CN_LV_NO;
}
const TREE_BRANCH_ENTITY = {
horizontal: "&boxh;",
vertical: "&boxv;",
branch: "&boxvr;",
} as const;
const TREE_BRANCH_ENTITY_CHARS: Record<string, string> = {
[TREE_BRANCH_ENTITY.horizontal]: "\u2500",
[TREE_BRANCH_ENTITY.vertical]: "\u2502",
[TREE_BRANCH_ENTITY.branch]: "\u251C",
};
function decodeTreeBranchEntityCodes(value: string): string {
return Object.entries(TREE_BRANCH_ENTITY_CHARS).reduce(
(text, [entity, char]) => text.replaceAll(entity, char),
value,
);
}
export function formatTreeBranchPrefix(depth: number): string {
if (depth === 1)
return decodeTreeBranchEntityCodes(
`${TREE_BRANCH_ENTITY.branch}${TREE_BRANCH_ENTITY.horizontal}`,
);
if (depth === 2) {
return decodeTreeBranchEntityCodes(
`${TREE_BRANCH_ENTITY.vertical} ${TREE_BRANCH_ENTITY.branch}${TREE_BRANCH_ENTITY.horizontal}`,
);
}
return "";
}
function compareSortSeq(a: CklstRvwRow, b: CklstRvwRow): number {
return (a.sortSeq ?? 0) - (b.sortSeq ?? 0);
}
export function buildRvwDisplayRows(rows: CklstRvwRow[]): CklstRvwDisplayRow[] {
const result: CklstRvwDisplayRow[] = [];
const roots = rows
.filter((row) => (row.rvwCnLvNo ?? 1) === 1 && row.upCklstRvwSn == null)
.sort(compareSortSeq);
function appendSubtree(parent: CklstRvwRow, depth: number) {
const children = rows
.filter((row) => row.upCklstRvwSn != null && row.upCklstRvwSn === parent.cklstRvwSn)
.sort(compareSortSeq);
for (const child of children) {
result.push({ row: child, depth });
if (depth < MAX_RVW_CN_LV_NO - 1) {
appendSubtree(child, depth + 1);
}
}
}
for (const root of roots) {
result.push({ row: root, depth: 0 });
appendSubtree(root, 1);
}
const orphans = rows.filter(
(row) => !result.some((display) => display.row.rowKey === row.rowKey),
);
for (const orphan of orphans.sort(compareSortSeq)) {
result.push({ row: orphan, depth: Math.max(0, (orphan.rvwCnLvNo ?? 1) - 1) });
}
return result;
}
@@ -0,0 +1,51 @@
"use client";
import { createColumnHelper } from "@tanstack/react-table";
import type { CklstArtcl } from "@/entities/cklst";
import { Button } from "@common/components";
const columnHelper = createColumnHelper<CklstArtcl>();
interface CreateCklstArtclColumnsOptions {
onEdit: (row: CklstArtcl) => void;
onDelete: (row: CklstArtcl) => void;
}
export function createCklstArtclColumns({ onEdit, onDelete }: CreateCklstArtclColumnsOptions) {
return [
columnHelper.accessor("cklstArtclSn", {
header: "점검항목코드",
meta: { align: "center" },
}),
columnHelper.accessor("cklstArtclNm", {
header: "점검항목명",
cell: ({ getValue }) => getValue() ?? "-",
}),
columnHelper.accessor("useYn", {
header: "사용여부",
cell: ({ getValue }) => {
const value = getValue();
return value === "Y" ? "예" : value === "N" ? "아니오" : "-";
},
}),
columnHelper.accessor("regDt", {
header: "등록일시",
cell: ({ getValue }) => getValue() ?? "-",
}),
columnHelper.display({
id: "actions",
header: "관리",
cell: ({ row }) => (
<div className="flex justify-center gap-2">
<Button type="button" size="sm" color="white" onClick={() => onEdit(row.original)}>
관리
</Button>
<Button type="button" size="sm" color="gray" onClick={() => onDelete(row.original)}>
삭제
</Button>
</div>
),
}),
];
}
@@ -0,0 +1,64 @@
"use client";
import { createColumnHelper } from "@tanstack/react-table";
import type { CklstMng } from "@/entities/cklst";
import { Button } from "@common/components";
type CklstMngRow = CklstMng & {
docSeComNm?: string;
taskSeCdNm?: string;
};
const columnHelper = createColumnHelper<CklstMngRow>();
interface CreateCklstMngColumnsOptions {
onEdit: (row: CklstMngRow) => void;
onDelete: (row: CklstMngRow) => void;
}
export function createCklstMngColumns({ onEdit, onDelete }: CreateCklstMngColumnsOptions) {
return [
columnHelper.accessor("cklstSn", {
header: "점검표일련번호",
meta: { align: "center" },
}),
columnHelper.accessor("cklstNm", {
header: "점검표명",
meta: { align: "left" },
}),
columnHelper.accessor("docSeComNm", {
header: "문서구분",
cell: ({ getValue, row }) => getValue() ?? row.original.docSeComCd ?? "-",
}),
columnHelper.accessor("taskSeCdNm", {
header: "업무구분",
cell: ({ getValue, row }) => getValue() ?? row.original.taskSeComCd ?? "-",
}),
columnHelper.accessor("useYn", {
header: "사용여부",
cell: ({ getValue }) => {
const value = getValue();
return value === "Y" ? "예" : value === "N" ? "아니오" : "-";
},
}),
columnHelper.accessor("regDt", {
header: "등록일시",
cell: ({ getValue }) => getValue() ?? "-",
}),
columnHelper.display({
id: "actions",
header: "관리",
cell: ({ row }) => (
<div className="flex justify-center gap-2" onClick={(e) => e.stopPropagation()}>
<Button type="button" size="sm" color="white" onClick={() => onEdit(row.original)}>
관리
</Button>
<Button type="button" size="sm" color="gray" onClick={() => onDelete(row.original)}>
삭제
</Button>
</div>
),
}),
];
}
@@ -0,0 +1,8 @@
import type { CklstRvw } from "@/entities/cklst";
export interface CklstRvwRow extends Omit<CklstRvw, "cklstRvwSn"> {
cklstRvwSn?: number;
rowKey: string;
isNew: boolean;
isDirty: boolean;
}
@@ -0,0 +1,28 @@
"use client";
import { useState } from "react";
import type { CklstMng } from "@/entities/cklst";
import { CklstArtclList } from "./cklst-artcl-list";
import { CklstMngList } from "./cklst-mng-list";
import { CklstRvwGrid } from "./cklst-rvw-grid";
function ChecklistManagementPage() {
const [selectedCklst, setSelectedCklst] = useState<CklstMng | null>(null);
return (
<div className="space-y-6 px-[1.8rem] pb-6">
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
<CklstMngList
selectedCklstSn={selectedCklst?.cklstSn ?? null}
onSelectCklst={setSelectedCklst}
/>
<CklstArtclList cklstSn={selectedCklst?.cklstSn} />
</div>
<CklstRvwGrid selectedCklst={selectedCklst} />
</div>
);
}
export { ChecklistManagementPage };
@@ -0,0 +1,112 @@
"use client";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import type { CklstArtcl } from "@/entities/cklst";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
export interface CklstArtclFormValues {
cklstArtclNm: string;
useYn: "Y" | "N";
}
interface CklstArtclFormDialogProps {
mode: "create" | "edit";
open: boolean;
onOpenChange: (open: boolean) => void;
initial?: CklstArtcl | null;
isSubmitting?: boolean;
onSubmit: (values: CklstArtclFormValues) => void;
}
function CklstArtclFormDialog({
mode,
open,
onOpenChange,
initial,
isSubmitting,
onSubmit,
}: CklstArtclFormDialogProps) {
const { register, handleSubmit, reset, setValue, watch } = useForm<CklstArtclFormValues>({
defaultValues: {
cklstArtclNm: "",
useYn: "Y",
},
});
const useYn = watch("useYn");
useEffect(() => {
if (!open) return;
if (mode === "edit" && initial) {
reset({
cklstArtclNm: initial.cklstArtclNm ?? "",
useYn: initial.useYn ?? "Y",
});
return;
}
reset({
cklstArtclNm: "",
useYn: "Y",
});
}, [open, mode, initial, reset]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[40rem]">
<DialogHeader>
<DialogTitle>{mode === "create" ? "점검항목 추가" : "점검항목 수정"}</DialogTitle>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-2">
<Label htmlFor="cklstArtclNm">점검항목명</Label>
<Input
id="cklstArtclNm"
placeholder="예: 법령검토"
{...register("cklstArtclNm", { required: true })}
/>
</div>
<div className="space-y-2">
<Label>사용여부</Label>
<Select value={useYn} onValueChange={(v) => setValue("useYn", v as "Y" | "N")}>
<SelectTrigger className="w-full">
<SelectValue placeholder="사용여부" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Y">예</SelectItem>
<SelectItem value="N">아니오</SelectItem>
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button type="button" color="white" onClick={() => onOpenChange(false)}>
취소
</Button>
<Button type="submit" disabled={isSubmitting}>
저장
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export { CklstArtclFormDialog };
@@ -0,0 +1,138 @@
"use client";
import { useMemo, useState } from "react";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { matchQueryStatus } from "@/shared/lib";
import { NoData } from "@/shared/ui/no-data";
import {
type CklstArtcl,
useCklstArtclList,
useCreateCklstArtcl,
useDeleteCklstArtcl,
useUpdateCklstArtcl,
} from "@/entities/cklst";
import { Button, DataTable, Skeleton } from "@common/components";
import { createCklstArtclColumns } from "../model/cklst-artcl-columns";
import { CklstArtclFormDialog, type CklstArtclFormValues } from "./cklst-artcl-form-dialog";
interface CklstArtclListProps {
cklstSn?: number;
}
function CklstArtclList({ cklstSn }: CklstArtclListProps) {
const { confirm } = useConfirm();
const [dialogMode, setDialogMode] = useState<"create" | "edit" | null>(null);
const [editingArtcl, setEditingArtcl] = useState<CklstArtcl | null>(null);
const artclQuery = useCklstArtclList(cklstSn);
const createMutation = useCreateCklstArtcl();
const updateMutation = useUpdateCklstArtcl();
const deleteMutation = useDeleteCklstArtcl();
const columns = useMemo(
() =>
createCklstArtclColumns({
onEdit: (row) => {
setEditingArtcl(row);
setDialogMode("edit");
},
onDelete: async (row) => {
if (!(await confirm("선택한 점검항목을 삭제하시겠습니까?"))) return;
await deleteMutation.mutateAsync({
cklstArtclSn: row.cklstArtclSn,
cklstSn: row.cklstSn,
});
},
}),
[confirm, deleteMutation],
);
const table = useReactTable({
data: artclQuery.data ?? [],
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => `${row.cklstArtclSn}-${row.cklstSn}`,
});
const tableComponent = matchQueryStatus(artclQuery, {
Pending: (
<div className="flex flex-col gap-2">
<Skeleton className="h-[4rem]" />
<Skeleton className="h-[12rem]" />
</div>
),
Empty: <NoData />,
Success: () => <DataTable table={table} />,
});
const handleFormSubmit = async (values: CklstArtclFormValues) => {
if (!cklstSn) return;
if (dialogMode === "create") {
await createMutation.mutateAsync({
cklstSn,
cklstArtclNm: values.cklstArtclNm,
useYn: values.useYn,
});
} else if (dialogMode === "edit" && editingArtcl) {
await updateMutation.mutateAsync({
cklstArtclSn: editingArtcl.cklstArtclSn,
cklstSn: editingArtcl.cklstSn,
body: {
cklstArtclNm: values.cklstArtclNm,
useYn: values.useYn,
},
});
}
setDialogMode(null);
setEditingArtcl(null);
};
const isDisabled = !cklstSn;
return (
<section className="card card-border mx-0 bg-background pt-0">
<div className="card-header flex items-center justify-between">
<h3 className="text-2xl font-semibold">점검항목 관리</h3>
<Button
type="button"
color="green"
disabled={isDisabled}
onClick={() => setDialogMode("create")}
>
점검항목 추가
</Button>
</div>
{isDisabled ? (
<p className="py-8 text-center text-lg text-muted-foreground">
점검표를 선택하면 점검항목을 관리할 수 있습니다.
</p>
) : (
tableComponent
)}
{dialogMode && (
<CklstArtclFormDialog
mode={dialogMode}
open={Boolean(dialogMode)}
onOpenChange={(open) => {
if (!open) {
setDialogMode(null);
setEditingArtcl(null);
}
}}
initial={dialogMode === "edit" ? editingArtcl : null}
isSubmitting={createMutation.isPending || updateMutation.isPending}
onSubmit={handleFormSubmit}
/>
)}
</section>
);
}
export { CklstArtclList };
@@ -0,0 +1,149 @@
"use client";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import type { CklstMng } from "@/entities/cklst";
import { CodeSelect } from "@/entities/code";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
export interface CklstMngFormValues {
cklstNm: string;
docSeComCd: string;
taskSeComCd: string;
useYn: "Y" | "N";
}
interface CklstMngFormDialogProps {
mode: "create" | "edit";
open: boolean;
onOpenChange: (open: boolean) => void;
initial?: CklstMng | null;
isSubmitting?: boolean;
onSubmit: (values: CklstMngFormValues) => void;
}
function CklstMngFormDialog({
mode,
open,
onOpenChange,
initial,
isSubmitting,
onSubmit,
}: CklstMngFormDialogProps) {
const { register, handleSubmit, reset, setValue, watch } = useForm<CklstMngFormValues>({
defaultValues: {
cklstNm: "",
docSeComCd: "",
taskSeComCd: "",
useYn: "Y",
},
});
const docSeComCd = watch("docSeComCd");
const useYn = watch("useYn");
useEffect(() => {
if (!open) return;
if (mode === "edit" && initial) {
reset({
cklstNm: initial.cklstNm,
docSeComCd: initial.docSeComCd ?? "",
taskSeComCd: initial.taskSeComCd,
useYn: initial.useYn ?? "Y",
});
return;
}
reset({
cklstNm: "",
docSeComCd: "",
taskSeComCd: "",
useYn: "Y",
});
}, [open, mode, initial, reset]);
useEffect(() => {
if (mode === "create" && docSeComCd) {
setValue("taskSeComCd", "");
}
}, [docSeComCd, mode, setValue]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[52rem]">
<DialogHeader>
<DialogTitle>{mode === "create" ? "점검표 추가" : "점검표 수정"}</DialogTitle>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-2">
<Label htmlFor="cklstNm">점검표명</Label>
<Input
id="cklstNm"
placeholder="점검표명 입력"
{...register("cklstNm", { required: true })}
/>
</div>
<div className="space-y-2">
<Label>문서구분</Label>
<CodeSelect
upCd="ASDOCSECD"
value={docSeComCd}
onValueChange={(v) => setValue("docSeComCd", v)}
placeholder="문서구분 선택"
className="w-full"
/>
</div>
<div className="space-y-2">
<Label>업무구분</Label>
<CodeSelect
upCd={docSeComCd || "ASDOCSECD"}
value={watch("taskSeComCd")}
onValueChange={(v) => setValue("taskSeComCd", v)}
placeholder="업무구분 선택"
className="w-full"
disabled={!docSeComCd}
/>
</div>
<div className="space-y-2">
<Label>사용여부</Label>
<Select value={useYn} onValueChange={(v) => setValue("useYn", v as "Y" | "N")}>
<SelectTrigger className="w-full">
<SelectValue placeholder="사용여부" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Y">예</SelectItem>
<SelectItem value="N">아니오</SelectItem>
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button type="button" color="white" onClick={() => onOpenChange(false)}>
취소
</Button>
<Button type="submit" disabled={isSubmitting}>
저장
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export { CklstMngFormDialog };
@@ -0,0 +1,150 @@
"use client";
import { useMemo, useState } from "react";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { getCoreRowModel, type RowSelectionState } from "@tanstack/react-table";
import { useDataTable } from "@/shared/lib";
import {
type CklstMng,
useCklstMngPage,
useCreateCklstMng,
useDeleteCklstMng,
useUpdateCklstMng,
} from "@/entities/cklst";
import { Button } from "@common/components";
import { createCklstMngColumns } from "../model/cklst-mng-columns";
import { CklstMngFormDialog, type CklstMngFormValues } from "./cklst-mng-form-dialog";
interface CklstMngListProps {
selectedCklstSn: number | null;
onSelectCklst: (cklst: CklstMng | null) => void;
}
function CklstMngList({ selectedCklstSn, onSelectCklst }: CklstMngListProps) {
const { confirm } = useConfirm();
const [dialogMode, setDialogMode] = useState<"create" | "edit" | null>(null);
const [editingCklst, setEditingCklst] = useState<CklstMng | null>(null);
const [rowSelection, setRowSelection] = useState<RowSelectionState>(() =>
selectedCklstSn != null ? { [String(selectedCklstSn)]: true } : {},
);
const mngQuery = useCklstMngPage({
page: 0,
size: 50,
sort: "regDt,desc",
});
const createMutation = useCreateCklstMng();
const updateMutation = useUpdateCklstMng();
const deleteMutation = useDeleteCklstMng();
const columns = useMemo(
() =>
createCklstMngColumns({
onEdit: (row) => {
setEditingCklst(row);
setDialogMode("edit");
},
onDelete: async (row) => {
if (!(await confirm("선택한 점검표를 삭제하시겠습니까?"))) return;
await deleteMutation.mutateAsync(row.cklstSn);
if (selectedCklstSn === row.cklstSn) {
onSelectCklst(null);
setRowSelection({});
}
},
}),
[confirm, deleteMutation, onSelectCklst, selectedCklstSn],
);
const { tableComponent } = useDataTable({
query: mngQuery,
tableOptions: {
columns,
state: { rowSelection },
enableRowSelection: true,
enableMultiRowSelection: false,
getRowId: (row) => String(row.cklstSn),
onRowSelectionChange: (updater) => {
const next = typeof updater === "function" ? updater(rowSelection) : updater;
setRowSelection(next);
const selectedId = Object.keys(next)[0];
if (!selectedId) {
onSelectCklst(null);
return;
}
const selected = mngQuery.data?.content.find((c) => String(c.cklstSn) === selectedId);
onSelectCklst(selected ?? null);
},
getCoreRowModel: getCoreRowModel(),
},
dataTableOptions: {
onRowClick: (row) => {
setRowSelection({ [String(row.cklstSn)]: true });
onSelectCklst(row);
},
},
});
const handleFormSubmit = async (values: CklstMngFormValues) => {
const body = {
cklstNm: values.cklstNm,
taskSeComCd: values.taskSeComCd,
useYn: values.useYn,
docSeComCd: values.docSeComCd || undefined,
};
if (dialogMode === "create") {
const cklstSn = await createMutation.mutateAsync(body);
const { data } = await mngQuery.refetch();
const created = data?.content.find((c) => c.cklstSn === cklstSn);
if (created) {
onSelectCklst(created);
setRowSelection({ [String(created.cklstSn)]: true });
}
} else if (dialogMode === "edit" && editingCklst) {
await updateMutation.mutateAsync({ cklstSn: editingCklst.cklstSn, body });
const { data } = await mngQuery.refetch();
const updated = data?.content.find((c) => c.cklstSn === editingCklst.cklstSn);
if (updated && selectedCklstSn === editingCklst.cklstSn) {
onSelectCklst(updated);
}
}
setDialogMode(null);
setEditingCklst(null);
};
return (
<section className="card card-border mx-0 bg-background pt-0">
<div className="card-header flex items-center justify-between">
<h3 className="text-2xl font-semibold">점검표 관리</h3>
<Button type="button" color="green" onClick={() => setDialogMode("create")}>
점검표 추가
</Button>
</div>
{tableComponent}
{dialogMode && (
<CklstMngFormDialog
mode={dialogMode}
open={Boolean(dialogMode)}
onOpenChange={(open) => {
if (!open) {
setDialogMode(null);
setEditingCklst(null);
}
}}
initial={dialogMode === "edit" ? editingCklst : null}
isSubmitting={createMutation.isPending || updateMutation.isPending}
onSubmit={handleFormSubmit}
/>
)}
</section>
);
}
export { CklstMngList };
@@ -0,0 +1,107 @@
"use client";
import type { CklstArtcl } from "@/entities/cklst";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
Label,
RadioGroup,
RadioGroupItem,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
import { buildRvwDisplayRows, formatTreeBranchPrefix } from "../model/build-rvw-display-rows";
import type { CklstRvwRow } from "../model/cklst-rvw-row-types";
interface CklstPreviewDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
cklstNm: string;
rows: CklstRvwRow[];
artclOptions: CklstArtcl[];
}
function CklstPreviewDialog({
open,
onOpenChange,
cklstNm,
rows,
artclOptions,
}: CklstPreviewDialogProps) {
const displayRows = buildRvwDisplayRows(rows);
function getArtclLabel(cklstArtclSn: number): string {
const artcl = artclOptions.find((item) => item.cklstArtclSn === cklstArtclSn);
return artcl?.cklstArtclNm ?? String(cklstArtclSn);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[64rem]">
<DialogHeader>
<DialogTitle>체크리스트 미리보기 — {cklstNm}</DialogTitle>
</DialogHeader>
{displayRows.length === 0 ? (
<p className="py-6 text-center text-muted-foreground">표시할 항목이 없습니다.</p>
) : (
<Table>
<colgroup>
<col width="25%" />
<col width="50%" />
<col width="25%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead align="left">점검항목</TableHead>
<TableHead align="left">주요 검토내용</TableHead>
<TableHead>검토 여부</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{displayRows.map(({ row, depth }) => {
const branchPrefix = formatTreeBranchPrefix(depth);
if (row.useYn === "N") return null;
return (
<TableRow key={row.rowKey}>
<TableCell align="left">
<span className="inline-flex items-center gap-1">
{branchPrefix ? (
<span className="font-mono text-muted">{branchPrefix}</span>
) : null}
<span>{getArtclLabel(row.cklstArtclSn)}</span>
</span>
</TableCell>
<TableCell>{row.rvwCn ?? "—"}</TableCell>
<TableCell align="center">
<RadioGroup className="flex items-center justify-center">
<div className="flex items-center gap-1">
<RadioGroupItem value="Y" id={`${row.rowKey}-use-y`} />
<Label htmlFor={`${row.rowKey}-use-y`}>예</Label>
</div>
<div className="flex items-center gap-1">
<RadioGroupItem value="N" id={`${row.rowKey}-use-n`} />
<Label htmlFor={`${row.rowKey}-use-n`}>아니오</Label>
</div>
</RadioGroup>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</DialogContent>
</Dialog>
);
}
export { CklstPreviewDialog };
@@ -0,0 +1,324 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useAlertStore } from "@/shared/lib";
import type { CklstMng, CklstRvw } from "@/entities/cklst";
import {
useCklstArtclList,
useCklstRvwPage,
useCreateCklstRvw,
useDeleteCklstRvw,
useUpdateCklstRvw,
} from "@/entities/cklst";
import {
Button,
Skeleton,
Table,
TableBody,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
import { buildRvwDisplayRows, MAX_RVW_CN_LV_NO } from "../model/build-rvw-display-rows";
import type { CklstRvwRow } from "../model/cklst-rvw-row-types";
import { CklstPreviewDialog } from "./cklst-preview-dialog";
import { CklstRvwRowComponent } from "./cklst-rvw-row";
interface CklstRvwGridProps {
selectedCklst: CklstMng | null;
}
function toRow(item: CklstRvw): CklstRvwRow {
return {
...item,
rowKey: `${item.cklstRvwSn}-${item.cklstArtclSn}-${item.cklstSn}`,
isNew: false,
isDirty: false,
};
}
function createDraftRowKey(): string {
return `draft-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
function validateRvwRow(row: CklstRvwRow): string | null {
if (!row.cklstArtclSn) return "점검항목을 선택하세요.";
if (!row.rvwCn?.trim()) return "주요 검토내용을 입력하세요.";
return null;
}
function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) {
const { confirm } = useConfirm();
const [rows, setRows] = useState<CklstRvwRow[]>([]);
const [previewOpen, setPreviewOpen] = useState(false);
const alert = useAlertStore((state) => state.setMessage);
const cklstSn = selectedCklst?.cklstSn ?? null;
const rvwQuery = useCklstRvwPage({
cklstSn: cklstSn ?? undefined,
page: 0,
size: 500,
sort: "sortSeq,asc",
});
const artclQuery = useCklstArtclList(cklstSn);
const createMutation = useCreateCklstRvw();
const updateMutation = useUpdateCklstRvw();
const deleteMutation = useDeleteCklstRvw();
const artclOptions = useMemo(
() => (artclQuery.data ?? []).filter((artcl) => artcl.useYn !== "N"),
[artclQuery.data],
);
useEffect(() => {
if (!cklstSn) {
setRows([]);
return;
}
setRows((rvwQuery.data?.content ?? []).map(toRow));
}, [cklstSn, rvwQuery.data?.content]);
const displayRows = useMemo(() => buildRvwDisplayRows(rows), [rows]);
const getDirtyRows = useCallback(() => rows.filter((row) => row.isDirty || row.isNew), [rows]);
const updateRow = useCallback((rowKey: string, patch: Partial<CklstRvwRow>) => {
setRows((prev) =>
prev.map((row) => (row.rowKey === rowKey ? { ...row, ...patch, isDirty: true } : row)),
);
}, []);
const removeRowLocal = useCallback((rowKey: string) => {
setRows((prev) => prev.filter((row) => row.rowKey !== rowKey));
}, []);
const addRootRow = useCallback(() => {
if (!cklstSn) return;
const maxSort = rows.reduce((max, row) => Math.max(max, row.sortSeq ?? 0), 0);
setRows((prev) => [
...prev,
{
rowKey: createDraftRowKey(),
cklstSn,
cklstArtclSn: artclOptions[0]?.cklstArtclSn ?? 0,
rvwCnLvNo: 1,
rvwCn: "",
useYn: "Y",
sortSeq: maxSort + 1,
isNew: true,
isDirty: true,
},
]);
}, [artclOptions, cklstSn, rows]);
const addChildRow = useCallback(
(parent: CklstRvwRow) => {
if (!cklstSn || parent.isNew || parent.cklstRvwSn == null) {
alert("상위 행을 먼저 저장한 후 하위 항목을 추가할 수 있습니다.");
return;
}
const parentLv = parent.rvwCnLvNo ?? 1;
if (parentLv >= MAX_RVW_CN_LV_NO) {
alert("최대 3단계까지 추가할 수 있습니다.");
return;
}
const maxSort = rows.reduce((max, row) => Math.max(max, row.sortSeq ?? 0), 0);
setRows((prev) => [
...prev,
{
rowKey: createDraftRowKey(),
cklstSn,
cklstArtclSn: parent.cklstArtclSn,
upCklstRvwSn: parent.cklstRvwSn,
rvwCnLvNo: parentLv + 1,
rvwCn: "",
useYn: "Y",
sortSeq: maxSort + 1,
isNew: true,
isDirty: true,
},
]);
},
[alert, cklstSn, rows],
);
const handleDelete = async (row: CklstRvwRow) => {
if (
!(await confirm(
"선택한 항목을 삭제하시겠습니까?\n삭제한 항목은 되돌릴 수 없으며, 하위 항목까지 모두 삭제됩니다.",
))
)
return;
if (row.isNew) {
removeRowLocal(row.rowKey);
return;
}
const children = rows.filter((r) => r.upCklstRvwSn === row.cklstRvwSn);
if (children.length > 0) {
for (const child of children) {
await deleteMutation.mutateAsync({
cklstRvwSn: child.cklstRvwSn!,
cklstArtclSn: child.cklstArtclSn,
cklstSn: child.cklstSn,
});
}
}
await deleteMutation.mutateAsync({
cklstRvwSn: row.cklstRvwSn!,
cklstArtclSn: row.cklstArtclSn,
cklstSn: row.cklstSn,
});
await rvwQuery.refetch();
};
const handleSave = async () => {
if (!cklstSn) return;
const dirty = getDirtyRows().sort((a, b) => (a.rvwCnLvNo ?? 1) - (b.rvwCnLvNo ?? 1));
for (const row of dirty) {
const fieldError = validateRvwRow(row);
if (fieldError) {
alert(fieldError);
return;
}
if (!(await confirm("체크리스트 항목을 저장하시겠습니까?"))) return;
if (row.isNew) {
await createMutation.mutateAsync({
cklstSn: row.cklstSn,
cklstArtclSn: row.cklstArtclSn,
upCklstRvwSn: row.upCklstRvwSn,
rvwCnLvNo: row.rvwCnLvNo,
rvwCn: row.rvwCn,
useYn: row.useYn,
sortSeq: row.sortSeq,
bchckUseYn: "Y",
});
} else if (row.cklstRvwSn != null) {
await updateMutation.mutateAsync({
cklstRvwSn: row.cklstRvwSn,
cklstArtclSn: row.cklstArtclSn,
cklstSn: row.cklstSn,
body: {
upCklstRvwSn: row.upCklstRvwSn,
rvwCnLvNo: row.rvwCnLvNo,
rvwCn: row.rvwCn,
useYn: row.useYn,
sortSeq: row.sortSeq,
bchckUseYn: row.bchckUseYn,
},
});
}
}
alert("체크리스트 항목이 저장되었습니다.");
await rvwQuery.refetch();
};
const isDisabled = !selectedCklst;
const isSaving = createMutation.isPending || updateMutation.isPending || deleteMutation.isPending;
return (
<section className="card card-border mx-0 pt-0">
<div className="card-header flex flex-wrap items-center justify-between gap-3">
<h3 className="text-2xl font-semibold">체크리스트 항목</h3>
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
color="white"
disabled={isDisabled}
onClick={() => setPreviewOpen(true)}
>
미리보기
</Button>
<Button type="button" color="green" disabled={isDisabled} onClick={addRootRow}>
체크리스트 추가
</Button>
<Button
type="button"
disabled={getDirtyRows().length === 0 || isDisabled || isSaving}
onClick={handleSave}
>
저장
</Button>
</div>
</div>
{rvwQuery.isPending && cklstSn ? (
<div className="flex flex-col gap-2 px-4 pb-4">
<Skeleton className="h-[4rem]" />
<Skeleton className="h-[16rem]" />
</div>
) : isDisabled ? (
<p className="py-8 text-center text-lg text-muted-foreground">
점검표를 선택하면 체크리스트 항목을 편집할 수 있습니다.
</p>
) : displayRows.length === 0 ? (
<p className="py-8 text-center text-lg text-muted-foreground">
등록된 항목이 없습니다. [추가]로 1단계 항목을 등록하세요.
</p>
) : (
<Table className="w-full">
<colgroup>
<col width="20%" />
<col width="50%" />
<col width="15%" />
<col width="5%" />
<col width="10%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>점검항목</TableHead>
<TableHead>주요 검토내용</TableHead>
<TableHead>사용 여부</TableHead>
<TableHead>구분</TableHead>
<TableHead>관리</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{displayRows.map(({ row, depth }) => (
<CklstRvwRowComponent
key={row.rowKey}
row={row}
depth={depth}
artclOptions={artclOptions}
onChange={(patch) => updateRow(row.rowKey, patch)}
onAddChild={() => addChildRow(row)}
onDelete={() => handleDelete(row)}
/>
))}
</TableBody>
</Table>
)}
{selectedCklst && (
<CklstPreviewDialog
open={previewOpen}
onOpenChange={setPreviewOpen}
cklstNm={selectedCklst.cklstNm}
rows={rows}
artclOptions={artclOptions}
/>
)}
</section>
);
}
export { CklstRvwGrid };
@@ -0,0 +1,115 @@
"use client";
import type { CklstArtcl } from "@/entities/cklst";
import {
Button,
Input,
Label,
RadioGroup,
RadioGroupItem,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
TableCell,
TableRow,
} from "@common/components";
import {
canAddChildRow,
formatRvwCnLvNo,
formatTreeBranchPrefix,
} from "../model/build-rvw-display-rows";
import type { CklstRvwRow } from "../model/cklst-rvw-row-types";
interface CklstRvwRowProps {
row: CklstRvwRow;
depth: number;
artclOptions: CklstArtcl[];
onChange: (patch: Partial<CklstRvwRow>) => void;
onAddChild: () => void;
onDelete: () => void;
}
function CklstRvwRowComponent({
row,
depth,
artclOptions,
onChange,
onAddChild,
onDelete,
}: CklstRvwRowProps) {
const levelLabel = formatRvwCnLvNo(row.rvwCnLvNo);
const branchPrefix = formatTreeBranchPrefix(depth);
return (
<TableRow>
<TableCell>
<div className="flex min-w-[12rem] items-center gap-1">
{branchPrefix ? (
<span className="shrink-0 font-mono text-muted">{branchPrefix}</span>
) : null}
<Select
value={row.cklstArtclSn ? String(row.cklstArtclSn) : ""}
onValueChange={(v) => onChange({ cklstArtclSn: Number(v), isDirty: true })}
disabled={!row.isNew}
>
<SelectTrigger className="w-full flex-1">
<SelectValue placeholder="점검항목 선택" />
</SelectTrigger>
<SelectContent>
{artclOptions.map((artcl) => (
<SelectItem key={artcl.cklstArtclSn} value={String(artcl.cklstArtclSn)}>
{artcl.cklstArtclNm ?? artcl.cklstArtclSn}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</TableCell>
<TableCell>
<Input
value={row.rvwCn ?? ""}
onChange={(e) => onChange({ rvwCn: e.target.value, isDirty: true })}
placeholder="주요 검토내용"
/>
</TableCell>
<TableCell align="center">
<RadioGroup
value={row.useYn ?? "Y"}
onValueChange={(v) => onChange({ useYn: v as "Y" | "N", isDirty: true })}
className="flex justify-center gap-4"
>
<div className="flex items-center gap-2">
<RadioGroupItem value="Y" id={`${row.rowKey}-use-y`} />
<Label htmlFor={`${row.rowKey}-use-y`}>예</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="N" id={`${row.rowKey}-use-n`} />
<Label htmlFor={`${row.rowKey}-use-n`}>아니오</Label>
</div>
</RadioGroup>
</TableCell>
<TableCell align="center">{levelLabel}</TableCell>
<TableCell align="center">
<div className="flex justify-center gap-2">
<Button
type="button"
size="sm"
color="white"
disabled={row.isNew || !canAddChildRow(row.rvwCnLvNo)}
onClick={onAddChild}
>
하위추가
</Button>
<Button type="button" size="sm" color="gray" onClick={onDelete}>
삭제
</Button>
</div>
</TableCell>
</TableRow>
);
}
export { CklstRvwRowComponent };
@@ -0,0 +1 @@
export { CnstpCodeManagementPage } from "./ui/cnstp-code-management-page";
@@ -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;
}
@@ -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<string, CnstpSeBaseEntry>,
): 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<string, CnstpSeBaseEntry> = {};
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<string> {
const result = new Set<string>([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<string>,
): CnstpDisplaySlot[] {
return structure.filter((slot) => !rowKeysToRemove.has(slot.rowKey));
}
export function patchCnstpGridRow(
state: CnstpSeGridState,
rowKey: string,
patch: Partial<CnstpSeRow>,
): 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),
};
}
@@ -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);
}
@@ -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<typeof buildSaveMultiPayload>) => Promise<void>;
}
export function useCnstpSeRows({ serverContent, onAlert, onSave }: UseCnstpSeRowsOptions) {
const [grid, setGrid] = useState<CnstpSeGridState>(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<CnstpSeRow>) => {
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,
};
}
@@ -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<string>,
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<string>();
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;
}
@@ -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<string, CnstpSeBaseEntry>;
structure: CnstpDisplaySlot[];
drafts: Record<string, CnstpSeDraft>;
patches: Record<string, Partial<CnstpSeMngResponseDto>>;
deletedKeys: Set<string>;
}
@@ -0,0 +1,8 @@
import type { CnstpSeMngResponseDto } from "@/entities/cnstp";
export interface CnstpSeRow extends CnstpSeMngResponseDto {
rowKey: string;
isNew: boolean;
isDirty: boolean;
isDeleted: boolean;
}

Some files were not shown because too many files have changed in this diff Show More