Merge remote-tracking branch 'origin/iipbs/as/dev' into iipbs/as/dev
This commit is contained in:
@@ -231,7 +231,7 @@ END
|
||||
| ------------- | ------------- | ----------------------------------------------------------------------------------------- | ---------------------------------------------- |
|
||||
| (드래그 핸들) | — | — | `useSortable` 연동 |
|
||||
| 결재선아이디 | `aplnId` | 읽기 전용 | 신규 행: `—`, 저장 후 서버 `aplnId` 표시 |
|
||||
| 결재순서번호 | `atrzSeqNo` | 숫자 Input | 드래그 시 자동 재부여 가능 |
|
||||
| 결재순서번호 | `atrzSeqNo` | 읽기 전용 (그리드 행 순서 1부터 표시) | 저장 시 행 순서 기준 자동 채번 |
|
||||
| 결재구분 | `atrzSeComCd` | Select (`ATRZSECD` 공통코드) | 표시명 `atrzSeComNm` (조회 행) |
|
||||
| 결재자 | `aprvrUuid` | 읽기 전용 이름 + **[선택]** 버튼 | 응답에 성명 없음 — 선택 시 로컬 `aprvrNm` 유지 |
|
||||
| 등록일시 | `regDt` | 읽기 전용 | 신규 행은 `—` |
|
||||
@@ -239,26 +239,28 @@ END
|
||||
|
||||
#### 인라인 편집 정책
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| **단일 행 편집** | **행 추가 또는 한 행 수정 중에는 다른 행을 편집할 수 없다.** `editingRowId` 로 현재 편집 행 1건만 허용. |
|
||||
| 편집 잠금 시 비활성 | 다른 행: 셀·[수정]·[삭제] 비활성 · `[행 추가]` 비활성 · 행 드래그 비활성 · (선택) `[결재선 저장]` 비활성 |
|
||||
| 편집 해제 | 행별 **[저장]** 성공 또는 **[취소]** 시 `editingRowId` 초기화 후 전체 조회 모드 복귀 |
|
||||
| 조회 행 | 편집 중이 아닐 때 읽기 전용. **[수정]** 클릭 시 해당 행만 편집 모드 진입 |
|
||||
| 신규 행 | `[행 추가]` 시 맨 아래 편집 행 1건 생성 (`editingRowId` = 임시 키) |
|
||||
| 필수값 | `atrzSeComCd`, `aprvrUuid`, `atrzSeqNo` |
|
||||
| 순서 중복 | 동일 서식 내 `atrzSeqNo` 중복 시 저장 전 검증·안내 |
|
||||
| 결재구분 코드 | `ATRZSECD` — 예: `ATRZ01` 검토자, `ATRZ02` 승인자, `ATRZ03` 최종승인자 |
|
||||
| 저장 단위 | 행별 **[저장]** (단건 POST/PUT) 또는 툴바 **[결재선 저장]** (일괄) |
|
||||
| 항목 | 내용 |
|
||||
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **단일 행 편집** | **행 추가 또는 한 행 수정 중에는 다른 행을 편집할 수 없다.** `editingRowId` 로 현재 편집 행 1건만 허용. |
|
||||
| 편집 잠금 시 비활성 | 다른 행: 셀·[수정]·[삭제] 비활성 · `[행 추가]` 비활성 · 행 드래그 비활성 · (선택) `[결재선 저장]` 비활성 |
|
||||
| 편집 해제 | 행별 **[저장]** 성공 또는 **[취소]** 시 `editingRowId` 초기화 후 전체 조회 모드 복귀 |
|
||||
| 조회 행 | 편집 중이 아닐 때 읽기 전용. **[수정]** 클릭 시 해당 행만 편집 모드 진입 |
|
||||
| 신규 행 | `[행 추가]` 시 맨 아래 편집 행 1건 생성 (`editingRowId` = 임시 키) |
|
||||
| 필수값 | `atrzSeComCd`, `aprvrUuid` |
|
||||
| 순서 중복 | 저장 시 행 순서로 자동 채번하므로 중복 없음 |
|
||||
| 결재구분 코드 | `ATRZSECD` — 예: `ATRZ01` 검토자, `ATRZ02` 승인자, `ATRZ03` 최종승인자 |
|
||||
| **순서 제약** | `ATRZ01`은 최상단 블록, `ATRZ03`은 최하단 블록. 드래그 종료·저장 시 `validateAtrzOrder`로 검증, 위반 시 적용/저장 불가 (결재구분 변경 시에는 미검증) |
|
||||
| 저장 단위 | 행별 **[저장]** (단건 POST/PUT) 또는 툴바 **[결재선 저장]** (일괄) |
|
||||
|
||||
#### 행 드래그 정렬 (`@dnd-kit` + `useSortable`)
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ---------- | ------------------------------------------------------------------------------------------- |
|
||||
| 목적 | 결재 단계 순서를 그리드에서 직접 변경 |
|
||||
| 라이브러리 | `@dnd-kit/core`, `@dnd-kit/sortable` — 테이블 행 POC 후 확정 |
|
||||
| 저장 시점 | 드래그는 클라이언트 상태만 변경 → **[결재선 저장]** 또는 행별 **[저장]** 시 `atrzSeqNo` PUT |
|
||||
| 편집 중 | `editingRowId != null` 이면 드래그 **불가** |
|
||||
| 항목 | 내용 |
|
||||
| ---------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| 목적 | 결재 단계 순서를 그리드에서 직접 변경 |
|
||||
| 라이브러리 | `@dnd-kit/core`, `@dnd-kit/sortable` — 테이블 행 POC 후 확정 |
|
||||
| 저장 시점 | 드래그는 클라이언트 상태만 변경 → **[결재선 저장]** 또는 행별 **[저장]** 시 `atrzSeqNo` PUT |
|
||||
| 편집 중 | `editingRowKey != null` 이면 드래그 **불가** |
|
||||
| 순서 제약 | 드래그 종료 시 `validateAtrzOrder` — 실패하면 순서 변경 미적용 + 안내. 결재구분 미선택 행은 드래그 불가 |
|
||||
|
||||
### 5-3. 보조 다이얼로그 (검토인력만)
|
||||
|
||||
@@ -369,13 +371,14 @@ END
|
||||
|
||||
### 7-2. 모듈·파일 배치(제안)
|
||||
|
||||
| 구분 | 경로 |
|
||||
| ------ | ------------------------------------------------------------------ |
|
||||
| 라우트 | `apps/as/app/(with-base-layout)/approval-line-management/page.tsx` |
|
||||
| 페이지 | `apps/as/src/pages/approval-line-management/` |
|
||||
| 위젯 | `widgets/elapr-tmplt-list/`, `widgets/elapr-apln-grid/` |
|
||||
| 피처 | `features/aprvr-picker/` (검토인력 [선택]만) |
|
||||
| 엔티티 | `apps/as/src/entities/elapr/` |
|
||||
| 구분 | 경로 |
|
||||
| ------ | -------------------------------------------------------------------------------------- |
|
||||
| 라우트 | `apps/as/app/(with-base-layout)/approval-line-management/page.tsx` |
|
||||
| 페이지 | `apps/as/src/pages/approval-line-management/` (Public API: page만 export) |
|
||||
| UI | `.../ui/elapr-tmplt-list.tsx`, `elapr-tmplt-form-dialog.tsx`, `elapr-apln-grid.tsx` 등 |
|
||||
| model | `.../model/columns.tsx`, `apln-row-types.ts`, `mock-reviewers.ts` |
|
||||
| lib | `.../lib/use-apln-rows.ts`, `validators.ts`, `use-dnd-sensors.ts` |
|
||||
| 엔티티 | `apps/as/src/entities/elapr/` |
|
||||
|
||||
### 7-3. 영역별 UI 매핑
|
||||
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
# 화면설계서: 체크리스트관리
|
||||
|
||||
## 1. 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ---------- | -------------------------------------------- |
|
||||
| 버전 | 0.1 |
|
||||
| 프로세스명 | UI설계 |
|
||||
| 화면명 | 체크리스트관리 |
|
||||
| 화면 유형 | [v] 조회 [v] 입력 [v] 수정 [v] 삭제 [ ] 출력 |
|
||||
| 메뉴 경로 | 디지털행정지원 > 시스템관리 > 체크리스트관리 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 화면 목적
|
||||
|
||||
업무별·문서별 **점검표**(체크리스트)를 등록·관리하고, **점검항목** 마스터를 유지한다. 상단 좌측에서 점검표를 선택하면 하단 **체크리스트 항목** 영역에 해당 점검표의 검토 구성(계층형 검토내용)을 편집한다. 상단 우측 **점검항목 관리**는 점검표에 연결할 항목 풀을 등록·수정·삭제한다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 화면 구성
|
||||
|
||||
### 3-1. 전체 레이아웃
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 페이지 타이틀: 체크리스트관리 브레드크럼: 디지털행정지원 > … > │
|
||||
├──────────────────────────────────────────────┬────────────────────────────────────────────────┤
|
||||
│ [영역 A] 점검표 관리 │ [영역 B] 점검항목 관리 │
|
||||
│ [점검표 추가] │ [점검항목 추가] │
|
||||
│ ┌────────────────────────────────────────┐ │ ┌──────────────────────────────────────────┐ │
|
||||
│ │ 점검표일련번호 | 점검표명 | 문서구분 │ │ │ 점검항목코드 | 점검항목명 | 사용여부 │ │
|
||||
│ │ 업무구분 | 사용여부 | 등록일시 | 관리 │ │ │ 등록일시 | 관리 │ │
|
||||
│ ├────────────────────────────────────────┤ │ ├──────────────────────────────────────────┤ │
|
||||
│ │ 1 (선택 행 강조) 설치인가 기본 … [관리]│ │ │ ITEM_001 요건검토 … [관리][삭제]│ │
|
||||
│ │ [삭제] │ │ │ ITEM_002 법령검토 … [관리][삭제]│ │
|
||||
│ └────────────────────────────────────────┘ │ └──────────────────────────────────────────┘ │
|
||||
├──────────────────────────────────────────────┴────────────────────────────────────────────────┤
|
||||
│ [영역 C] 체크리스트 항목 (선택된 점검표: {cklstNm}) │
|
||||
│ [미리보기] [추가] [저장] │
|
||||
│ ┌─────────────────────────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 점검항목 ▼ | 주요 검토내용 | 사용 여부 ○예 ○아니오 | 구분 | 관리 │ │
|
||||
│ │ L1 [요건검토 ▼] | (입력) | ○예 ○아니오 | 1단계| [하위추가]│ │
|
||||
│ │ L2 [요건검토 ▼] | (입력) | ○예 ○아니오 | 2단계| [삭제] │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────────────────────────┘ │
|
||||
└───────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3-2. 화면 데이터 흐름
|
||||
|
||||
```
|
||||
BEGIN
|
||||
|
||||
// 최초 진입
|
||||
CALL GET /as-checklist-service/api/v1/cklst
|
||||
DISPLAY 점검표 그리드 (영역 A)
|
||||
CALL GET /as-checklist-service/api/v1/cklst-artcl
|
||||
DISPLAY 점검항목 그리드 (영역 B)
|
||||
DISPLAY 빈 상태 안내 (영역 C) // 점검표 미선택 시 C 비활성
|
||||
|
||||
// 영역 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)
|
||||
END ON
|
||||
|
||||
// 영역 A: 점검표 추가
|
||||
ON [점검표 추가] 버튼 CLICK DO
|
||||
OPEN 점검표 추가 다이얼로그
|
||||
ON [저장] DO
|
||||
VALIDATE cklstNm, taskSeComCd
|
||||
CALL POST /as-checklist-service/api/v1/cklst
|
||||
CALL GET /as-checklist-service/api/v1/cklst
|
||||
CLOSE 다이얼로그
|
||||
END ON
|
||||
ON [취소] / [닫기] DO
|
||||
CLOSE 다이얼로그
|
||||
END ON
|
||||
END ON
|
||||
|
||||
// 영역 A: 점검표 수정·삭제
|
||||
ON 영역 A 행 [관리] CLICK DO
|
||||
OPEN 점검표 수정 다이얼로그 (기존 값 초기화)
|
||||
ON [저장] DO
|
||||
CALL PUT /as-checklist-service/api/v1/cklst/{cklstSn}
|
||||
CALL GET /as-checklist-service/api/v1/cklst
|
||||
END ON
|
||||
END ON
|
||||
|
||||
ON 영역 A 행 [삭제] CLICK DO
|
||||
CONFIRM "선택한 점검표를 삭제하시겠습니까?"
|
||||
CALL DELETE /as-checklist-service/api/v1/cklst/{cklstSn}
|
||||
IF 삭제 대상 == 선택된 점검표 THEN RESET 영역 C
|
||||
CALL GET /as-checklist-service/api/v1/cklst
|
||||
END ON
|
||||
|
||||
// 영역 B: 점검항목 추가
|
||||
ON [점검항목 추가] 버튼 CLICK DO
|
||||
OPEN 점검항목 추가 다이얼로그
|
||||
ON [저장] DO
|
||||
VALIDATE cklstArtclNm
|
||||
// TBD: cklstSn 필수 — UI에 소속 점검표 선택 없음. 백엔드 정책 확인
|
||||
CALL POST /as-checklist-service/api/v1/cklst-artcl
|
||||
CALL GET /as-checklist-service/api/v1/cklst-artcl
|
||||
CLOSE 다이얼로그
|
||||
END ON
|
||||
END ON
|
||||
|
||||
// 영역 B: 점검항목 수정·삭제
|
||||
ON 영역 B 행 [관리] CLICK DO
|
||||
OPEN 점검항목 수정 다이얼로그
|
||||
ON [저장] DO
|
||||
CALL PUT /as-checklist-service/api/v1/cklst-artcl/{cklstArtclSn}/{cklstSn}
|
||||
END ON
|
||||
END ON
|
||||
|
||||
ON 영역 B 행 [삭제] CLICK DO
|
||||
CONFIRM "선택한 점검항목을 삭제하시겠습니까?"
|
||||
CALL DELETE /as-checklist-service/api/v1/cklst-artcl/{cklstArtclSn}/{cklstSn}
|
||||
CALL GET /as-checklist-service/api/v1/cklst-artcl
|
||||
END ON
|
||||
|
||||
// 영역 C: 검토 행 추가
|
||||
ON [추가] 버튼 CLICK DO
|
||||
VALIDATE 선택된 점검표 존재
|
||||
ADD 영역 C 그리드 맨 아래 편집 가능 행 (1단계, rvwCnLvNo=1)
|
||||
END ON
|
||||
|
||||
// 영역 C: 하위 검토 추가
|
||||
ON 행 [하위추가] CLICK DO
|
||||
ADD 부모행 하위 편집 행 (2단계, upCklstRvwSn=부모.cklstRvwSn, rvwCnLvNo=2)
|
||||
END ON
|
||||
|
||||
// 영역 C: 일괄 저장
|
||||
ON [저장] 버튼 CLICK DO
|
||||
VALIDATE 선택된 점검표 존재
|
||||
FOR EACH 신규·변경된 검토 행 DO
|
||||
IF 신규 THEN
|
||||
CALL POST /as-checklist-service/api/v1/cklst-rvw
|
||||
ELSE
|
||||
CALL PUT /as-checklist-service/api/v1/cklst-rvw/{cklstRvwSn}/{cklstArtclSn}/{cklstSn}
|
||||
END IF
|
||||
END FOR
|
||||
CALL GET /as-checklist-service/api/v1/cklst-rvw?cklstSn={cklstSn}
|
||||
TOAST "체크리스트 항목이 저장되었습니다."
|
||||
END ON
|
||||
|
||||
// 영역 C: 행 삭제
|
||||
ON 행 [삭제] CLICK DO
|
||||
CONFIRM "선택한 항목을 삭제하시겠습니까?"
|
||||
IF 서버 저장된 행 THEN
|
||||
CALL DELETE /as-checklist-service/api/v1/cklst-rvw/{cklstRvwSn}/{cklstArtclSn}/{cklstSn}
|
||||
END IF
|
||||
REMOVE 그리드 행 (미저장 행은 API 호출 없이 제거)
|
||||
END ON
|
||||
|
||||
// 영역 C: 미리보기
|
||||
ON [미리보기] 버튼 CLICK DO
|
||||
OPEN 체크리스트 미리보기 다이얼로그 (읽기 전용 트리)
|
||||
// TBD: 미리보기 UI·API
|
||||
END ON
|
||||
|
||||
END
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 영역 A: 점검표 관리
|
||||
|
||||
선택된 점검표가 영역 C의 편집 대상이 된다. 행 클릭으로 선택 상태를 표시한다.
|
||||
|
||||
### 4-1. 헤더 / 툴바
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ----------- | ----------------------------------------- |
|
||||
| 섹션 타이틀 | 점검표 관리 |
|
||||
| 우측 버튼 | **[점검표 추가]** — 점검표 등록 다이얼로그 |
|
||||
|
||||
### 4-2. 그리드 컬럼
|
||||
|
||||
| 컬럼명 | 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` |
|
||||
| 관리 | — | — | 행별 **[관리] · [삭제]** |
|
||||
|
||||
- 행 클릭 시: `cklstSn` 선택 → 영역 C 헤더·그리드 갱신.
|
||||
- 페이지네이션: Spring `Pageable` (`page`, `size`, `sort`).
|
||||
|
||||
### 4-3. 버튼
|
||||
|
||||
| 버튼명 | 위치 | 동작 |
|
||||
| ---------- | -------------- | ------------------------------------------------- |
|
||||
| 점검표 추가 | 섹션 우측 상단 | 점검표 추가 다이얼로그 오픈 |
|
||||
| 관리 | 행별 | 점검표 수정 다이얼로그 (기존 값 초기화) |
|
||||
| 삭제 | 행별 | 확인 후 삭제. 선택 중인 점검표이면 영역 C 초기화. |
|
||||
|
||||
### 4-4. 다이얼로그: 점검표 추가 / 수정
|
||||
|
||||
| 표시명 | 입력 유형 | 필수 | 기본값 | 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 표시: 예/아니오 |
|
||||
|
||||
| 버튼명 | 동작 |
|
||||
| ------ | -------------------------------------- |
|
||||
| 저장 | POST(등록) 또는 PUT(수정) 후 목록 갱신 |
|
||||
| 취소 | 다이얼로그 닫기, 입력값 폐기 |
|
||||
| 닫기 | 다이얼로그 닫기 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 영역 B: 점검항목 관리
|
||||
|
||||
점검표 하단(영역 C)에서 선택할 **점검항목** 마스터를 관리한다.
|
||||
|
||||
### 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` |
|
||||
| 관리 | — | — | 행별 **[관리] · [삭제]** |
|
||||
|
||||
> **TBD**: 백엔드 `CklstArtcl`은 복합 PK(`cklstArtclSn`, `cklstSn`)이며 등록 시 `cklstSn` 필수. UI 다이얼로그에는 `cklstSn` 입력이 없음 — 소속 점검표 지정 방식·전역 마스터 여부 백엔드 정책 확인.
|
||||
|
||||
### 5-3. 다이얼로그: 점검항목 추가 / 수정
|
||||
|
||||
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
|
||||
| ---------- | --------- | ---- | ------ | ----------------- | ----------------------- |
|
||||
| 점검항목명 | Text | Y | — | `cklstArtclNm` | placeholder: "예: 법령검토" |
|
||||
| 사용여부 | Select | N | Y | `useYn` | Y/N |
|
||||
|
||||
| 버튼명 | 동작 |
|
||||
| ------ | -------------------------------------- |
|
||||
| 저장 | POST(등록) 또는 PUT(수정) 후 목록 갱신 |
|
||||
| 취소 | 다이얼로그 닫기 |
|
||||
| 닫기 | 다이얼로그 닫기 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 영역 C: 체크리스트 항목 (선택된 점검표)
|
||||
|
||||
영역 A에서 선택된 `cklstSn` 기준으로 **점검표검토**(계층형 검토내용)를 구성한다.
|
||||
|
||||
### 6-1. 헤더 / 툴바
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ----------- | -------------------------------------------------------------------- |
|
||||
| 섹션 타이틀 | 체크리스트 항목 (선택된 점검표: {cklstNm}) |
|
||||
| 우측 버튼 | **[미리보기]** · **[추가]** · **[저장]** — 점검표 미선택 시 C 비활성 |
|
||||
|
||||
### 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단계 행은 [하위추가] 미표시 |
|
||||
|
||||
#### 계층 정책
|
||||
|
||||
| 항목 | 내용 |
|
||||
| -------------- | -------------------------------------------------------------------- |
|
||||
| 최대 깊이 | 2단계 (`rvwCnLvNo`: 1, 2) |
|
||||
| 상위-하위 연결 | `upCklstRvwSn` — 2단계 행은 부모 1단계 `cklstRvwSn` 참조 |
|
||||
| 정렬 | `sortSeq` — 동일 레벨 내 순서 (TBD: UI 드래그 정렬 여부) |
|
||||
| 체크 사용 | `bchckUseYn` — 미리보기·실사용 화면 체크박스 노출 여부 (TBD UI 반영) |
|
||||
|
||||
### 6-3. 버튼
|
||||
|
||||
| 버튼명 | 동작 |
|
||||
| -------- | ------------------------------------------------------------ |
|
||||
| 추가 | 1단계 검토 행 1건 추가 (편집 가능) |
|
||||
| 저장 | 변경·신규 검토 행 일괄 POST/PUT (일괄 저장 API 없음) |
|
||||
| 미리보기 | 선택 점검표 체크리스트 읽기 전용 미리보기 (TBD) |
|
||||
| 하위추가 | 선택 1단계 행 아래 2단계 자식 행 추가 |
|
||||
| 삭제 | 확인 후 DELETE 또는 미저장 행 로컬 제거 |
|
||||
|
||||
---
|
||||
|
||||
## 7. API/DTO 명세
|
||||
|
||||
### 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) |
|
||||
|
||||
### 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` |
|
||||
|
||||
**CklstMng (응답)**
|
||||
|
||||
| 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?`
|
||||
|
||||
**CklstMngSearchParams**: `cklstNm?`, `taskSeComCd?`, `useYn?`, `docSeComCd?` + `page`, `size`, `sort`
|
||||
|
||||
**React Query hooks**: `useCklstMngPage`, `useCklstMng`, `useCreateCklstMng`, `useUpdateCklstMng`, `useDeleteCklstMng`
|
||||
|
||||
### 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` |
|
||||
|
||||
**CklstArtcl (응답)**
|
||||
|
||||
| Field | 타입 | 설명 |
|
||||
| ---------------- | ------- | -------------------- |
|
||||
| `cklstArtclSn` | number | 점검표항목일련번호 |
|
||||
| `cklstSn` | number | 점검표일련번호 |
|
||||
| `cklstArtclNm` | string? | 점검표항목명 |
|
||||
| `useYn` | string? | 사용여부 |
|
||||
| `rgtr` / `rgtrNm`| string? | 등록자 |
|
||||
| `regDt` | string? | 등록일시 |
|
||||
|
||||
**CklstArtclSaveRequest**: `cklstSn`\*, `cklstArtclNm?`, `useYn?`
|
||||
|
||||
**CklstArtclUpdateRequest**: `cklstArtclNm?`, `useYn?`
|
||||
|
||||
**CklstArtclId**: `{ cklstArtclSn: number; cklstSn: number }`
|
||||
|
||||
**React Query hooks**: `useCklstArtclPage`, `useCklstArtclList`, `useCklstArtcl`, `useCreateCklstArtcl`, `useUpdateCklstArtcl`, `useDeleteCklstArtcl`
|
||||
|
||||
### 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` |
|
||||
|
||||
**CklstRvw (응답)**
|
||||
|
||||
| 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?`
|
||||
|
||||
**CklstRvwUpdateRequestDto**: `upCklstRvwSn?`, `rvwCnLvNo?`, `rvwCn?`, `bchckUseYn?`, `sortSeq?`, `useYn?`
|
||||
|
||||
**CklstRvwSearchDto**: `cklstSn?`, `cklstArtclSn?`, `useYn?` + Pageable
|
||||
|
||||
### 7-5. 공통코드 (보조)
|
||||
|
||||
| UI 필드 | 코드 그룹 (TBD) | 예시 값 |
|
||||
| -------- | --------------- | -------------------------------------------- |
|
||||
| 문서구분 | `ASDOCSECD` 등 | FNRS, INSTL, PWRK, DESIGN |
|
||||
| 업무구분 | TBD | RE(재이용수), SF(하수처리시설), SP(하수관로) |
|
||||
| 사용여부 | 고정 | Y, N |
|
||||
|
||||
공통코드 조회: `@/entities/code` — `codeService`, `CodeSelect`
|
||||
|
||||
---
|
||||
|
||||
## 8. UI 구현 가이드
|
||||
|
||||
### 8-1. 기술 스택·원칙
|
||||
|
||||
- **공통 컴포넌트**: `@common/components` (`Dialog`, `Button`, `Input`, `Select`, `RadioGroup` 등)
|
||||
- **테이블**: `@tanstack/react-table` + `useDataTable`
|
||||
- **데이터**: React Query — `@/entities/cklst` hooks (검토 API는 entities 추가 후 연동)
|
||||
- **공통코드**: `@/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) |
|
||||
| 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` |
|
||||
|
||||
### 8-3. 영역별 UI 매핑
|
||||
|
||||
| 영역 | 주요 UI |
|
||||
| ---- | ----------------------------------------------------------------------- |
|
||||
| A | DataTable + 행 선택 + 점검표 추가/수정 Dialog |
|
||||
| B | DataTable + 점검항목 추가/수정 Dialog |
|
||||
| C | 계층형 편집 그리드(1·2단계) + [추가][저장][미리보기] + 행별 [하위추가][삭제] |
|
||||
| 공통 | `CodeSelect` (문서구분·업무구분), 사용여부 Y/N Select/Radio |
|
||||
|
||||
### 8-4. 다이얼로그·라우팅
|
||||
|
||||
| 다이얼로그명 | 트리거 | API 연동 |
|
||||
| ------------------ | ----------------- | ------------------------------------- |
|
||||
| 점검표 추가 | [점검표 추가] | `useCreateCklstMng` |
|
||||
| 점검표 수정 | 행 [관리] | `useUpdateCklstMng` |
|
||||
| 점검표 삭제 확인 | 행 [삭제] | `useDeleteCklstMng` |
|
||||
| 점검항목 추가 | [점검항목 추가] | `useCreateCklstArtcl` |
|
||||
| 점검항목 수정 | 행 [관리] | `useUpdateCklstArtcl` |
|
||||
| 체크리스트 미리보기 | [미리보기] | TBD — 읽기 전용 트리 렌더 |
|
||||
|
||||
라우트: `/checklist-management` (메뉴 URL은 CMS 등록값과 일치시킴)
|
||||
|
||||
### 8-5. 데이터 흐름(요약)
|
||||
|
||||
- **진입**: `useCklstMngPage` → 영역 A, `useCklstArtclPage` → 영역 B. C 비활성.
|
||||
- **점검표 선택**: `cklstSn` 설정 → 영역 C 활성 → `cklst-rvw` 목록 조회(TBD) → 트리 그리드 표시.
|
||||
- **점검표 CRUD**: Dialog → `useCreateCklstMng` / `useUpdateCklstMng` / `useDeleteCklstMng`.
|
||||
- **점검항목 CRUD**: Dialog → `useCreateCklstArtcl` / `useUpdateCklstArtcl` / `useDeleteCklstArtcl`.
|
||||
- **검토 저장**: [저장] → 신규 POST / 기존 PUT 순차 호출 (일괄 API 없음).
|
||||
- **영역 C 점검항목 Select**: `useCklstArtclList(cklstSn)` 또는 영역 B 전체 목록과 교차 — **TBD**.
|
||||
|
||||
### 8-6. 미확정 / 검증 필요
|
||||
|
||||
- [ ] 영역 B 점검항목 등록 시 `cklstSn` 필수 vs UI 무소속 마스터 — 백엔드·기획 확인
|
||||
- [ ] `점검항목코드`(ITEM_001) 표시 형식 vs `cklstArtclSn` 숫자 PK
|
||||
- [ ] 영역 C 전체 검토 트리 조회: `cklstSn` 단위 일괄 API 부재 — `by-artcl` 다건 호출 또는 백엔드 확장
|
||||
- [ ] `@/entities/cklst-rvw` entities·hooks 구현
|
||||
- [ ] 문서구분·업무구분 공통코드 상위코드 ID (`ASDOCSECD` 등)
|
||||
- [ ] [미리보기] 다이얼로그 UI·데이터 소스
|
||||
- [ ] `bchckUseYn` UI 반영 여부
|
||||
- [ ] 영역 A·B 페이지네이션·검색 UI 노출 여부
|
||||
- [ ] 권한(역할별 CRUD)
|
||||
@@ -0,0 +1,70 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
|
||||
import type {
|
||||
CklstArtcl,
|
||||
CklstArtclId,
|
||||
CklstArtclSaveRequest,
|
||||
CklstArtclSearchParams,
|
||||
CklstArtclService,
|
||||
CklstArtclUpdateRequest,
|
||||
} from "../model/cklst-artcl-interface";
|
||||
|
||||
async function findPage(params: CklstArtclSearchParams): Promise<PageResponse<CklstArtcl>> {
|
||||
const response = await client.get<PageResponse<CklstArtcl>>(
|
||||
ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL,
|
||||
{ params },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findByCklstSn(cklstSn: number): Promise<CklstArtcl[]> {
|
||||
const response = await client.get<CklstArtcl[]>(
|
||||
`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL}/by-cklst/${cklstSn}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findById(cklstArtclSn: number, cklstSn: number): Promise<CklstArtcl> {
|
||||
const response = await client.get<CklstArtcl>(
|
||||
`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL}/${cklstArtclSn}/${cklstSn}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function create(body: CklstArtclSaveRequest): Promise<CklstArtclId> {
|
||||
const response = await client.post<CklstArtclId>(
|
||||
ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL,
|
||||
body,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function update(
|
||||
cklstArtclSn: number,
|
||||
cklstSn: number,
|
||||
body: CklstArtclUpdateRequest,
|
||||
): Promise<CklstArtclId> {
|
||||
const response = await client.put<CklstArtclId>(
|
||||
`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL}/${cklstArtclSn}/${cklstSn}`,
|
||||
body,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function remove(cklstArtclSn: number, cklstSn: number): Promise<CklstArtclId> {
|
||||
const response = await client.delete<CklstArtclId>(
|
||||
`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL}/${cklstArtclSn}/${cklstSn}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const cklstArtclService = {
|
||||
findPage,
|
||||
findByCklstSn,
|
||||
findById,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
} satisfies CklstArtclService;
|
||||
@@ -0,0 +1,48 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
|
||||
import type {
|
||||
CklstMng,
|
||||
CklstMngSaveRequest,
|
||||
CklstMngSearchParams,
|
||||
CklstMngService,
|
||||
} from "../model/cklst-mng-interface";
|
||||
|
||||
async function findPage(params: CklstMngSearchParams): Promise<PageResponse<CklstMng>> {
|
||||
const response = await client.get<PageResponse<CklstMng>>(ENDPOINT.AS_CHECKLIST_SERVICE.CKLST, {
|
||||
params,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findById(cklstSn: number): Promise<CklstMng> {
|
||||
const response = await client.get<CklstMng>(`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST}/${cklstSn}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function create(body: CklstMngSaveRequest): Promise<number> {
|
||||
const response = await client.post<number>(ENDPOINT.AS_CHECKLIST_SERVICE.CKLST, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function update(cklstSn: number, body: CklstMngSaveRequest): Promise<number> {
|
||||
const response = await client.put<number>(
|
||||
`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST}/${cklstSn}`,
|
||||
body,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function remove(cklstSn: number): Promise<number> {
|
||||
const response = await client.delete<number>(`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST}/${cklstSn}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const cklstMngService = {
|
||||
findPage,
|
||||
findById,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
} satisfies CklstMngService;
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type {
|
||||
CklstArtclSaveRequest,
|
||||
CklstArtclSearchParams,
|
||||
CklstArtclUpdateRequest,
|
||||
} from "../model/cklst-artcl-interface";
|
||||
import type { CklstMngSaveRequest, CklstMngSearchParams } from "../model/cklst-mng-interface";
|
||||
|
||||
import { cklstArtclService } from "./cklst-artcl-service";
|
||||
import { cklstMngService } from "./cklst-mng-service";
|
||||
|
||||
const cklstMngBaseKey = [ENDPOINT.AS_CHECKLIST_SERVICE.CKLST] as const;
|
||||
const cklstArtclBaseKey = [ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL] as const;
|
||||
|
||||
export function useCklstMngPage(params: CklstMngSearchParams) {
|
||||
return useQuery({
|
||||
queryKey: [...cklstMngBaseKey, "page", params],
|
||||
queryFn: () => cklstMngService.findPage(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCklstMng(cklstSn: number | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [...cklstMngBaseKey, cklstSn],
|
||||
queryFn: () => cklstMngService.findById(cklstSn!),
|
||||
enabled: cklstSn != null,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateCklstMng() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (body: CklstMngSaveRequest) => cklstMngService.create(body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: cklstMngBaseKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCklstMng() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ cklstSn, body }: { cklstSn: number; body: CklstMngSaveRequest }) =>
|
||||
cklstMngService.update(cklstSn, body),
|
||||
onSuccess: (_data, { cklstSn }) => {
|
||||
queryClient.invalidateQueries({ queryKey: cklstMngBaseKey });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...cklstMngBaseKey, cklstSn],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCklstMng() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (cklstSn: number) => cklstMngService.remove(cklstSn),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: cklstMngBaseKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCklstArtclPage(params: CklstArtclSearchParams) {
|
||||
const { cklstSn } = params;
|
||||
|
||||
return useQuery({
|
||||
queryKey: [...cklstArtclBaseKey, "page", params],
|
||||
queryFn: () => cklstArtclService.findPage(params),
|
||||
enabled: cklstSn != null,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCklstArtclList(cklstSn: number | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [...cklstArtclBaseKey, "by-cklst", cklstSn],
|
||||
queryFn: () => cklstArtclService.findByCklstSn(cklstSn!),
|
||||
enabled: cklstSn != null,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCklstArtcl(params: {
|
||||
cklstArtclSn: number | null | undefined;
|
||||
cklstSn: number | null | undefined;
|
||||
}) {
|
||||
const { cklstArtclSn, cklstSn } = params;
|
||||
|
||||
return useQuery({
|
||||
queryKey: [...cklstArtclBaseKey, cklstArtclSn, cklstSn],
|
||||
queryFn: () => cklstArtclService.findById(cklstArtclSn!, cklstSn!),
|
||||
enabled: cklstArtclSn != null && cklstSn != null,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateCklstArtcl() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (body: CklstArtclSaveRequest) => cklstArtclService.create(body),
|
||||
onSuccess: (_data, { cklstSn }) => {
|
||||
queryClient.invalidateQueries({ queryKey: cklstArtclBaseKey });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...cklstArtclBaseKey, "page", { cklstSn }],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...cklstArtclBaseKey, "by-cklst", cklstSn],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCklstArtcl() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
cklstArtclSn,
|
||||
cklstSn,
|
||||
body,
|
||||
}: {
|
||||
cklstArtclSn: number;
|
||||
cklstSn: number;
|
||||
body: CklstArtclUpdateRequest;
|
||||
}) => cklstArtclService.update(cklstArtclSn, cklstSn, body),
|
||||
onSuccess: (_data, { cklstArtclSn, cklstSn }) => {
|
||||
queryClient.invalidateQueries({ queryKey: cklstArtclBaseKey });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...cklstArtclBaseKey, cklstArtclSn, cklstSn],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...cklstArtclBaseKey, "page", { cklstSn }],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...cklstArtclBaseKey, "by-cklst", cklstSn],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCklstArtcl() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
cklstArtclSn,
|
||||
cklstSn,
|
||||
}: {
|
||||
cklstArtclSn: number;
|
||||
cklstSn: number;
|
||||
}) => cklstArtclService.remove(cklstArtclSn, cklstSn),
|
||||
onSuccess: (_data, { cklstSn }) => {
|
||||
queryClient.invalidateQueries({ queryKey: cklstArtclBaseKey });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...cklstArtclBaseKey, "page", { cklstSn }],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...cklstArtclBaseKey, "by-cklst", cklstSn],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export { cklstArtclService } from "./api/cklst-artcl-service";
|
||||
export { cklstMngService } from "./api/cklst-mng-service";
|
||||
export {
|
||||
useCklstArtcl,
|
||||
useCklstArtclList,
|
||||
useCklstArtclPage,
|
||||
useCklstMng,
|
||||
useCklstMngPage,
|
||||
useCreateCklstArtcl,
|
||||
useCreateCklstMng,
|
||||
useDeleteCklstArtcl,
|
||||
useDeleteCklstMng,
|
||||
useUpdateCklstArtcl,
|
||||
useUpdateCklstMng,
|
||||
} from "./api/queries";
|
||||
export type {
|
||||
CklstArtcl,
|
||||
CklstArtclId,
|
||||
CklstArtclSaveRequest,
|
||||
CklstArtclSearchParams,
|
||||
CklstArtclService,
|
||||
CklstArtclUpdateRequest,
|
||||
} from "./model/cklst-artcl-interface";
|
||||
export type {
|
||||
CklstMng,
|
||||
CklstMngSaveRequest,
|
||||
CklstMngSearchParams,
|
||||
CklstMngService,
|
||||
} from "./model/cklst-mng-interface";
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { PageableParams, PageResponse } from "@/shared/model";
|
||||
|
||||
export interface CklstArtclId {
|
||||
cklstArtclSn: number;
|
||||
cklstSn: number;
|
||||
}
|
||||
|
||||
export interface CklstArtcl {
|
||||
cklstArtclSn: number;
|
||||
cklstSn: number;
|
||||
cklstArtclNm?: string;
|
||||
useYn?: "Y" | "N";
|
||||
rgtr?: string;
|
||||
rgtrNm?: string;
|
||||
mdfr?: string;
|
||||
regDt?: string;
|
||||
mdfcnDt?: string;
|
||||
}
|
||||
|
||||
export interface CklstArtclSearchParams extends PageableParams {
|
||||
cklstSn?: number;
|
||||
cklstArtclNm?: string;
|
||||
useYn?: "Y" | "N";
|
||||
}
|
||||
|
||||
export interface CklstArtclSaveRequest {
|
||||
cklstSn: number;
|
||||
cklstArtclNm?: string;
|
||||
useYn?: "Y" | "N";
|
||||
}
|
||||
|
||||
export interface CklstArtclUpdateRequest {
|
||||
cklstArtclNm?: string;
|
||||
useYn?: "Y" | "N";
|
||||
}
|
||||
|
||||
export interface CklstArtclService {
|
||||
findPage(params: CklstArtclSearchParams): Promise<PageResponse<CklstArtcl>>;
|
||||
findByCklstSn(cklstSn: number): Promise<CklstArtcl[]>;
|
||||
findById(cklstArtclSn: number, cklstSn: number): Promise<CklstArtcl>;
|
||||
create(body: CklstArtclSaveRequest): Promise<CklstArtclId>;
|
||||
update(
|
||||
cklstArtclSn: number,
|
||||
cklstSn: number,
|
||||
body: CklstArtclUpdateRequest,
|
||||
): Promise<CklstArtclId>;
|
||||
remove(cklstArtclSn: number, cklstSn: number): Promise<CklstArtclId>;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { PageableParams, PageResponse } from "@/shared/model";
|
||||
|
||||
export interface CklstMng {
|
||||
cklstSn: number;
|
||||
cklstNm: string;
|
||||
taskSeComCd: string;
|
||||
useYn?: "Y" | "N";
|
||||
docSeComCd?: string;
|
||||
rgtr?: string;
|
||||
rgtrNm?: string;
|
||||
mdfr?: string;
|
||||
regDt?: string;
|
||||
mdfcnDt?: string;
|
||||
}
|
||||
|
||||
export interface CklstMngSearchParams extends PageableParams {
|
||||
cklstNm?: string;
|
||||
taskSeComCd?: string;
|
||||
useYn?: "Y" | "N";
|
||||
docSeComCd?: string;
|
||||
}
|
||||
|
||||
export interface CklstMngSaveRequest {
|
||||
cklstNm: string;
|
||||
taskSeComCd: string;
|
||||
useYn?: "Y" | "N";
|
||||
docSeComCd?: string;
|
||||
}
|
||||
|
||||
export interface CklstMngService {
|
||||
findPage(params: CklstMngSearchParams): Promise<PageResponse<CklstMng>>;
|
||||
findById(cklstSn: number): Promise<CklstMng>;
|
||||
create(body: CklstMngSaveRequest): Promise<number>;
|
||||
update(cklstSn: number, body: CklstMngSaveRequest): Promise<number>;
|
||||
remove(cklstSn: number): Promise<number>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { client } from "@/shared/api/client";
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type { Code, CodeService } from "../model/code-interface";
|
||||
@@ -9,9 +9,7 @@ async function getList(upCd: string): Promise<Code[]> {
|
||||
}
|
||||
|
||||
async function getListByUseYn(upCd: string, useYn: string): Promise<Code[]> {
|
||||
const response = await client.get<Code[]>(
|
||||
`${ENDPOINT.CMS_SERVICE.CODE}/${upCd}/${useYn}`,
|
||||
);
|
||||
const response = await client.get<Code[]>(`${ENDPOINT.CMS_SERVICE.CODE}/${upCd}/${useYn}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { client } from "@/shared/api/client";
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
|
||||
@@ -11,17 +11,14 @@ import type {
|
||||
} 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 },
|
||||
);
|
||||
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}`,
|
||||
);
|
||||
const response = await client.get<ElaprApln>(`${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN}/${aplnId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { client } from "@/shared/api/client";
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
|
||||
@@ -26,17 +26,11 @@ async function findById(elaprTmpltId: string): Promise<ElaprTmplt> {
|
||||
}
|
||||
|
||||
async function create(body: ElaprTmpltSaveRequest): Promise<ElaprTmplt> {
|
||||
const response = await client.post<ElaprTmplt>(
|
||||
ENDPOINT.AS_ELAPR_SERVICE.ELAPR_TMPLT,
|
||||
body,
|
||||
);
|
||||
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> {
|
||||
async function update(elaprTmpltId: string, body: ElaprTmpltUpdateRequest): Promise<ElaprTmplt> {
|
||||
const response = await client.put<ElaprTmplt>(
|
||||
`${ENDPOINT.AS_ELAPR_SERVICE.ELAPR_TMPLT}/${elaprTmpltId}`,
|
||||
body,
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export { AprvrPickerDialog } from "./ui/aprvr-picker-dialog";
|
||||
export type { ReviewerOption } from "./model/mock-reviewers";
|
||||
@@ -1,2 +0,0 @@
|
||||
export { ElaprTmpltFormDialog } from "./ui/elapr-tmplt-form-dialog";
|
||||
export type { ElaprTmpltFormValues } from "./ui/elapr-tmplt-form-dialog";
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ElaprAplnRow } from "../model/apln-row-types";
|
||||
|
||||
/** 그리드 행 순서(1-based)로 결재순서번호 일괄 채번 */
|
||||
export function assignAtrzSeqNumbers(rows: ElaprAplnRow[]): ElaprAplnRow[] {
|
||||
return rows.map((row, index) => ({ ...row, atrzSeqNo: index + 1 }));
|
||||
}
|
||||
+22
-11
@@ -6,7 +6,10 @@ import { arrayMove } from "@dnd-kit/sortable";
|
||||
|
||||
import type { ElaprApln } from "@/entities/elapr";
|
||||
|
||||
import type { ElaprAplnRow } from "../model/types";
|
||||
import type { ElaprAplnRow } from "../model/apln-row-types";
|
||||
|
||||
import { assignAtrzSeqNumbers } from "./assign-atrz-seq";
|
||||
import { validateAtrzOrder } from "./validators";
|
||||
|
||||
function toRow(item: ElaprApln): ElaprAplnRow {
|
||||
return { ...item, rowKey: item.aplnId, isNew: false, isDirty: false };
|
||||
@@ -41,14 +44,12 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) {
|
||||
if (!elaprTmpltId || isEditingLocked) return;
|
||||
|
||||
const rowKey = createDraftRowKey();
|
||||
const nextSeq = rows.length + 1;
|
||||
|
||||
setRows((prev) => [
|
||||
...prev,
|
||||
{
|
||||
rowKey,
|
||||
elaprTmpltId,
|
||||
atrzSeqNo: nextSeq,
|
||||
atrzSeComCd: "",
|
||||
aprvrUuid: "",
|
||||
aprvrNm: "",
|
||||
@@ -57,7 +58,7 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) {
|
||||
},
|
||||
]);
|
||||
setEditingRowKey(rowKey);
|
||||
}, [elaprTmpltId, isEditingLocked, rows.length]);
|
||||
}, [elaprTmpltId, isEditingLocked]);
|
||||
|
||||
const startEdit = useCallback(
|
||||
(rowKey: string) => {
|
||||
@@ -110,26 +111,36 @@ export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) {
|
||||
}, []);
|
||||
|
||||
const reorderRows = useCallback(
|
||||
(activeKey: string, overKey: string): ElaprAplnRow[] => {
|
||||
if (isEditingLocked) return [];
|
||||
(
|
||||
activeKey: string,
|
||||
overKey: string,
|
||||
): { payloadRows?: ElaprAplnRow[]; orderError?: string | null } => {
|
||||
if (isEditingLocked) return {};
|
||||
|
||||
let newRows: ElaprAplnRow[] = [];
|
||||
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);
|
||||
if (oldIndex < 0 || newIndex < 0) return prev;
|
||||
|
||||
const moved = arrayMove(prev, oldIndex, newIndex).map((row, idx) => ({
|
||||
const moved = assignAtrzSeqNumbers(arrayMove(prev, oldIndex, newIndex)).map((row) => ({
|
||||
...row,
|
||||
atrzSeqNo: idx + 1,
|
||||
isDirty: true,
|
||||
}));
|
||||
newRows = moved;
|
||||
|
||||
const error = validateAtrzOrder(moved);
|
||||
if (error) {
|
||||
orderError = error;
|
||||
return prev;
|
||||
}
|
||||
|
||||
payloadRows = moved;
|
||||
return moved;
|
||||
});
|
||||
|
||||
return newRows;
|
||||
return { payloadRows, orderError };
|
||||
},
|
||||
[isEditingLocked],
|
||||
);
|
||||
+1
-6
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import { KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
|
||||
import { sortableKeyboardCoordinates } from "@dnd-kit/sortable";
|
||||
|
||||
export function useDndSensors() {
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { ElaprAplnRow } from "../model/apln-row-types";
|
||||
|
||||
export const ATRZ_REVIEWER_CD = "ATRZ01";
|
||||
export const ATRZ_FINAL_CD = "ATRZ03";
|
||||
|
||||
/** 검토자 최상단 · 최종승인자 최하단 순서 검증 */
|
||||
export function validateAtrzOrder(rows: ElaprAplnRow[]): string | null {
|
||||
const reviewerIndices = rows
|
||||
.map((row, index) => (row.atrzSeComCd === ATRZ_REVIEWER_CD ? index : -1))
|
||||
.filter((index) => index >= 0);
|
||||
|
||||
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 finalIndices = rows
|
||||
.map((row, index) => (row.atrzSeComCd === ATRZ_FINAL_CD ? index : -1))
|
||||
.filter((index) => index >= 0);
|
||||
|
||||
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 (hasNonFinalBelow) {
|
||||
return "최종승인자는 결재선 최하단에만 배치할 수 있습니다.";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateAplnRow(row: ElaprAplnRow): string | null {
|
||||
if (!row.atrzSeComCd?.trim()) {
|
||||
return "결재구분을 선택해 주세요.";
|
||||
}
|
||||
if (!row.aprvrUuid?.trim()) {
|
||||
return "결재자를 선택해 주세요.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateUniqueSeqNo(rows: ElaprAplnRow[]): string | null {
|
||||
const seqSet = new Set<number>();
|
||||
for (const row of rows) {
|
||||
if (row.atrzSeqNo == null) continue;
|
||||
if (seqSet.has(row.atrzSeqNo)) {
|
||||
return "동일 서식 내 결재순서번호가 중복됩니다.";
|
||||
}
|
||||
seqSet.add(row.atrzSeqNo);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -3,8 +3,9 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import type { ElaprTmplt } from "@/entities/elapr";
|
||||
import { ElaprAplnGrid } from "@/widgets/elapr-apln-grid";
|
||||
import { ElaprTmpltList } from "@/widgets/elapr-tmplt-list";
|
||||
|
||||
import { ElaprAplnGrid } from "./elapr-apln-grid";
|
||||
import { ElaprTmpltList } from "./elapr-tmplt-list";
|
||||
|
||||
function ApprovalLineManagementPage() {
|
||||
const [selectedTmplt, setSelectedTmplt] = useState<ElaprTmplt | null>(null);
|
||||
|
||||
+4
-7
@@ -1,6 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||
|
||||
import {
|
||||
Button,
|
||||
DataTable,
|
||||
@@ -11,11 +14,6 @@ import {
|
||||
DialogTitle,
|
||||
Input,
|
||||
} from "@common/components";
|
||||
import {
|
||||
createColumnHelper,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import type { ReviewerOption } from "../model/mock-reviewers";
|
||||
import { createMockReviewers } from "../model/mock-reviewers";
|
||||
@@ -36,8 +34,7 @@ function AprvrPickerDialog({ open, onOpenChange, onSelect }: AprvrPickerDialogPr
|
||||
const q = keyword.trim().toLowerCase();
|
||||
if (!q) return reviewers;
|
||||
return reviewers.filter(
|
||||
(r) =>
|
||||
r.name.toLowerCase().includes(q) || r.deptNm.toLowerCase().includes(q),
|
||||
(r) => r.name.toLowerCase().includes(q) || r.deptNm.toLowerCase().includes(q),
|
||||
);
|
||||
}, [keyword, reviewers]);
|
||||
|
||||
+44
-28
@@ -12,10 +12,9 @@ import {
|
||||
useElaprAplnPage,
|
||||
useUpdateElaprApln,
|
||||
} from "@/entities/elapr";
|
||||
import { AprvrPickerDialog, type ReviewerOption } from "@/features/aprvr-picker";
|
||||
import {
|
||||
Button,
|
||||
LoadingWithAnimation,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableHead,
|
||||
@@ -23,11 +22,14 @@ import {
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import { assignAtrzSeqNumbers } from "../lib/assign-atrz-seq";
|
||||
import { useAplnRows } from "../lib/use-apln-rows";
|
||||
import { useDndSensors } from "../lib/use-dnd-sensors";
|
||||
import { validateAplnRow, validateUniqueSeqNo } from "../lib/validators";
|
||||
import type { ElaprAplnRow } from "../model/types";
|
||||
import { validateAplnRow, validateAtrzOrder } from "../lib/validators";
|
||||
import type { ElaprAplnRow } from "../model/apln-row-types";
|
||||
import type { ReviewerOption } from "../model/mock-reviewers";
|
||||
|
||||
import { AprvrPickerDialog } from "./aprvr-picker-dialog";
|
||||
import { SortableAplnRow } from "./sortable-apln-row";
|
||||
|
||||
interface ElaprAplnGridProps {
|
||||
@@ -73,7 +75,15 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
reorderRows(String(active.id), String(over.id));
|
||||
|
||||
const { orderError } = reorderRows(String(active.id), String(over.id));
|
||||
if (orderError) {
|
||||
alert(orderError);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRowChange = (row: ElaprAplnRow, patch: Partial<ElaprAplnRow>) => {
|
||||
updateRow(row.rowKey, patch);
|
||||
};
|
||||
|
||||
const handleRowSave = async (row: ElaprAplnRow) => {
|
||||
@@ -82,29 +92,34 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
|
||||
alert(fieldError);
|
||||
return;
|
||||
}
|
||||
const dupError = validateUniqueSeqNo(rows.map((r) => (r.rowKey === row.rowKey ? row : r)));
|
||||
if (dupError) {
|
||||
alert(dupError);
|
||||
|
||||
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 (row.isNew) {
|
||||
if (savedRow.isNew) {
|
||||
await createMutation.mutateAsync({
|
||||
elaprTmpltId,
|
||||
aprvrUuid: row.aprvrUuid,
|
||||
atrzSeComCd: row.atrzSeComCd,
|
||||
atrzSeqNo: row.atrzSeqNo,
|
||||
aprvrUuid: savedRow.aprvrUuid,
|
||||
atrzSeComCd: savedRow.atrzSeComCd,
|
||||
atrzSeqNo: savedRow.atrzSeqNo,
|
||||
});
|
||||
} else if (row.aplnId) {
|
||||
} else if (savedRow.aplnId) {
|
||||
await updateMutation.mutateAsync({
|
||||
aplnId: row.aplnId,
|
||||
aplnId: savedRow.aplnId,
|
||||
body: {
|
||||
elaprTmpltId,
|
||||
aprvrUuid: row.aprvrUuid,
|
||||
atrzSeComCd: row.atrzSeComCd,
|
||||
atrzSeqNo: row.atrzSeqNo,
|
||||
aprvrUuid: savedRow.aprvrUuid,
|
||||
atrzSeComCd: savedRow.atrzSeComCd,
|
||||
atrzSeqNo: savedRow.atrzSeqNo,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -130,13 +145,14 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
|
||||
const handleBulkSave = async () => {
|
||||
if (!elaprTmpltId || isEditingLocked) return;
|
||||
|
||||
const dupError = validateUniqueSeqNo(rows);
|
||||
if (dupError) {
|
||||
alert(dupError);
|
||||
const rowsWithSeq = assignAtrzSeqNumbers(rows);
|
||||
const orderError = validateAtrzOrder(rowsWithSeq);
|
||||
if (orderError) {
|
||||
alert(orderError);
|
||||
return;
|
||||
}
|
||||
|
||||
const dirtyRows = rows.filter((r) => r.isDirty || r.isNew);
|
||||
const dirtyRows = rowsWithSeq.filter((r) => r.isDirty || r.isNew);
|
||||
|
||||
for (const row of dirtyRows) {
|
||||
const fieldError = validateAplnRow(row);
|
||||
@@ -198,10 +214,9 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
|
||||
</div>
|
||||
|
||||
{aplnQuery.isPending && elaprTmpltId ? (
|
||||
<div className="flex h-full w-full items-center justify-center py-8">
|
||||
<div className="relative size-[4rem]">
|
||||
<LoadingWithAnimation />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-[4rem]" />
|
||||
<Skeleton className="h-[16rem]" />
|
||||
</div>
|
||||
) : !elaprTmpltId ? (
|
||||
<p className="py-8 text-center text-lg text-muted-foreground">
|
||||
@@ -230,18 +245,19 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
|
||||
items={rows.map((r) => r.rowKey)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{rows.map((row) => (
|
||||
{rows.map((row, index) => (
|
||||
<SortableAplnRow
|
||||
key={row.rowKey}
|
||||
row={row}
|
||||
displaySeqNo={index + 1}
|
||||
isEditing={editingRowKey === row.rowKey}
|
||||
isLocked={isEditingLocked}
|
||||
dragDisabled={isEditingLocked}
|
||||
dragDisabled={isEditingLocked || !row.atrzSeComCd}
|
||||
onStartEdit={() => startEdit(row.rowKey)}
|
||||
onSave={() => handleRowSave(row)}
|
||||
onCancel={cancelEdit}
|
||||
onDelete={() => handleRowDelete(row)}
|
||||
onChange={(patch) => updateRow(row.rowKey, patch)}
|
||||
onChange={(patch) => handleRowChange(row, patch)}
|
||||
onPickAprvr={() => {
|
||||
setPickingRowKey(row.rowKey);
|
||||
setPickerOpen(true);
|
||||
+9
-8
@@ -41,13 +41,14 @@ function ElaprTmpltFormDialog({
|
||||
isSubmitting,
|
||||
onSubmit,
|
||||
}: ElaprTmpltFormDialogProps) {
|
||||
const { register, handleSubmit, reset, setValue, watch } = useForm<ElaprTmpltFormValues>({
|
||||
defaultValues: {
|
||||
elaprTmpltNm: "",
|
||||
docSeComCd: "",
|
||||
elaprTmpltExpln: "",
|
||||
},
|
||||
});
|
||||
const { register, handleSubmit, reset, getValues, setValue, watch } =
|
||||
useForm<ElaprTmpltFormValues>({
|
||||
defaultValues: {
|
||||
elaprTmpltNm: "",
|
||||
docSeComCd: "",
|
||||
elaprTmpltExpln: "",
|
||||
},
|
||||
});
|
||||
|
||||
const docSeComCd = watch("docSeComCd");
|
||||
|
||||
@@ -81,7 +82,7 @@ function ElaprTmpltFormDialog({
|
||||
{mode === "edit" ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="elaprTmpltId">전자결재서식아이디</Label>
|
||||
<Input id="elaprTmpltId" disabled {...register("elaprTmpltId")} />
|
||||
<p className="p-2 text-xl">{getValues("elaprTmpltId")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
+2
-1
@@ -12,11 +12,12 @@ import {
|
||||
useElaprTmpltPage,
|
||||
useUpdateElaprTmplt,
|
||||
} from "@/entities/elapr";
|
||||
import { ElaprTmpltFormDialog, type ElaprTmpltFormValues } from "@/features/elapr-tmplt-dialog";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import { createTmpltColumns } from "../model/columns";
|
||||
|
||||
import { ElaprTmpltFormDialog, type ElaprTmpltFormValues } from "./elapr-tmplt-form-dialog";
|
||||
|
||||
interface ElaprTmpltListProps {
|
||||
selectedTmpltId: string | null;
|
||||
onSelectTmplt: (tmplt: ElaprTmplt | null) => void;
|
||||
+5
-17
@@ -5,12 +5,13 @@ import { CSS } from "@dnd-kit/utilities";
|
||||
import { Equal } from "lucide-react";
|
||||
|
||||
import { CodeSelect, findCodeLabel, useCodeList } from "@/entities/code";
|
||||
import { Button, Input, TableCell, TableRow } from "@common/components";
|
||||
import { Button, TableCell, TableRow } from "@common/components";
|
||||
|
||||
import type { ElaprAplnRow } from "../model/types";
|
||||
import type { ElaprAplnRow } from "../model/apln-row-types";
|
||||
|
||||
interface SortableAplnRowProps {
|
||||
row: ElaprAplnRow;
|
||||
displaySeqNo: number;
|
||||
isEditing: boolean;
|
||||
isLocked: boolean;
|
||||
dragDisabled: boolean;
|
||||
@@ -24,6 +25,7 @@ interface SortableAplnRowProps {
|
||||
|
||||
function SortableAplnRow({
|
||||
row,
|
||||
displaySeqNo,
|
||||
isEditing,
|
||||
isLocked,
|
||||
dragDisabled,
|
||||
@@ -66,21 +68,7 @@ function SortableAplnRow({
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell align="center">{row.isNew ? "-" : (row.aplnId ?? "-")}</TableCell>
|
||||
<TableCell align="center">
|
||||
{isEditing ? (
|
||||
<Input
|
||||
type="number"
|
||||
className="w-20"
|
||||
min={1}
|
||||
value={row.atrzSeqNo ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ atrzSeqNo: Number.parseInt(e.target.value, 10) || undefined })
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
(row.atrzSeqNo ?? "-")
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">{displaySeqNo}</TableCell>
|
||||
<TableCell align="center">
|
||||
{isEditing ? (
|
||||
<CodeSelect
|
||||
@@ -14,6 +14,10 @@ export const ENDPOINT = {
|
||||
ELAPR_TMPLT: "/as-elapr-service/api/v1/elapr-tmplt",
|
||||
ELAPR_APLN: "/as-elapr-service/api/v1/elapr-apln",
|
||||
},
|
||||
AS_CHECKLIST_SERVICE: {
|
||||
CKLST: "/as-checklist-service/api/v1/cklst",
|
||||
CKLST_ARTCL: "/as-checklist-service/api/v1/cklst-artcl",
|
||||
},
|
||||
CMS_SERVICE: {
|
||||
CODE: "/cms-service/api/v1/code/cache",
|
||||
MENU_ROLES: "/cms-service/api/v1/menu-roles",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
|
||||
import type { UseQueryResult } from "@tanstack/react-query";
|
||||
import { useReactTable, type TableOptions } from "@tanstack/react-table";
|
||||
import { DataTable } from "@common/components";
|
||||
import { type TableOptions, useReactTable } from "@tanstack/react-table";
|
||||
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
import { NoData } from "@/shared/ui/no-data";
|
||||
import { DataTable, Skeleton } from "@common/components";
|
||||
|
||||
import { matchQueryStatus } from "./match-query-status";
|
||||
|
||||
@@ -21,11 +22,7 @@ interface UseDataTableOptions<T> {
|
||||
dataTableOptions?: DataTableOptions<T>;
|
||||
}
|
||||
|
||||
export function useDataTable<T>({
|
||||
query,
|
||||
tableOptions,
|
||||
dataTableOptions,
|
||||
}: UseDataTableOptions<T>) {
|
||||
export function useDataTable<T>({ query, tableOptions, dataTableOptions }: UseDataTableOptions<T>) {
|
||||
const table = useReactTable({
|
||||
...tableOptions,
|
||||
data: query.data?.content ?? [],
|
||||
@@ -34,7 +31,12 @@ export function useDataTable<T>({
|
||||
|
||||
return {
|
||||
tableComponent: matchQueryStatus(query, {
|
||||
Pending: <p className="py-8 text-center text-sm text-muted-foreground">로딩 중…</p>,
|
||||
Pending: (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-[4rem]" />
|
||||
<Skeleton className="h-[16rem]" />
|
||||
</div>
|
||||
),
|
||||
Empty: <NoData />,
|
||||
Success: () => <DataTable table={table} {...dataTableOptions} />,
|
||||
}),
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { ElaprAplnGrid } from "./ui/elapr-apln-grid";
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { ElaprAplnRow } from "../model/types";
|
||||
|
||||
export function validateAplnRow(row: ElaprAplnRow): string | null {
|
||||
if (row.atrzSeqNo == null || Number.isNaN(row.atrzSeqNo)) {
|
||||
return "결재순서번호를 입력해 주세요.";
|
||||
}
|
||||
if (!row.atrzSeComCd?.trim()) {
|
||||
return "결재구분을 선택해 주세요.";
|
||||
}
|
||||
if (!row.aprvrUuid?.trim()) {
|
||||
return "결재자를 선택해 주세요.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateUniqueSeqNo(rows: ElaprAplnRow[]): string | null {
|
||||
const seqSet = new Set<number>();
|
||||
for (const row of rows) {
|
||||
if (row.atrzSeqNo == null) continue;
|
||||
if (seqSet.has(row.atrzSeqNo)) {
|
||||
return "동일 서식 내 결재순서번호가 중복됩니다.";
|
||||
}
|
||||
seqSet.add(row.atrzSeqNo);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { ElaprTmpltList } from "./ui/elapr-tmplt-list";
|
||||
Reference in New Issue
Block a user