Merge branch 'iipbs/as/dev'
Deploy Frontend / deploy (push) Successful in 7m56s

This commit is contained in:
2026-05-21 17:39:32 +09:00
54 changed files with 3436 additions and 540 deletions
@@ -0,0 +1 @@
export { ChecklistManagementPage as default } from "@/pages/checklist-management";
+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 반영 여부
+1
View File
@@ -17,6 +17,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@faker-js/faker": "^9.9.0",
"@frontend-opensource/use-react-hooks": "^1.1.4",
"@hookform/resolvers": "^4.1.3",
"@tanstack/react-query": "^5.75.5",
"@tanstack/react-query-devtools": "^5.75.5",
+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,15 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { AcntgSeMngTreeDto, AcntgService } from "../model/acntg-interface";
async function findTree(): Promise<AcntgSeMngTreeDto[]> {
const response = await client.get<AcntgSeMngTreeDto[]>(
`${ENDPOINT.AS_COMMON_SERVICE.ACNTG_SE}/tree`,
);
return response.data;
}
export const acntgService = {
findTree,
} satisfies AcntgService;
+2
View File
@@ -0,0 +1,2 @@
export type { AcntgSeMngTreeDto, AcntgService } from "./model/acntg-interface";
export { acntgService } from "./api/acntg-service";
@@ -0,0 +1,23 @@
/** 회계구분 트리 노드 DTO */
export interface AcntgSeMngTreeDto {
/** 들여쓰기 포함 표시명 (앞 공백 수 = 레벨 인디케이터) */
indAcntgSeCdNm: string;
/** 회계구분코드 */
acntgSeCd: string;
/** 회계구분코드명 */
acntgSeCdNm: string;
/** 상위 회계구분코드 (null = 최상위) */
upAcntgSeCd: string | null;
/** 사용여부 */
useYn: string;
/** 트리 레벨 (1 = 총사업비, 2 = 1.공사비/2.보상비/3.부대경비, …) */
treeLevel: number;
/** 최상위~현재 노드까지 "|" 구분 경로 */
treePath: string;
}
/** 회계구분 서비스 인터페이스 */
export interface AcntgService {
/** 회계구분 트리 전체 조회 */
findTree(): Promise<AcntgSeMngTreeDto[]>;
}
@@ -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],
});
},
});
}
+15
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,
@@ -27,3 +34,11 @@ export type {
CklstMngSearchParams,
CklstMngService,
} from "./model/cklst-mng-interface";
export type {
CklstRvw,
CklstRvwId,
CklstRvwSaveRequest,
CklstRvwSearchParams,
CklstRvwService,
CklstRvwUpdateRequest,
} from "./model/cklst-rvw-interface";
@@ -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>;
}
@@ -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,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,23 @@
/** OgnzUserTreeResponseDto.UserDto */
export interface OgnzUserTreeUserDto {
userUnqId: string;
userNm: string;
jbpsSeCd: string;
jbgdSeCd: string;
}
/** OgnzUserTreeResponseDto */
export interface OgnzUserTreeResponseDto {
ognzSn: number;
upOgnzSn: number | null;
ognzNm: string;
ognzExpln?: string;
sortSeq?: number;
useYn?: string;
users: OgnzUserTreeUserDto[];
children: OgnzUserTreeResponseDto[];
}
export interface UserOgnzService {
findOgnzUserTree(): Promise<OgnzUserTreeResponseDto[]>;
}
@@ -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,44 @@
import type { OgnzUserTreeResponseDto } from "@/entities/user-organization";
export function filterOgnzUserTree(
nodes: OgnzUserTreeResponseDto[],
keyword: string,
): OgnzUserTreeResponseDto[] {
const q = keyword.trim().toLowerCase();
if (!q) return nodes;
return nodes
.map((node) => {
const ognzMatches = node.ognzNm.toLowerCase().includes(q);
const filteredUsers = ognzMatches
? node.users
: node.users.filter((user) => user.userNm.toLowerCase().includes(q));
const filteredChildren = filterOgnzUserTree(node.children ?? [], keyword);
if (ognzMatches || filteredUsers.length > 0 || filteredChildren.length > 0) {
return {
...node,
users: filteredUsers,
children: filteredChildren,
};
}
return null;
})
.filter((node): node is OgnzUserTreeResponseDto => node !== null);
}
export function collectExpandedOgnzSnIds(nodes: OgnzUserTreeResponseDto[]): Set<number> {
const ids = new Set<number>();
function walk(list: OgnzUserTreeResponseDto[]) {
for (const node of list) {
const hasContent = (node.users?.length ?? 0) > 0 || (node.children?.length ?? 0) > 0;
if (hasContent) ids.add(node.ognzSn);
walk(node.children ?? []);
}
}
walk(nodes);
return ids;
}
@@ -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,37 @@
import type { ElaprAplnRow } from "../model/apln-row-types";
export const ATRZ_REVIEWER_CD = "ATRZ01";
export const ATRZ_FINAL_CD = "ATRZ03";
export const ATRZ_RECEIVER_CD = "ATRZ00";
export const ATRZ_REVIEWER_1_CD = "ATRZ01";
export const ATRZ_REVIEWER_2_CD = "ATRZ02";
export const ATRZ_APPROVER_CD = "ATRZ03";
export const ATRZ_FINAL_CD = "ATRZ99";
/** 검토자 최상단 · 최종승인자 최하단 순서 검증 */
export function getVisibleRows(rows: ElaprAplnRow[]): ElaprAplnRow[] {
return rows.filter((row) => !row.isDeleted);
}
/** 접수자·최종승인자 위치·중복 검증 (최종 저장 시점) */
export function validateAtrzOrder(rows: ElaprAplnRow[]): string | null {
const reviewerIndices = rows
.map((row, index) => (row.atrzSeComCd === ATRZ_REVIEWER_CD ? index : -1))
.filter((index) => index >= 0);
const visibleRows = getVisibleRows(rows);
if (reviewerIndices.length > 0) {
const lastReviewerIndex = Math.max(...reviewerIndices);
const hasNonReviewerAbove = rows
.slice(0, lastReviewerIndex)
.some((row) => row.atrzSeComCd && row.atrzSeComCd !== ATRZ_REVIEWER_CD);
if (hasNonReviewerAbove) {
return "검토자는 결재선 최상단에만 배치할 수 있습니다.";
}
const receiverCount = visibleRows.filter((row) => row.atrzSeComCd === ATRZ_RECEIVER_CD).length;
if (receiverCount > 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 > 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 +48,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;
}
@@ -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 { OgnzUserTreeUserDto } from "@/entities/user-organization";
export interface ReviewerOption {
uuid: string;
name: string;
deptNm: string;
}
export function toReviewerOption(user: OgnzUserTreeUserDto, ognzNm: string): ReviewerOption {
return {
uuid: user.userUnqId,
name: user.userNm,
deptNm: ognzNm,
};
}
@@ -1,12 +1,10 @@
"use client";
import { useMemo, useState } from "react";
import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { useCallback, useEffect, useState } from "react";
import { useOgnzUserTree } from "@/entities/user-organization";
import {
Button,
DataTable,
Dialog,
DialogContent,
DialogFooter,
@@ -15,8 +13,10 @@ import {
Input,
} from "@common/components";
import type { ReviewerOption } from "../model/mock-reviewers";
import { createMockReviewers } from "../model/mock-reviewers";
import { collectExpandedOgnzSnIds, filterOgnzUserTree } from "../lib/filter-ognz-user-tree";
import type { ReviewerOption } from "../model/reviewer-option";
import { OgnzUserTree } from "./ognz-user-tree";
interface AprvrPickerDialogProps {
open: boolean;
@@ -24,67 +24,100 @@ interface AprvrPickerDialogProps {
onSelect: (reviewer: ReviewerOption) => void;
}
const columnHelper = createColumnHelper<ReviewerOption>();
function AprvrPickerDialog({ open, onOpenChange, onSelect }: AprvrPickerDialogProps) {
const [keyword, setKeyword] = useState("");
const reviewers = useMemo(() => createMockReviewers(), []);
const [displayTree, setDisplayTree] = useState<ReturnType<typeof filterOgnzUserTree>>([]);
const [expandedOgnzSnIds, setExpandedOgnzSnIds] = useState<Set<number>>(new Set());
const [selectedUserId, setSelectedUserId] = useState<string>();
const filtered = useMemo(() => {
const q = keyword.trim().toLowerCase();
if (!q) return reviewers;
return reviewers.filter(
(r) => r.name.toLowerCase().includes(q) || r.deptNm.toLowerCase().includes(q),
);
}, [keyword, reviewers]);
const { data, isPending, isError } = useOgnzUserTree({ enabled: open });
const columns = useMemo(
() => [
columnHelper.accessor("name", { header: "성명" }),
columnHelper.accessor("deptNm", { header: "부서" }),
columnHelper.display({
id: "actions",
header: "선택",
cell: ({ row }) => (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
onSelect(row.original);
onOpenChange(false);
}}
>
선택
</Button>
),
}),
],
[onOpenChange, onSelect],
);
const resetState = useCallback(() => {
setKeyword("");
setSelectedUserId(undefined);
setExpandedOgnzSnIds(new Set());
setDisplayTree(data ?? []);
}, [data]);
const table = useReactTable({
data: filtered,
columns,
getCoreRowModel: getCoreRowModel(),
});
useEffect(() => {
if (!open) {
setKeyword("");
setSelectedUserId(undefined);
setExpandedOgnzSnIds(new Set());
return;
}
setDisplayTree(data ?? []);
}, [open, data]);
const handleToggle = (ognzSn: number, isExpanded: boolean) => {
setExpandedOgnzSnIds((prev) => {
const next = new Set(prev);
if (isExpanded) next.add(ognzSn);
else next.delete(ognzSn);
return next;
});
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
e.stopPropagation();
const filtered = filterOgnzUserTree(data ?? [], keyword);
setDisplayTree(filtered);
setExpandedOgnzSnIds(collectExpandedOgnzSnIds(filtered));
};
const handleReset = () => {
resetState();
};
const handleSelect = (reviewer: ReviewerOption) => {
setSelectedUserId(reviewer.uuid);
onSelect(reviewer);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogContent className="max-w-[30rem]" 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="flex flex-col gap-4">
<div className="card card-border !m-0 w-full">
<form onSubmit={handleSubmit} className="flex items-center gap-2">
<Input
className="h-[2.5rem] !text-[1.3rem]"
placeholder="성명·부서 검색"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
<Button type="submit" size="sm">
조회
</Button>
<Button type="button" size="sm" color="white" onClick={handleReset}>
초기화
</Button>
</form>
</div>
<div className="h-[36rem] overflow-auto">
<OgnzUserTree
nodes={displayTree}
isPending={isPending}
isError={isError}
expandedOgnzSnIds={expandedOgnzSnIds}
selectedUserId={selectedUserId}
onToggle={handleToggle}
onSelect={handleSelect}
/>
</div>
</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,63 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
const sensors = useDndSensors();
const isDisabled = !elaprTmpltId;
const serverRows = aplnQuery.data?.content ?? [];
const canSave = hasUnsavedChanges(rows, serverRows);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const { orderError } = reorderRows(String(active.id), String(over.id));
if (orderError) {
alert(orderError);
}
reorderRows(String(active.id), String(over.id));
};
const handleRowChange = (row: ElaprAplnRow, patch: Partial<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;
if (!(await 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;
}
await saveMultiMutation.mutateAsync({ body: payload, elaprTmpltId });
alert("결재선이 저장되었습니다.");
await aplnQuery.refetch();
};
@@ -205,9 +154,18 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
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 +180,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 +209,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}
@@ -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({
@@ -0,0 +1,116 @@
"use client";
import { ChevronRight } from "lucide-react";
import type { OgnzUserTreeResponseDto } from "@/entities/user-organization";
import {
Button,
cn,
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@common/components";
import type { ReviewerOption } from "../model/reviewer-option";
import { toReviewerOption } from "../model/reviewer-option";
interface OgnzUserTreeNodeProps {
node: OgnzUserTreeResponseDto;
depth?: number;
expandedOgnzSnIds: Set<number>;
selectedUserId?: string;
onToggle: (ognzSn: number, open: boolean) => void;
onSelect: (reviewer: ReviewerOption) => void;
}
function OgnzUserTreeNode({
node,
depth = 0,
expandedOgnzSnIds,
selectedUserId,
onToggle,
onSelect,
}: OgnzUserTreeNodeProps) {
const hasUsers = (node.users?.length ?? 0) > 0;
const hasChildren = (node.children?.length ?? 0) > 0;
const isExpandable = hasUsers || hasChildren;
const isOpen = expandedOgnzSnIds.has(node.ognzSn);
const indent = depth * 16 + 8;
if (!isExpandable) {
return (
<div style={{ paddingInlineStart: indent }} className="py-1">
<span className="text-[1.4rem] font-medium text-label">{node.ognzNm}</span>
</div>
);
}
return (
<Collapsible
open={isOpen}
onOpenChange={(open) => onToggle(node.ognzSn, open)}
className="w-full"
>
<div style={{ paddingInlineStart: indent }}>
<CollapsibleTrigger asChild>
<button
type="button"
className="group flex w-full items-center gap-2 rounded-sm p-2 text-left hover:bg-accent"
>
<ChevronRight
className={cn(
"size-5 shrink-0 text-label transition-transform",
isOpen && "rotate-90",
)}
/>
<span className="text-[1.4rem] font-medium text-label">{node.ognzNm}</span>
</button>
</CollapsibleTrigger>
</div>
<CollapsibleContent>
{node.users.map((user) => {
const isSelected = selectedUserId === user.userUnqId;
return (
<div
key={user.userUnqId}
style={{ paddingInlineStart: indent + 24 }}
className={cn(
"group flex cursor-pointer items-center justify-between gap-2 rounded-sm p-2 hover:bg-accent",
isSelected && "bg-accent",
)}
onClick={() => onSelect(toReviewerOption(user, node.ognzNm))}
>
<span className="text-[1.4rem] text-label">{user.userNm}</span>
<Button
type="button"
size="sm"
onClick={(e) => {
e.stopPropagation();
onSelect(toReviewerOption(user, node.ognzNm));
}}
>
선택
</Button>
</div>
);
})}
{node.children.map((child) => (
<OgnzUserTreeNode
key={child.ognzSn}
node={child}
depth={depth + 1}
expandedOgnzSnIds={expandedOgnzSnIds}
selectedUserId={selectedUserId}
onToggle={onToggle}
onSelect={onSelect}
/>
))}
</CollapsibleContent>
</Collapsible>
);
}
export { OgnzUserTreeNode };
@@ -0,0 +1,71 @@
"use client";
import type { OgnzUserTreeResponseDto } from "@/entities/user-organization";
import { Skeleton } from "@common/components";
import type { ReviewerOption } from "../model/reviewer-option";
import { OgnzUserTreeNode } from "./ognz-user-tree-node";
interface OgnzUserTreeProps {
nodes: OgnzUserTreeResponseDto[];
isPending?: boolean;
isError?: boolean;
expandedOgnzSnIds: Set<number>;
selectedUserId?: string;
onToggle: (ognzSn: number, open: boolean) => void;
onSelect: (reviewer: ReviewerOption) => void;
}
function OgnzUserTree({
nodes,
isPending,
isError,
expandedOgnzSnIds,
selectedUserId,
onToggle,
onSelect,
}: OgnzUserTreeProps) {
if (isPending) {
return (
<div className="flex flex-col gap-2">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-3/4" />
</div>
);
}
if (isError) {
return (
<p className="py-8 text-center text-lg text-muted-foreground">
조직 사용자 목록을 불러오지 못했습니다.
</p>
);
}
if (nodes.length === 0) {
return (
<p className="py-8 text-center text-lg text-muted-foreground">
등록된 조직/사용자가 없습니다.
</p>
);
}
return (
<div className="flex flex-col">
{nodes.map((node) => (
<OgnzUserTreeNode
key={node.ognzSn}
node={node}
expandedOgnzSnIds={expandedOgnzSnIds}
selectedUserId={selectedUserId}
onToggle={onToggle}
onSelect={onSelect}
/>
))}
</div>
);
}
export { OgnzUserTree };
@@ -103,7 +103,7 @@ function SortableAplnRow({
{isEditing ? (
<>
<Button type="button" size="sm" onClick={onSave}>
저장
확인
</Button>
<Button type="button" size="sm" color="white" onClick={onCancel}>
취소
@@ -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" onClick={() => onEdit(row.original)}>
관리
</Button>
<Button type="button" size="sm" color="red" 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" onClick={() => onEdit(row.original)}>
관리
</Button>
<Button type="button" size="sm" color="red" 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,133 @@
"use client";
import { useMemo, useState } from "react";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { matchQueryStatus } from "@/shared/lib";
import { NoData } from "@/shared/ui/no-data";
import {
type CklstArtcl,
useCklstArtclList,
useCreateCklstArtcl,
useDeleteCklstArtcl,
useUpdateCklstArtcl,
} from "@/entities/cklst";
import { Button, DataTable, Skeleton } from "@common/components";
import { createCklstArtclColumns } from "../model/cklst-artcl-columns";
import { CklstArtclFormDialog, type CklstArtclFormValues } from "./cklst-artcl-form-dialog";
interface CklstArtclListProps {
cklstSn?: number;
}
function CklstArtclList({ cklstSn }: CklstArtclListProps) {
const { confirm } = useConfirm();
const [dialogMode, setDialogMode] = useState<"create" | "edit" | null>(null);
const [editingArtcl, setEditingArtcl] = useState<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" 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" 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,92 @@
"use client";
import type { CklstArtcl } from "@/entities/cklst";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
import { buildRvwDisplayRows, formatRvwCnLvNo, 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>
<TableHeader>
<TableRow>
<TableHead>점검항목</TableHead>
<TableHead>주요 검토내용</TableHead>
<TableHead>사용 여부</TableHead>
<TableHead>구분</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{displayRows.map(({ row, depth }) => {
const branchPrefix = formatTreeBranchPrefix(depth);
return (
<TableRow key={row.rowKey}>
<TableCell>
<span className="inline-flex items-center gap-1">
{branchPrefix ? (
<span className="font-mono text-muted-foreground">{branchPrefix}</span>
) : null}
<span>{getArtclLabel(row.cklstArtclSn)}</span>
</span>
</TableCell>
<TableCell>{row.rvwCn ?? "—"}</TableCell>
<TableCell align="center">
{row.useYn === "Y" ? "예" : row.useYn === "N" ? "아니오" : "—"}
</TableCell>
<TableCell align="center">
{formatRvwCnLvNo(row.rvwCnLvNo)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</DialogContent>
</Dialog>
);
}
export { CklstPreviewDialog };
@@ -0,0 +1,304 @@
"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("선택한 항목을 삭제하시겠습니까?"))) return;
if (row.isNew) {
removeRowLocal(row.rowKey);
return;
}
if (row.cklstRvwSn == null) return;
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 (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="white" disabled={isDisabled} onClick={addRootRow}>
추가
</Button>
<Button type="button" disabled={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,110 @@
"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-foreground">{branchPrefix}</span>
) : null}
<Select
value={row.cklstArtclSn ? String(row.cklstArtclSn) : ""}
onValueChange={(v) => onChange({ cklstArtclSn: Number(v), isDirty: true })}
>
<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="red" onClick={onDelete}>
삭제
</Button>
</div>
</TableCell>
</TableRow>
);
}
export { CklstRvwRowComponent };
@@ -1,86 +1,813 @@
"use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import type { AcntgSeMngTreeDto } from "@/entities/acntg";
import { acntgService } from "@/entities/acntg";
import { Button, Input } from "@common/components";
import "./cnfm-page.css";
/* ------------------------------------------------------------------ */
/* 타입 */
/* ------------------------------------------------------------------ */
type BzScaleRow = {
bscPl: string; // 예산반영(A)
budgetReflct: string; // 표준사업비(B)
thtiDsgn: string; // 금회설계시(C)
};
type BzAmtRow = {
bscPl: string;
budgetReflct: string;
thtiDsgn: string;
};
const emptyScale = (): BzScaleRow => ({ bscPl: "", budgetReflct: "", thtiDsgn: "" });
const emptyAmt = (): BzAmtRow => ({ bscPl: "", budgetReflct: "", thtiDsgn: "" });
const num = (v: string) => parseFloat(v.replace(/,/g, "") || "0");
const fmt = (v: number) => (isNaN(v) ? "" : v.toLocaleString());
/* ------------------------------------------------------------------ */
/* 메인 컴포넌트 */
/* ------------------------------------------------------------------ */
function CnfmRegistPage() {
const router = useRouter();
/* ── 기본 정보 ── */
const [bzNm, setBzNm] = useState("");
const [bzBgngYr, setBzBgngYr] = useState("");
const [bzEndYr, setBzEndYr] = useState("");
const [bzCn, setBzCn] = useState("");
const [instlCnfmApr, setInstlCnfmApr] = useState("CNFM");
/* ── 설계자 정보 ── */
const [dsgnrNm, setDsgnrNm] = useState("");
const [dsgnrPsitn, setDsgnrPsitn] = useState("");
const [dsgnrTelno, setDsgnrTelno] = useState("");
const [dsgnrEmlAddr, setDsgnrEmlAddr] = useState("");
/* ── 개인정보 동의 ── */
const [privacyAgree, setPrivacyAgree] = useState<"Y" | "N">("N");
/* ── 기타 첨부파일 ── */
type EtcFile = { id: number; name: string; ext: string; size: string; uploader: string };
const [etcFiles, setEtcFiles] = useState<EtcFile[]>([]);
const [etcNoticeOpen, setEtcNoticeOpen] = useState(true);
/* ── 체크리스트 ── */
const CHECKLIST_ITEMS = [
"사업계획의 적정성",
"사업규모의 적정성",
"사업방식의 적정성",
"사업비의 적정성",
"사업효과의 적정성",
"사업일정의 적정성",
];
const [checkResults, setCheckResults] = useState<Record<string, "Y" | "N" | "">>(() =>
Object.fromEntries(CHECKLIST_ITEMS.map((item) => [item, "" as "Y" | "N" | ""])),
);
/* ── 사업규모 ── */
const [bzSpecWwtp, setBzSpecWwtp] = useState<BzScaleRow>(emptyScale()); // 처리장용량
const [bzSpecArea, setBzSpecArea] = useState<BzScaleRow>(emptyScale()); // 면적
const [bzSpecNcr, setBzSpecNcr] = useState<BzScaleRow>(emptyScale()); // 하수관로 신설
const [bzSpecRplc, setBzSpecRplc] = useState<BzScaleRow>(emptyScale()); // 교체(보수)
const [bzSpecPump, setBzSpecPump] = useState<BzScaleRow>(emptyScale()); // 펌프장
const [bzSpecDrng, setBzSpecDrng] = useState<BzScaleRow>(emptyScale()); // 배수설비
/* ── 총사업비 (회계구분 트리 연동) ── */
// acntgSeCd → { bscPl, budgetReflct, thtiDsgn } 단일 map
const [amtMap, setAmtMap] = useState<Record<string, BzAmtRow>>({});
/* ── 회계구분 트리 로드 ── */
const { data: acntgTree = [] } = useQuery<AcntgSeMngTreeDto[]>({
queryKey: ["acntgTree"],
queryFn: acntgService.findTree,
staleTime: Infinity, // 코드 데이터 — 앱 수명 동안 재요청 불필요
});
/* ── 리프 노드 집합 (자식이 없는 노드 = 직접 입력 가능) ── */
const leafSet = useMemo(() => {
const parentCodes = new Set(acntgTree.map((n) => n.upAcntgSeCd).filter(Boolean) as string[]);
return new Set(acntgTree.filter((n) => !parentCodes.has(n.acntgSeCd)).map((n) => n.acntgSeCd));
}, [acntgTree]);
/**
* treePath 기반 소계 계산
* - 이 노드의 acntgSeCd 가 treePath 안에 포함된 리프 노드들의 합산
* - 새로운 하위 레벨이 추가돼도 자동으로 포함됨
*/
const subtotal = (node: AcntgSeMngTreeDto, col: keyof BzAmtRow): number =>
acntgTree
.filter((n) => leafSet.has(n.acntgSeCd) && n.treePath.split("|").includes(node.acntgSeCd))
.reduce((sum, n) => sum + num((amtMap[n.acntgSeCd] ?? emptyAmt())[col]), 0);
/** 노드 값 반환 — 리프이면 입력값, 부모이면 소계 */
const nodeVal = (node: AcntgSeMngTreeDto, col: keyof BzAmtRow): number =>
leafSet.has(node.acntgSeCd)
? num((amtMap[node.acntgSeCd] ?? emptyAmt())[col])
: subtotal(node, col);
/** 단일 셀 입력 핸들러 */
const handleAmtInput = (acntgSeCd: string, col: keyof BzAmtRow, val: string) =>
setAmtMap((prev) => ({
...prev,
[acntgSeCd]: { ...(prev[acntgSeCd] ?? emptyAmt()), [col]: val },
}));
/**
* indAcntgSeCdNm 의 앞 공백 수를 파싱해 padding px 값으로 반환
* (공백 4칸 = 1 depth = 1rem)
*/
const indentPx = (indNm: string): number => {
const spaces = indNm.length - indNm.trimStart().length;
return (spaces / 4) * 16; // 4공백 = 16px
};
/** 트리 행 스타일 분류 */
const rowStyle = (node: AcntgSeMngTreeDto) => {
if (node.treeLevel === 1) return "bg-blue-50 font-bold";
if (node.treeLevel === 2) return "bg-gray-100 font-semibold";
if (!leafSet.has(node.acntgSeCd)) return "font-semibold"; // 중간 부모 (예: 하수관로)
return "";
};
/* ── 증감 라벨 ── */
const diffLabel = (v: number) => (v > 0 ? `+${fmt(v)}` : fmt(v));
/* ── 검증 ── */
function fnValidation(mode: "SAVE" | "PRESENTN") {
if (!bzNm.trim()) {
alert("사업명을 입력하세요.");
return false;
}
if (!bzBgngYr || !bzEndYr) {
alert("사업기간을 입력하세요.");
return false;
}
if (!bzCn.trim()) {
alert("사업내용을 입력하세요.");
return false;
}
if (mode === "PRESENTN" && privacyAgree !== "Y") {
alert("개인정보 제공에 동의하셔야 제출이 가능합니다.");
return false;
}
return true;
}
function handleSave() {
if (!fnValidation("SAVE")) return;
alert("저장(임시) API 연동 후 progrsSttus=SV 전송");
}
function handleSubmit() {
if (!fnValidation("PRESENTN")) return;
alert("제출 API 연동 후 progrsSttus=ST 전송");
}
/* ── 공통 입력 핸들러 ── */
function scaleInput(
setter: React.Dispatch<React.SetStateAction<BzScaleRow>>,
col: keyof BzScaleRow,
val: string,
) {
setter((prev) => ({ ...prev, [col]: val }));
}
/* ── 공통 숫자 셀 ── */
const NumCell = ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
<td>
<input
type="text"
className="cnfm-input w-full text-right"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</td>
);
const ReadCell = ({ value }: { value: string | number }) => (
<td className="bg-gray-50 pr-2 text-right text-[1.25rem] font-semibold">{value}</td>
);
/* ---------------------------------------------------------------- */
return (
<div className="mt-6 space-y-4">
{/* ── 프로세스 스텝 바 ── */}
<div className="cnfm-card p-4">
<h2 className="cnfm-section-title">설치인가 신청</h2>
<p className="mt-2 text-[1.3rem] text-gray-600">
AS-IS 화면 설계(신청 화면)를 반영한 입력 레이아웃입니다.
</p>
<div className="cnfm-step-row flex-wrap gap-y-2">
{["설치인가 신청", "기술검토/조치계획 검토", "기술검토 회신", "인가승인"].map(
(label, i) => (
<span key={label} className="flex items-center gap-1">
<span
className={`cnfm-step-badge ${i === 0 ? "cnfm-step-badge--current" : "cnfm-step-badge--pending"}`}
>
<span
className={`cnfm-step-dot ${i === 0 ? "cnfm-step-dot--current" : "cnfm-step-dot--pending"}`}
/>
{label}
</span>
{i < 3 && <span className="text-[1.2rem] text-gray-400">›</span>}
</span>
),
)}
</div>
</div>
{/* ── 타이틀 + 액션 버튼 ── */}
<div className="cnfm-card flex items-center justify-between p-4">
<h2 className="cnfm-section-title">■ 설치인가 신청/수정</h2>
<div className="flex gap-2">
<Button color="white" onClick={handleSave}>
저장
</Button>
<Button onClick={handleSubmit}>제출</Button>
</div>
</div>
{/* ── 기본 정보 ── */}
<div className="cnfm-card p-4">
<h3 className="text-[1.5rem] font-semibold">기본 정보</h3>
<table className="cnfm-form-table mt-3">
<h3 className="mb-3 border-b pb-2 text-[1.5rem] font-bold text-gray-700">기본 정보</h3>
<table className="cnfm-form-table">
<tbody>
<tr>
<th>사업명</th>
<td>
<Input placeholder="사업명 입력" className="!text-[1.3rem]" />
<th>
<span className="text-red-500">*</span> 사업명
</th>
<td colSpan={3}>
<div className="flex gap-2">
<Input
placeholder="사업명을 입력하거나 사업찾기를 이용하세요"
className="flex-1 !text-[1.3rem]"
value={bzNm}
onChange={(e) => setBzNm(e.target.value)}
/>
<button className="cnfm-btn-search whitespace-nowrap">🔍 사업찾기</button>
</div>
</td>
</tr>
<tr>
<th>
<span className="text-red-500">*</span> 사업기간
</th>
<td colSpan={3}>
<div className="flex items-center gap-2">
<Input
placeholder="시작연도(YYYY)"
className="w-[14rem] !text-[1.3rem]"
value={bzBgngYr}
onChange={(e) => setBzBgngYr(e.target.value)}
/>
<span className="text-gray-500">~</span>
<Input
placeholder="종료연도(YYYY)"
className="w-[14rem] !text-[1.3rem]"
value={bzEndYr}
onChange={(e) => setBzEndYr(e.target.value)}
/>
</div>
</td>
</tr>
<tr>
<th>
<span className="text-red-500">*</span> 사업내용
</th>
<td colSpan={3}>
<textarea
className="cnfm-input h-[6rem] w-full resize-y"
placeholder="사업 내용을 입력하세요"
value={bzCn}
onChange={(e) => setBzCn(e.target.value)}
/>
</td>
</tr>
<tr>
<th>구분</th>
<td>
<div className="flex gap-4 text-[1.3rem]">하수관로</div>
</td>
<th>신청 지자체</th>
<td>
<span className="text-[1.3rem] text-gray-500">(로그인 정보 자동 세팅)</span>
</td>
</tr>
<tr>
<th>사업구분</th>
<td>
<select className="h-[3.6rem] w-full rounded-md border border-gray-300 px-3 text-[1.3rem]">
<option value="CNFM">설치인가</option>
<option value="APR">설치승인</option>
<option value="AFA">폐지인가</option>
</select>
</td>
</tr>
<tr>
<th>사업기간(시작)</th>
<td>
<Input placeholder="YYYY" className="!text-[1.3rem]" />
</td>
<th>사업기간(종료)</th>
<td>
<Input placeholder="YYYY" className="!text-[1.3rem]" />
<div className="flex gap-4 text-[1.3rem]">
{[
["CNFM", "설치인가"],
["APR", "설치승인"],
["AFA", "폐지인가"],
].map(([val, label]) => (
<label key={val} className="flex cursor-pointer items-center gap-1">
<input
type="radio"
name="instlCnfmApr"
value={val}
checked={instlCnfmApr === val}
onChange={() => setInstlCnfmApr(val)}
/>
{label}
</label>
))}
</div>
</td>
</tr>
</tbody>
</table>
</div>
{/* ── 신청자 / 설계자 정보 ── */}
<div className="cnfm-card p-4">
<h3 className="text-[1.5rem] font-semibold">신청자 / 설계자 정보</h3>
<table className="cnfm-form-table mt-3">
<h3 className="mb-3 border-b pb-2 text-[1.5rem] font-bold text-gray-700">신청자 정보</h3>
<table className="cnfm-form-table">
<tbody>
<tr>
<th>신청자명</th>
<th>신청자 이름</th>
<td>
<Input placeholder="신청자명" className="!text-[1.3rem]" />
<span className="text-[1.3rem] text-gray-500">(로그인 사용자 자동 세팅)</span>
</td>
<th>신청자 연락처</th>
<th>전화번호</th>
<td>
<Input placeholder="010-0000-0000" className="!text-[1.3rem]" />
<span className="text-[1.3rem] text-gray-500">(로그인 사용자 자동 세팅)</span>
</td>
</tr>
<tr>
<th>설계자명</th>
<th>부서</th>
<td>
<Input placeholder="설계자명" className="!text-[1.3rem]" />
<span className="text-[1.3rem] text-gray-500">(로그인 사용자 자동 세팅)</span>
</td>
<th>설계자 소속</th>
<th>이메일</th>
<td>
<Input placeholder="설계자 소속" className="!text-[1.3rem]" />
<span className="text-[1.3rem] text-gray-500">(로그인 사용자 자동 세팅)</span>
</td>
</tr>
<tr>
<th>설계자 이름</th>
<td>
<Input
placeholder="설계자 성명"
className="!text-[1.3rem]"
value={dsgnrNm}
onChange={(e) => setDsgnrNm(e.target.value)}
/>
</td>
<th>전화번호</th>
<td>
<Input
placeholder="설계자 전화번호"
className="!text-[1.3rem]"
value={dsgnrTelno}
onChange={(e) => setDsgnrTelno(e.target.value)}
/>
</td>
</tr>
<tr>
<th>소속(용역사)</th>
<td>
<Input
placeholder="용역사명"
className="!text-[1.3rem]"
value={dsgnrPsitn}
onChange={(e) => setDsgnrPsitn(e.target.value)}
/>
</td>
<th>이메일</th>
<td>
<Input
placeholder="설계자 이메일"
className="!text-[1.3rem]"
value={dsgnrEmlAddr}
onChange={(e) => setDsgnrEmlAddr(e.target.value)}
/>
</td>
</tr>
</tbody>
</table>
</div>
{/* ── 개인정보 동의 ── */}
<div className="cnfm-card p-4">
<h3 className="mb-3 border-b pb-2 text-[1.5rem] font-bold text-gray-700">개인정보 동의</h3>
<div className="mb-3 rounded border bg-gray-50 p-3 text-[1.25rem] leading-relaxed text-gray-600">
<p className="mb-1 font-semibold">[개인정보 수집·이용 목적]</p>
<ul className="ml-4 list-disc space-y-1">
<li>수집항목: 소속기관, 성명, 전화번호, 이메일, IP Address, 접속 기기 정보</li>
<li>수집목적: 하수도 시설물 설치인가 신청 및 처리 목적</li>
<li>보유기간: 관련 법령에 따른 의무 보유 기간 준수</li>
</ul>
<p className="mt-2 text-[1.2rem] text-gray-500">
※ 개인정보 제공에 동의하지 않을 수 있으나, 미동의 시 서비스 이용이 제한될 수 있습니다.
</p>
</div>
<div className="flex gap-6 text-[1.3rem]">
<label className="flex cursor-pointer items-center gap-2 font-semibold text-blue-600">
<input
type="radio"
name="privacyAgree"
value="Y"
checked={privacyAgree === "Y"}
onChange={() => setPrivacyAgree("Y")}
/>
예 (동의)
</label>
<label className="flex cursor-pointer items-center gap-2">
<input
type="radio"
name="privacyAgree"
value="N"
checked={privacyAgree === "N"}
onChange={() => setPrivacyAgree("N")}
/>
아니오 (미동의)
</label>
</div>
</div>
{/* ── 사업규모 ── */}
<div className="cnfm-card p-4">
<h3 className="mb-3 border-b pb-2 text-[1.5rem] font-bold text-gray-700">사업규모</h3>
<div className="cnfm-table-wrap">
<table className="cnfm-table">
<thead>
<tr>
<th rowSpan={2} className="w-[12rem]">
구분
</th>
<th>기본계획(A)</th>
<th>예산반영(B)</th>
<th>금회설계시(C)</th>
<th>증감(C-B)</th>
</tr>
</thead>
<tbody>
{[
{ label: "처리장용량(㎥/일)", state: bzSpecWwtp, setter: setBzSpecWwtp },
{ label: "면적(㎡)", state: bzSpecArea, setter: setBzSpecArea },
{ label: "하수관로 신설(m)", state: bzSpecNcr, setter: setBzSpecNcr },
{ label: "교체/보수(m)", state: bzSpecRplc, setter: setBzSpecRplc },
{ label: "펌프장(개소)", state: bzSpecPump, setter: setBzSpecPump },
{ label: "배수설비(개소)", state: bzSpecDrng, setter: setBzSpecDrng },
].map(({ label, state, setter }) => (
<tr key={label}>
<td className="bg-gray-50 pl-2 text-left font-semibold">{label}</td>
{(["bscPl", "budgetReflct", "thtiDsgn"] as const).map((col) => (
<td key={col}>
<input
type="text"
className="cnfm-input w-full text-right"
value={state[col]}
onChange={(e) => scaleInput(setter, col, e.target.value)}
/>
</td>
))}
<td className="bg-gray-50 pr-2 text-right text-[1.25rem] font-semibold">
{diffLabel(num(state.thtiDsgn) - num(state.budgetReflct))}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* ── 총사업비 ── */}
<div className="cnfm-card p-4">
<h3 className="mb-3 border-b pb-2 text-[1.5rem] font-bold text-gray-700">
총사업비 <span className="text-[1.2rem] font-normal text-gray-400">(단위: 백만원)</span>
</h3>
<div className="cnfm-table-wrap">
<table className="cnfm-table">
<thead>
<tr>
<th colSpan={2} className="w-[20rem]">
구분
</th>
<th>예산반영(A)</th>
<th>표준사업비(B)</th>
<th>금회설계시(C)</th>
<th colSpan={2}>증감</th>
</tr>
<tr>
<th colSpan={2}></th>
<th></th>
<th></th>
<th></th>
<th>(C-B)</th>
<th>%</th>
</tr>
</thead>
<tbody>
{acntgTree.map((node) => {
const isLeaf = leafSet.has(node.acntgSeCd);
// level 1,2 는 colSpan=2, level 3+ 는 왼쪽 빈셀 + 이름
const useColSpan = node.treeLevel <= 2;
const bscPl = nodeVal(node, "bscPl");
const budgetReflct = nodeVal(node, "budgetReflct");
const thtiDsgn = nodeVal(node, "thtiDsgn");
const diffVal = thtiDsgn - budgetReflct;
// indAcntgSeCdNm 압 공백으로 paddig 계산
const pl = indentPx(node.indAcntgSeCdNm);
// level 4+ (신설, 교체/보수 등) 서브 아이콘
const showSubIcon = node.treeLevel >= 4;
return (
<tr key={node.acntgSeCd} className={rowStyle(node)}>
{useColSpan ? (
<td colSpan={2} className="pl-2 text-left">
{node.acntgSeCdNm}
</td>
) : (
<>
<td className="w-[4rem]"></td>
<td
className={`bg-gray-50 text-left ${isLeaf ? "text-gray-700" : ""}`}
style={{ paddingLeft: `${pl + 8}px` }}
>
{showSubIcon && <span className="mr-1 text-gray-400">└</span>}
{node.acntgSeCdNm}
</td>
</>
)}
{/* 리프 노드: 직접 입력 / 부모 노드: 소계 읽기 전용 */}
{isLeaf
? [
["bscPl", bscPl] as const,
["budgetReflct", budgetReflct] as const,
["thtiDsgn", thtiDsgn] as const,
].map(([col]) => (
<NumCell
key={col}
value={(amtMap[node.acntgSeCd] ?? emptyAmt())[col]}
onChange={(v) => handleAmtInput(node.acntgSeCd, col, v)}
/>
))
: (
[
["bscPl", bscPl],
["budgetReflct", budgetReflct],
["thtiDsgn", thtiDsgn],
] as [keyof BzAmtRow, number][]
).map(([col, val]) => <ReadCell key={col} value={fmt(val)} />)}
{/* 증감 (C-B) */}
<ReadCell value={diffLabel(diffVal)} />
{/* % 증감률 전체 행 표시 */}
<ReadCell
value={
budgetReflct !== 0
? fmt(Math.round((diffVal / budgetReflct) * 100)) + "%"
: "-"
}
/>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* ── 체크리스트 ── */}
<div className="cnfm-card p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-[1.5rem] font-bold text-gray-700">체크리스트</h3>
<button className="cnfm-btn-search text-[1.2rem]">하수관로(0/8)</button>
</div>
<div className="mb-2 text-[1.25rem] text-gray-500">
체크리스트 진행상황 — 총합: <strong>0</strong> · 완료: <strong>0</strong> · 진행률:{" "}
<strong>0%</strong>
</div>
<div className="cnfm-table-wrap">
<table className="cnfm-table">
<thead>
<tr>
<th className="w-[20rem]" rowSpan={2}>
검토항목
</th>
<th rowSpan={2}>주요 검토내용</th>
<th colSpan={2}>검토 결과(-)</th>
</tr>
<tr>
<th className="w-[9rem]">
<button
type="button"
className="w-full rounded border border-green-300 bg-green-500 px-3 py-1 text-[1.1rem] font-semibold text-white hover:bg-green-600"
onClick={() =>
setCheckResults(
Object.fromEntries(CHECKLIST_ITEMS.map((i) => [i, "Y" as const])),
)
}
>
전체 예
</button>
</th>
<th className="w-[9rem]">
<button
type="button"
className="w-full rounded border border-red-300 bg-red-500 px-3 py-1 text-[1.1rem] font-semibold text-white hover:bg-red-600"
onClick={() =>
setCheckResults(
Object.fromEntries(CHECKLIST_ITEMS.map((i) => [i, "N" as const])),
)
}
>
전체 아니오
</button>
</th>
</tr>
</thead>
<tbody>
{CHECKLIST_ITEMS.map((item) => (
<tr key={item}>
<td className="pl-2 text-left">{item}</td>
<td className="pl-2 text-left text-[1.2rem] text-gray-400">
금회 총사업비가 확정예산 범위 내
</td>
<td className="text-center">
<label className="flex cursor-pointer items-center justify-center gap-1 text-[1.2rem] text-green-600">
<input
type="radio"
name={`chk_${item}`}
value="Y"
checked={checkResults[item] === "Y"}
onChange={() => setCheckResults((prev) => ({ ...prev, [item]: "Y" }))}
/>
</label>
</td>
<td className="text-center">
<label className="flex cursor-pointer items-center justify-center gap-1 text-[1.2rem] text-red-500">
<input
type="radio"
name={`chk_${item}`}
value="N"
checked={checkResults[item] === "N"}
onChange={() => setCheckResults((prev) => ({ ...prev, [item]: "N" }))}
/>
</label>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* ── 첨부파일 ── */}
<div className="cnfm-card p-4">
<h3 className="mb-3 border-b pb-2 text-[1.5rem] font-bold text-gray-700">첨부파일</h3>
<div className="cnfm-table-wrap">
<table className="cnfm-table">
<thead>
<tr>
<th>필수 자료명</th>
<th>파일형식</th>
<th>파일첨부</th>
</tr>
</thead>
<tbody>
{[
"인가신청서",
"계획수립 기준 및 목표연도 계획",
"하수도 시설계획 적정성 검토 결과",
"관로설계시설물 감리비용 산출 계획서",
"사업비 내역서",
"설계도면",
"기타 첨부자료",
].map((name) => (
<tr key={name}>
<td className="pl-2 text-left">{name}</td>
<td>hwp/pdf</td>
<td>
<div className="flex items-center justify-center gap-2">
<input
type="text"
placeholder="첨부문서를 올려주세요"
className="cnfm-input flex-1"
readOnly
/>
<button className="cnfm-btn-search text-[1.2rem]">첨부하기</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* ── 기타 첨부파일 ── */}
<div className="cnfm-card p-4">
<h3 className="mb-3 border-b pb-2 text-[1.5rem] font-bold text-gray-700">기타 첨부파일</h3>
{/* 안내 문구 토글 */}
<div className="mb-3 overflow-hidden rounded border">
<button
type="button"
className="flex w-full items-center gap-2 bg-gray-50 px-3 py-2 text-[1.25rem] text-gray-600 hover:bg-gray-100"
onClick={() => setEtcNoticeOpen((prev) => !prev)}
>
<span className="text-gray-400">{etcNoticeOpen ? "▼" : "▶"}</span>
복수개의 파일을 등록하시려면 Zip파일 형식으로 압축하여 올려주십시오.
</button>
</div>
{/* 파일 업로드 영역 */}
<div className="mb-3 flex flex-nowrap items-center gap-2">
<input
type="text"
className="cnfm-input min-w-0 flex-1"
placeholder="첨부문서를 등록해주세요"
readOnly
/>
<label className="cnfm-btn-search shrink-0 cursor-pointer whitespace-nowrap text-[1.2rem]">
첨부파일
<input
type="file"
className="hidden"
multiple
onChange={(e) => {
const files = Array.from(e.target.files ?? []);
const mapped: EtcFile[] = files.map((f, i) => ({
id: Date.now() + i,
name: f.name.replace(/\.[^.]+$/, ""),
ext: f.name.split(".").pop() ?? "",
size:
f.size > 1024 * 1024
? `${(f.size / 1024 / 1024).toFixed(1)}MB`
: `${(f.size / 1024).toFixed(0)}KB`,
uploader: "(로그인 사용자)",
}));
setEtcFiles((prev) => [...prev, ...mapped]);
e.target.value = "";
}}
/>
</label>
</div>
{/* 첨부된 파일 목록 테이블 */}
<div className="cnfm-table-wrap">
<table className="cnfm-table">
<thead>
<tr>
<th>파일명</th>
<th className="w-[8rem]">확장자</th>
<th className="w-[8rem]">크기</th>
<th className="w-[12rem]">등록자</th>
<th className="w-[6rem]">삭제</th>
</tr>
</thead>
<tbody>
{etcFiles.length === 0 ? (
<tr>
<td colSpan={5} className="cnfm-td-empty">
첨부된 파일이 없습니다.
</td>
</tr>
) : (
etcFiles.map((f) => (
<tr key={f.id}>
<td className="pl-2 text-left">{f.name}</td>
<td>{f.ext}</td>
<td>{f.size}</td>
<td>{f.uploader}</td>
<td>
<button
type="button"
className="text-[1.2rem] text-red-500 hover:text-red-700"
onClick={() => setEtcFiles((prev) => prev.filter((x) => x.id !== f.id))}
>
✕
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* ── 액션 버튼 ── */}
<div className="cnfm-action-row">
<Button color="white" onClick={() => router.push("/instl-sncn")}>목록</Button>
<Button type="button" onClick={() => window.alert("저장 API 연동은 추후 확장 가능합니다.")}>저장</Button>
<Button type="button" onClick={() => window.alert("제출 API 연동은 추후 확장 가능합니다.")}>제출</Button>
<Button color="white" onClick={() => router.push("/instl-sncn")}>
목록
</Button>
<Button color="white" onClick={handleSave}>
저장
</Button>
<Button onClick={handleSubmit}>제출</Button>
</div>
</div>
);
+5
View File
@@ -1,4 +1,7 @@
export const ENDPOINT = {
AS_COMMON_SERVICE: {
ACNTG_SE: "/as-common-service/api/v1/acntg-se",
},
AS_INSTL_SERVICE: {
INSTL_SNCN: "/as-instl-service/api/v1/instl-sncn",
},
@@ -14,11 +17,13 @@ export const ENDPOINT = {
AS_CHECKLIST_SERVICE: {
CKLST: "/as-checklist-service/api/v1/cklst",
CKLST_ARTCL: "/as-checklist-service/api/v1/cklst-artcl",
CKLST_RVW: "/as-checklist-service/api/v1/cklst-rvw",
},
CMS_SERVICE: {
CODE: "/cms-service/api/v1/code/cache",
MENU_ROLES: "/cms-service/api/v1/menu-roles",
MENU_AUTHRTS: "/cms-service/api/v1/menu-authrts",
USER_ORGANIZATION: "/cms-service/api/v1/user-ognz",
},
};
@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
import type { UseQueryResult } from "@tanstack/react-query";
interface MatchQueryStatusOptions {
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import {
Button,
type ButtonProps,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
} from "@common/components";
type ConfirmDialogProps = {
isOpen?: boolean;
confirmButtonProps?: ButtonProps & { label?: string };
cancelButtonProps?: ButtonProps & { label?: string };
};
function ConfirmDialog({ isOpen, confirmButtonProps, cancelButtonProps }: ConfirmDialogProps) {
const { message, isOpen: isGlobalOpen, onConfirm, onCancel } = useConfirm();
return (
<Dialog open={isOpen ?? isGlobalOpen} onOpenChange={onCancel}>
<DialogContent aria-describedby={undefined}>
<div className="py-10">
<p className="whitespace-pre-line text-center text-[1.4rem] text-foreground">{message}</p>
</div>
<DialogFooter className="!justify-center">
<DialogClose asChild>
<Button onClick={onConfirm} {...confirmButtonProps}>
{confirmButtonProps?.label ?? "확인"}
</Button>
</DialogClose>
<DialogClose asChild>
<Button onClick={onCancel} color="white" {...cancelButtonProps}>
{cancelButtonProps?.label ?? "취소"}
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { ConfirmDialog };
+1 -1
View File
@@ -1,7 +1,7 @@
function NoData({ message = "조회된 데이터가 없습니다." }: { message?: string }) {
return (
<div className="flex min-h-[120px] items-center justify-center text-muted-foreground">
<p className="text-sm">{message}</p>
<p className="text-lg">{message}</p>
</div>
);
}
@@ -5,6 +5,7 @@ export * from "./button-group";
export * from "./calendar";
export * from "./carousel";
export * from "./checkbox";
export * from "./collapsible";
export * from "./combobox";
export * from "./data-table";
export * from "./date-picker";
+8 -5
View File
@@ -104,10 +104,10 @@ importers:
devDependencies:
eslint:
specifier: ^9.0.0
version: 9.39.4(jiti@2.6.1)
version: 9.39.4(jiti@1.21.7)
eslint-config-next:
specifier: 15.3.8
version: 15.3.8(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
version: 15.3.8(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
postcss:
specifier: ^8.4.49
version: 8.5.8
@@ -150,6 +150,9 @@ importers:
'@faker-js/faker':
specifier: ^9.9.0
version: 9.9.0
'@frontend-opensource/use-react-hooks':
specifier: ^1.1.4
version: 1.1.4
'@hookform/resolvers':
specifier: ^4.1.3
version: 4.1.3(react-hook-form@7.72.0(react@19.2.4))
@@ -192,13 +195,13 @@ importers:
version: 3.0.6
eslint:
specifier: ^9.0.0
version: 9.39.4(jiti@1.21.7)
version: 9.39.4(jiti@2.6.1)
eslint-config-next:
specifier: 15.3.8
version: 15.3.8(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
version: 15.3.8(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
eslint-plugin-react-hooks:
specifier: ^5.0.0
version: 5.2.0(eslint@9.39.4(jiti@1.21.7))
version: 5.2.0(eslint@9.39.4(jiti@2.6.1))
postcss:
specifier: ^8.4.49
version: 8.5.8