[as] feat: 결재선 관리 페이지 구현
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export { ApprovalLineManagementPage as default } from "@/pages/approval-line-management";
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ---------- | -------------------------------------------- |
|
||||
| 버전 | 0.1 (초안) |
|
||||
| 버전 | 0.3 |
|
||||
| 프로세스명 | UI설계 |
|
||||
| 화면명 | 결재선 관리 |
|
||||
| 화면 유형 | [v] 조회 [v] 입력 [v] 수정 [v] 삭제 [ ] 출력 |
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
## 2. 화면 목적
|
||||
|
||||
전자결재 처리에 사용할 **결재서식**(템플릿)을 등록·관리하고, 각 서식별로 **결재선**(접수 -> 검토 -> 승인 -> 최종승인 단계)을 구성한다. 상단의 결재서식 목록에서 하나를 선택하면 하단 영역에 해당 서식의 결재 단계(결재자/순서/구분)가 표시되며, 사용자는 결재자 추가·수정·삭제와 결재선 저장을 수행한다.
|
||||
전자결재 처리에 사용할 **결재서식**(템플릿)을 등록·관리하고, 각 서식별로 **결재선**(결재구분·결재자·순서)을 구성한다. 상단의 결재서식 목록에서 하나를 선택하면 하단 영역에 해당 서식의 결재선이 표시되며, 사용자는 **그리드 인라인 폼**으로 결재자를 등록·수정한다. **행 추가 또는 한 행 수정 중에는 다른 행을 편집할 수 없다**(단일 행 편집 잠금). 드래그로 순서를 변경한 뒤 **[결재선 저장]** 으로 서버에 반영한다.
|
||||
|
||||
---
|
||||
|
||||
@@ -23,30 +23,27 @@
|
||||
### 3-1. 전체 레이아웃
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 페이지 타이틀: 결재선 관리 브레드크럼: 디지털행정지원 > … > │
|
||||
├──────────────────────────────────────────────────────────────────────────────┤
|
||||
│ [영역 A] 전자결재선 관리 목록 [서식 등록] │
|
||||
│ ┌──────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 전자결재서식아이디 | 결재서식명 | 문서구분 | 등록일시 | 관리 │ │
|
||||
│ ├──────────────────────────────────────────────────────────────────────┤ │
|
||||
│ │ TMPL-0001 (선택 행 강조) … [수정][삭제] │ │
|
||||
│ │ TMPL-0002 … [수정][삭제] │ │
|
||||
│ └──────────────────────────────────────────────────────────────────────┘ │
|
||||
├──────────────────────────────────────────────────────────────────────────────┤
|
||||
│ [영역 B] 결재선 관리 선택된 서식: TMPL-0001 │
|
||||
│ ┌──────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ [영역 B-1] 결재자 입력 폼 │ │
|
||||
│ │ 결재선아이디(자동발급) | 결재구분 ▾ | 결재자명 [검토인력 선택] │ │
|
||||
│ │ 결재순서번호 [결재자 추가] [결재선 저장] [초기화] │ │
|
||||
│ ├──────────────────────────────────────────────────────────────────────┤ │
|
||||
│ │ [영역 B-2] 결재선 그리드 │ │
|
||||
│ │ 결재선아이디 | 결재순서번호 | 결재구분 | 결재자명 | 등록일시 | 관리 │ │
|
||||
│ │ APLN-0001 | 1 | 접수 | 김민수 | … |[수정][삭제] │
|
||||
│ │ APLN-0002 | 2 | 검토 | 박지훈 | … |[수정][삭제] │
|
||||
│ │ APLN-0003 | 3 | 최종승인 | 이서연 | … |[수정][삭제] │
|
||||
│ └──────────────────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
┌───────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 페이지 타이틀: 결재선 관리 브레드크럼: 디지털행정지원 > … > │
|
||||
├───────────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ [영역 A] 전자결재선 관리 목록 [서식 등록] │
|
||||
│ ┌──────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 전자결재서식아이디 | 결재서식명 | 문서구분 | 등록일시 | 관리 │ │
|
||||
│ ├──────────────────────────────────────────────────────────────────────┤ │
|
||||
│ │ TMPL-0001 (선택 행 강조) … [수정][삭제] │ │
|
||||
│ └──────────────────────────────────────────────────────────────────────┘ │
|
||||
├───────────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ [영역 B] 결재선 관리 선택된 서식: TMPL-0001 [결재선 저장] │
|
||||
│ ┌─────────────────────────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ [영역 B-2] 결재선 그리드 (인라인 편집 + 행 드래그 — @dnd-kit) │ │
|
||||
│ │ ⋮⋮ | 결재선아이디 | 결재순서 | 결재구분 ▼ | 결재자 [선택] | 등록일시 | 관리 │ │
|
||||
│ │ ⋮⋮ | APLN-0001 | [ 1 ] | [검토자 ▼] | [김민수 ][선택] | … | [저장][삭제] │ │
|
||||
│ │ ⋮⋮ | (신규) | [ 2 ] | [승인자 ▼] | [ ][선택] | — | [저장][취소] │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────────────────────────┘ │
|
||||
│ [행 추가] │
|
||||
│ ※ 결재자 등록·수정은 그리드 셀에서 직접 입력. 검토인력만 [선택] 팝업/다이얼로그 │
|
||||
│ ※ 행 추가·수정 중: 해당 행만 편집 가능, 나머지 행·[행 추가]·드래그 비활성 │
|
||||
└───────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3-2. 화면 데이터 흐름
|
||||
@@ -55,102 +52,116 @@
|
||||
BEGIN
|
||||
|
||||
// 최초 진입
|
||||
CALL TBD.결재서식목록조회()
|
||||
CALL GET /as-elapr-service/api/v1/elapr-tmplt
|
||||
DISPLAY 결재서식 그리드 (영역 A)
|
||||
DISPLAY 빈 상태 안내 (영역 B) // 서식 미선택 시 폼·그리드 비활성
|
||||
DISPLAY 빈 상태 안내 (영역 B) // 서식 미선택 시 그리드·툴바 비활성
|
||||
|
||||
// 영역 A: 서식 행 선택
|
||||
ON 영역 A 행 CLICK DO
|
||||
SET 선택된 서식 = 행.전자결재서식아이디 // 영역 B 상단 라벨에 표시
|
||||
CALL TBD.결재선목록조회(서식아이디)
|
||||
DISPLAY 결재선 그리드 (영역 B-2)
|
||||
RESET 결재자 입력 폼 (영역 B-1)
|
||||
SET 선택된 서식 = 행.elaprTmpltId
|
||||
CALL GET /as-elapr-service/api/v1/elapr-apln?elaprTmpltId={id}
|
||||
DISPLAY 결재선 그리드 (영역 B-2) — 조회 모드
|
||||
END ON
|
||||
|
||||
// 영역 A: 신규 서식 등록
|
||||
ON [서식 등록] 버튼 CLICK DO
|
||||
OPEN 결재서식 등록 다이얼로그
|
||||
ON 확인 DO
|
||||
CALL TBD.결재서식등록(서식정보)
|
||||
CALL TBD.결재서식목록조회() // → 영역 A 갱신
|
||||
CALL POST /as-elapr-service/api/v1/elapr-tmplt
|
||||
CALL GET /as-elapr-service/api/v1/elapr-tmplt
|
||||
END ON
|
||||
END ON
|
||||
|
||||
// 영역 A: 서식 수정·삭제
|
||||
ON 영역 A [수정] 버튼 CLICK DO
|
||||
OPEN 결재서식 수정 다이얼로그(선택 행)
|
||||
ON 확인 DO
|
||||
CALL TBD.결재서식수정(서식아이디, 서식정보)
|
||||
CALL TBD.결재서식목록조회()
|
||||
END ON
|
||||
// 영역 A: 서식 수정·삭제 (다이얼로그 유지)
|
||||
ON 영역 A [수정] / [삭제] DO
|
||||
(기존과 동일 — PUT / DELETE elapr-tmplt)
|
||||
END ON
|
||||
|
||||
ON 영역 A [삭제] 버튼 CLICK DO
|
||||
CONFIRM "선택한 결재서식을 삭제하시겠습니까?"
|
||||
CALL TBD.결재서식삭제(서식아이디)
|
||||
CALL TBD.결재서식목록조회()
|
||||
IF 삭제된 서식이 현재 선택된 서식 THEN
|
||||
RESET 영역 B (그리드 비우기, 폼 비활성)
|
||||
END IF
|
||||
END ON
|
||||
|
||||
// 영역 B-1: 결재자 입력 폼
|
||||
ON [검토인력 선택] 버튼 CLICK DO
|
||||
VALIDATE 서식 선택됨 // 미선택 시 안내 후 종료
|
||||
OPEN 검토인력 선택 다이얼로그
|
||||
ON 선택 DO
|
||||
SET 결재자명 = 선택인력.성명
|
||||
SET 결재자ID = 선택인력.사용자ID // 내부 상태로 보관
|
||||
END ON
|
||||
END ON
|
||||
|
||||
ON 결재구분 SELECT CHANGE DO
|
||||
SET 결재구분 = 선택값 // 접수 / 검토 / 최종승인
|
||||
END ON
|
||||
|
||||
ON 결재순서번호 INPUT CHANGE DO
|
||||
SET 결재순서번호 = 값(숫자)
|
||||
END ON
|
||||
|
||||
// 영역 B-1: 결재자 추가 (그리드에 행 추가, 미저장 상태)
|
||||
ON [결재자 추가] 버튼 CLICK DO
|
||||
// 영역 B: 행 추가 (인라인 신규 행)
|
||||
ON [행 추가] 버튼 CLICK DO
|
||||
VALIDATE 서식 선택됨
|
||||
· 결재구분 입력
|
||||
· 결재자 선택됨
|
||||
· 결재순서번호 > 0 (중복 시 안내)
|
||||
ADD 입력 폼 값을 영역 B-2 그리드에 임시 행으로 추가
|
||||
RESET 결재자 입력 폼 (영역 B-1)
|
||||
// 결재선아이디는 저장 시점에 서버에서 자동 발급되므로
|
||||
// 임시 행은 클라이언트 임시 키로 식별 (예: APLN-0007 같은 미발급 표기 또는 임시 ID)
|
||||
VALIDATE 편집 중인 행 없음 (editingRowId == null)
|
||||
ADD 영역 B-2 그리드 맨 아래 편집 가능 행
|
||||
SET editingRowId = 신규 행 임시 키
|
||||
SET 편집상태 = 신규
|
||||
SET aplnId = 클라이언트 임시 ID (저장 시 서버 등록용 ID로 전송)
|
||||
SET atrzSeqNo = 현재 행 수 + 1
|
||||
SET atrzSeComCd, aprvrUuid = 빈 값
|
||||
DISABLE 나머지 행 편집 · [행 추가] · 드래그 · [결재선 저장](정책에 따라)
|
||||
END ON
|
||||
|
||||
// 영역 B-1: 결재선 저장 (그리드 전체 일괄 저장)
|
||||
// 영역 B-2: 기존 행 수정 진입
|
||||
ON 행별 [수정] CLICK DO
|
||||
VALIDATE 편집 중인 행 없음
|
||||
SET editingRowId = 행.aplnId
|
||||
SET 해당 행만 편집 모드
|
||||
DISABLE 나머지 행 편집 · [행 추가] · 드래그
|
||||
END ON
|
||||
|
||||
// 영역 B-2: 인라인 편집
|
||||
ON 그리드 셀 변경 DO
|
||||
VALIDATE 변경 대상 행 == editingRowId
|
||||
SET 해당 행 dirty = true
|
||||
END ON
|
||||
|
||||
ON 셀 [선택] (검토인력) CLICK DO
|
||||
OPEN 검토인력 선택 다이얼로그 (단일 선택)
|
||||
ON 선택 DO
|
||||
SET 행.aprvrUuid = 선택인력.uuid
|
||||
SET 행.aprvrNm = 선택인력.성명 // UI 표시용 (응답 DTO에 없음 — 로컬 보조 필드)
|
||||
END ON
|
||||
END ON
|
||||
|
||||
// 영역 B-2: 행별 저장
|
||||
ON 행별 [저장] CLICK DO
|
||||
VALIDATE atrzSeComCd, aprvrUuid, atrzSeqNo
|
||||
IF 편집상태 = 신규 THEN
|
||||
CALL POST /as-elapr-service/api/v1/elapr-apln
|
||||
ELSE
|
||||
CALL PUT /as-elapr-service/api/v1/elapr-apln/{aplnId}
|
||||
END IF
|
||||
CALL GET /as-elapr-service/api/v1/elapr-apln?elaprTmpltId={id}
|
||||
CLEAR editingRowId
|
||||
ENABLE 전체 행 조회 모드 · [행 추가] · 드래그
|
||||
END ON
|
||||
|
||||
// 영역 B: 결재선 저장 (드래그 순서 일괄 반영)
|
||||
ON [결재선 저장] 버튼 CLICK DO
|
||||
VALIDATE 서식 선택됨 · 결재선 1건 이상 · 순서번호 중복 없음
|
||||
CALL TBD.결재선일괄저장(서식아이디, 결재선목록)
|
||||
CALL TBD.결재선목록조회(서식아이디) // → 영역 B-2 갱신
|
||||
VALIDATE 서식 선택됨
|
||||
VALIDATE 편집 중인 행 없음
|
||||
REORDER atrzSeqNo = 그리드 행 순서 (1..n)
|
||||
FOR EACH dirty 또는 순서 변경된 행 DO
|
||||
CALL PUT /as-elapr-service/api/v1/elapr-apln/{aplnId} // atrzSeqNo 등 반영
|
||||
END FOR
|
||||
FOR EACH 신규 미저장 행 DO
|
||||
CALL POST /as-elapr-service/api/v1/elapr-apln
|
||||
END FOR
|
||||
CALL GET /as-elapr-service/api/v1/elapr-apln?elaprTmpltId={id}
|
||||
TOAST "결재선이 저장되었습니다."
|
||||
END ON
|
||||
|
||||
// 영역 B-1: 초기화
|
||||
ON [초기화] 버튼 CLICK DO
|
||||
RESET 결재자 입력 폼 (영역 B-1) → 기본값
|
||||
(결재구분=접수, 결재자명="", 결재순서번호=1)
|
||||
// 영역 B-2: 행 드래그
|
||||
ON 영역 B-2 행 DRAG END DO
|
||||
VALIDATE 편집 중인 행 없음
|
||||
REORDER 그리드 행
|
||||
SET 각 행 atrzSeqNo = 행 인덱스 + 1 (UI 즉시 반영, 서버는 [결재선 저장] 또는 행별 [저장])
|
||||
END ON
|
||||
|
||||
// 영역 B-2: 그리드 행 수정·삭제
|
||||
ON 영역 B-2 [수정] 버튼 CLICK DO
|
||||
OPEN 결재자 수정 다이얼로그(선택 행)
|
||||
ON 확인 DO
|
||||
CALL TBD.결재선수정(결재선아이디, 결재선정보)
|
||||
CALL TBD.결재선목록조회(서식아이디)
|
||||
END ON
|
||||
// 영역 B-2: 행 삭제
|
||||
ON 행별 [삭제] CLICK DO
|
||||
CONFIRM "선택한 결재선을 삭제하시겠습니까?"
|
||||
IF 서버 저장된 행 THEN
|
||||
CALL DELETE /as-elapr-service/api/v1/elapr-apln/{aplnId}
|
||||
END IF
|
||||
REMOVE 그리드 행 (신규 미저장 행은 API 호출 없이 제거)
|
||||
CALL GET /as-elapr-service/api/v1/elapr-apln?elaprTmpltId={id}
|
||||
END ON
|
||||
|
||||
ON 영역 B-2 [삭제] 버튼 CLICK DO
|
||||
CONFIRM "선택한 결재자를 삭제하시겠습니까?"
|
||||
CALL TBD.결재선삭제(결재선아이디)
|
||||
CALL TBD.결재선목록조회(서식아이디)
|
||||
ON 행별 [취소] CLICK DO // 신규·수정 공통
|
||||
IF 편집상태 = 신규 THEN REMOVE 임시 행
|
||||
ELSE RESTORE 행 스냅샷 (수정 취소)
|
||||
CLEAR editingRowId
|
||||
ENABLE 전체 행 조회 모드 · [행 추가] · 드래그
|
||||
END ON
|
||||
|
||||
END
|
||||
@@ -171,135 +182,177 @@ END
|
||||
|
||||
### 4-2. 그리드 컬럼
|
||||
|
||||
| 컬럼명 | Field | 타입 | 비고 |
|
||||
| ------------------ | ----------------- | ------ | --------------------------------------------- |
|
||||
| 전자결재서식아이디 | `elctAtrzTmpltId` | string | 예) `TMPL-0001` (서버 자동 발급, TBD) |
|
||||
| 결재서식명 | `atrzTmpltNm` | string | 예) `설치인가 검토 결재` |
|
||||
| 문서구분 | `docSeNm` | string | 예) `설치인가`, `기본계획` (코드/명 매핑 TBD) |
|
||||
| 등록일시 | `regDt` | string | `YYYY-MM-DD HH:mm:ss` |
|
||||
| 관리 | — | — | 행별 **[수정] · [삭제]** 버튼 |
|
||||
| 컬럼명 | Field | 타입 | 비고 |
|
||||
| ------------------ | -------------- | ------ | ---------------------------------------- |
|
||||
| 전자결재서식아이디 | `elaprTmpltId` | string | 등록 시 클라이언트 또는 정책에 따라 발급 |
|
||||
| 결재서식명 | `elaprTmpltNm` | string | |
|
||||
| 문서구분 | `docSeComNm` | string | 코드 `docSeComCd`, 상위코드 `ASDOCSECD` |
|
||||
| 등록일시 | `regDt` | string | `yyyy-MM-dd HH:mm:ss` |
|
||||
| 관리 | — | — | 행별 **[수정] · [삭제]** 버튼 |
|
||||
|
||||
- 행 클릭 시: 해당 서식을 **선택 상태**로 만들고 영역 B 라벨(`선택된 서식: {전자결재서식아이디}`)과 영역 B-2 그리드를 갱신한다.
|
||||
- 페이지네이션: TBD (스크린샷에는 미표시).
|
||||
- 행 클릭 시: `elaprTmpltId` 선택 → 영역 B 라벨·B-2 그리드 갱신.
|
||||
- 페이지네이션: Spring `Pageable` (`page`, `size`, `sort`).
|
||||
|
||||
### 4-3. 버튼
|
||||
|
||||
| 버튼명 | 위치 | 동작 |
|
||||
| --------- | -------------- | ----------------------------------------------------------------- |
|
||||
| 서식 등록 | 섹션 우측 상단 | 결재서식 등록 다이얼로그 오픈 (입력 항목·필수값 TBD) |
|
||||
| 수정 | 행별 | 결재서식 수정 다이얼로그 오픈 (해당 행 값으로 초기화) |
|
||||
| 삭제 | 행별 | 확인 다이얼로그 후 삭제. 선택 중인 서식이면 영역 B 상태도 초기화. |
|
||||
| 버튼명 | 위치 | 동작 |
|
||||
| --------- | -------------- | ----------------------------------------------- |
|
||||
| 서식 등록 | 섹션 우측 상단 | 결재서식 등록 다이얼로그 오픈 |
|
||||
| 수정 | 행별 | 결재서식 수정 다이얼로그 (기존 값 초기화) |
|
||||
| 삭제 | 행별 | 확인 후 삭제. 선택 중인 서식이면 영역 B 초기화. |
|
||||
|
||||
### 4-4. 다이얼로그(개요)
|
||||
|
||||
| 다이얼로그명 | 트리거 | 입력 항목(예상) | 비고 |
|
||||
| ------------------ | ---------------- | ------------------------------ | ------------------------ |
|
||||
| 결재서식 등록 | [서식 등록] 클릭 | 결재서식명, 문서구분(드롭다운) | 등록 성공 시 영역 A 갱신 |
|
||||
| 결재서식 수정 | 행별 [수정] | 결재서식명, 문서구분 | 기존 값으로 초기화 |
|
||||
| 결재서식 삭제 확인 | 행별 [삭제] | (확인/취소) | |
|
||||
| 다이얼로그명 | 트리거 | 입력 항목 | 비고 |
|
||||
| ------------------ | ----------- | ----------------------------------------------- | ---------------------- |
|
||||
| 결재서식 등록 | [서식 등록] | `elaprTmpltId`, `elaprTmpltNm`, `docSeComCd` | `elaprTmpltExpln` 선택 |
|
||||
| 결재서식 수정 | 행별 [수정] | `elaprTmpltNm`, `docSeComCd`, `elaprTmpltExpln` | ID는 변경 불가 |
|
||||
| 결재서식 삭제 확인 | 행별 [삭제] | (확인/취소) | |
|
||||
|
||||
---
|
||||
|
||||
## 5. 영역 B: 결재선 관리
|
||||
|
||||
영역 A에서 선택된 결재서식을 기준으로 결재 단계(접수·검토·최종승인)와 결재자, 순서를 관리한다. 영역 B는 상단 **결재자 입력 폼(B-1)** 과 하단 **결재선 그리드(B-2)** 로 구성된다.
|
||||
영역 A에서 선택된 `elaprTmpltId` 기준으로 결재선을 관리한다. **등록·수정은 B-2 그리드 인라인 폼**으로 수행하며, 검토인력 지정만 **[선택]** 보조 다이얼로그를 사용한다.
|
||||
|
||||
### 5-1. 헤더 / 상태 표시
|
||||
### 5-1. 헤더 / 툴바
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| 섹션 타이틀 | 결재선 관리 |
|
||||
| 우측 라벨 | `선택된 서식: {전자결재서식아이디}` — 영역 A에서 선택한 서식 ID를 강조 색으로 표시. 미선택 시 영역 B 비활성. |
|
||||
| 항목 | 내용 |
|
||||
| ----------- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 섹션 타이틀 | 결재선 관리 |
|
||||
| 우측 라벨 | `선택된 서식: {elaprTmpltId}` — 미선택 시 영역 B 비활성 |
|
||||
| 우측 버튼 | **[행 추가]** — 편집 중인 행이 없을 때만 활성 · **[결재선 저장]** — 편집 중인 행이 없을 때만 활성(순서·미저장 행 일괄 반영) |
|
||||
|
||||
### 5-2. 영역 B-1: 결재자 입력 폼
|
||||
|
||||
#### 필드 정의
|
||||
|
||||
| 표시명 | 입력 유형 | 필수 | 기본값 | 데이터 소스 | API 파라미터 매핑 | 비고 |
|
||||
| ---------------------- | ----------------- | ---- | ----------- | -------------------- | ----------------- | --------------------------------------------------------------------- |
|
||||
| 결재선아이디(자동발급) | 텍스트(읽기 전용) | — | `APLN-XXXX` | 서버 자동 발급 (TBD) | TBD | 입력 불가. 저장 시 서버 발급. 표시값은 미발급 자리표시(또는 임시 ID). |
|
||||
| 결재구분 | 드롭다운 | 예 | `접수` | 코드 (TBD) | TBD | 옵션: **접수 / 검토 / 최종승인** (그리드 행 기준) |
|
||||
| 결재자명 | 텍스트(읽기 전용) | 예 | `""` | [검토인력 선택] 결과 | TBD | 자유 입력 불가. 다이얼로그 선택 결과로 채움. |
|
||||
| 결재순서번호 | 숫자 입력 | 예 | `1` | — | TBD | 1 이상의 정수. 동일 서식 내 중복 검증 필요. |
|
||||
|
||||
플레이스홀더 / 표시 텍스트:
|
||||
|
||||
- 결재자명 입력란 플레이스홀더: `검토인력을 선택하세요`
|
||||
- 결재선아이디 표시 예시: `APLN-0007` (다음 발급 예정 번호 노출 또는 빈 칸 — 표시 정책 TBD)
|
||||
|
||||
#### 버튼
|
||||
|
||||
| 버튼명 | 동작 |
|
||||
| ------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| 검토인력 선택 | **검토인력 선택 다이얼로그** 오픈. 선택 시 `결재자명`과 내부 `결재자 ID`를 폼 상태에 반영. |
|
||||
| 결재자 추가 | 입력 폼 값을 영역 B-2 그리드에 **임시 행**으로 추가하고 폼을 초기화한다. (서버 호출 없음, 저장 시 일괄 반영.) |
|
||||
| 결재선 저장 | 영역 B-2의 결재선 목록을 일괄 저장 API로 전송한다. 성공 시 그리드 재조회 + 토스트 표시. |
|
||||
| 초기화 | 입력 폼을 기본값으로 리셋(결재구분=접수, 결재자명=빈값, 결재순서번호=1). |
|
||||
|
||||
> 정책 메모: **[결재자 추가]** 와 **[결재선 저장]** 의 분리에 대해서는 두 가지 운영이 가능하다. 본 SPEC 초안은 그리드에 임시 행을 누적해 두고 [결재선 저장] 시점에 서버에 일괄 반영하는 모델을 가정한다. 실제 구현 방식(추가 즉시 저장 vs 일괄 저장)은 백엔드 API 확정 후 재정의(TBD).
|
||||
|
||||
#### 다이얼로그
|
||||
|
||||
| 다이얼로그명 | 트리거 | 표시 내용 / 입력 항목 | 결과 |
|
||||
| ---------------- | --------------- | ---------------------------------------------------------- | ----------------------------------- |
|
||||
| 검토인력 선택 | [검토인력 선택] | 사용자 검색 필터(이름/소속 등 TBD), 사용자 목록(단일 선택) | 선택된 사용자 정보를 입력 폼에 반영 |
|
||||
| 결재자 수정 | B-2 행별 [수정] | 결재구분, 결재자명(선택), 결재순서번호 | 확인 시 해당 행 갱신 |
|
||||
| 결재자 삭제 확인 | B-2 행별 [삭제] | (확인/취소) | 확인 시 해당 행 삭제 |
|
||||
|
||||
### 5-3. 영역 B-2: 결재선 그리드
|
||||
### 5-2. 영역 B-2: 결재선 그리드 (인라인 편집)
|
||||
|
||||
#### 그리드 컬럼
|
||||
|
||||
| 컬럼명 | Field | 타입 | 비고 |
|
||||
| ------------ | ------------ | ------ | ------------------------------------- |
|
||||
| 결재선아이디 | `atrzLineId` | string | 예) `APLN-0001` (서버 자동 발급, TBD) |
|
||||
| 결재순서번호 | `atrzSeqNo` | number | 1부터 시작. 단계 순서. |
|
||||
| 결재구분 | `atrzSeNm` | string | `접수` / `검토` / `최종승인` |
|
||||
| 결재자명 | `atrzUserNm` | string | 검토인력 선택 시 채워짐 |
|
||||
| 등록일시 | `regDt` | string | `YYYY-MM-DD HH:mm:ss` |
|
||||
| 관리 | — | — | 행별 **[수정] · [삭제]** 버튼 |
|
||||
| 컬럼명 | Field | 입력 UI (편집 모드) | 비고 |
|
||||
| ------------- | ------------- | ----------------------------------------------------------------------------------------- | ---------------------------------------------- |
|
||||
| (드래그 핸들) | — | — | `useSortable` 연동 |
|
||||
| 결재선아이디 | `aplnId` | 읽기 전용 | 신규 행: 임시 ID 표시, 저장 후 서버 ID |
|
||||
| 결재순서번호 | `atrzSeqNo` | 숫자 Input | 드래그 시 자동 재부여 가능 |
|
||||
| 결재구분 | `atrzSeComCd` | Select (`ATRZSECD` 공통코드) | 표시명 `atrzSeComNm` (조회 행) |
|
||||
| 결재자 | `aprvrUuid` | 읽기 전용 이름 + **[선택]** 버튼 | 응답에 성명 없음 — 선택 시 로컬 `aprvrNm` 유지 |
|
||||
| 등록일시 | `regDt` | 읽기 전용 | 신규 행은 `—` |
|
||||
| 관리 | — | 편집 중인 행: **[저장] [취소]** · 조회 행: **[수정] [삭제]** (다른 행 편집 중이면 비활성) |
|
||||
|
||||
- 표시 정렬: 기본 `결재순서번호` 오름차순.
|
||||
- 임시 행(서버 미저장)과 저장된 행의 시각적 구분 필요(예: ID 컬럼 표기 또는 행 스타일). 세부 UI는 TBD.
|
||||
- 페이지네이션: TBD.
|
||||
#### 인라인 편집 정책
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| **단일 행 편집** | **행 추가 또는 한 행 수정 중에는 다른 행을 편집할 수 없다.** `editingRowId` 로 현재 편집 행 1건만 허용. |
|
||||
| 편집 잠금 시 비활성 | 다른 행: 셀·[수정]·[삭제] 비활성 · `[행 추가]` 비활성 · 행 드래그 비활성 · (선택) `[결재선 저장]` 비활성 |
|
||||
| 편집 해제 | 행별 **[저장]** 성공 또는 **[취소]** 시 `editingRowId` 초기화 후 전체 조회 모드 복귀 |
|
||||
| 조회 행 | 편집 중이 아닐 때 읽기 전용. **[수정]** 클릭 시 해당 행만 편집 모드 진입 |
|
||||
| 신규 행 | `[행 추가]` 시 맨 아래 편집 행 1건 생성 (`editingRowId` = 임시 키) |
|
||||
| 필수값 | `atrzSeComCd`, `aprvrUuid`, `atrzSeqNo` |
|
||||
| 순서 중복 | 동일 서식 내 `atrzSeqNo` 중복 시 저장 전 검증·안내 |
|
||||
| 결재구분 코드 | `ATRZSECD` — 예: `ATRZ01` 검토자, `ATRZ02` 승인자, `ATRZ03` 최종승인자 |
|
||||
| 저장 단위 | 행별 **[저장]** (단건 POST/PUT) 또는 툴바 **[결재선 저장]** (일괄) |
|
||||
|
||||
#### 행 드래그 정렬 (`@dnd-kit` + `useSortable`)
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ---------- | ------------------------------------------------------------------------------------------- |
|
||||
| 목적 | 결재 단계 순서를 그리드에서 직접 변경 |
|
||||
| 라이브러리 | `@dnd-kit/core`, `@dnd-kit/sortable` — 테이블 행 POC 후 확정 |
|
||||
| 저장 시점 | 드래그는 클라이언트 상태만 변경 → **[결재선 저장]** 또는 행별 **[저장]** 시 `atrzSeqNo` PUT |
|
||||
| 편집 중 | `editingRowId != null` 이면 드래그 **불가** |
|
||||
|
||||
### 5-3. 보조 다이얼로그 (검토인력만)
|
||||
|
||||
| 다이얼로그명 | 트리거 | 결과 |
|
||||
| ------------- | ----------------------- | ---------------------------------- |
|
||||
| 검토인력 선택 | 그리드 결재자 셀 [선택] | `aprvrUuid` 및 UI용 `aprvrNm` 반영 |
|
||||
|
||||
> 결재자 **등록·수정 다이얼로그는 사용하지 않음** (v0.2 변경).
|
||||
|
||||
---
|
||||
|
||||
## 6. API/DTO 명세
|
||||
|
||||
> 본 화면은 현재 UI 위주 초안으로, API/DTO는 백엔드 명세 확정 후 채운다.
|
||||
|
||||
### 6-1. 공통
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ----------- | ------------------------------------------- |
|
||||
| Base prefix | TBD (예: `/server` 또는 결재 서비스 prefix) |
|
||||
| 인증 | JWT 토큰 기반 (Authorization 헤더) |
|
||||
| 클라이언트 | TBD (예: `@/entities/approval-line`) |
|
||||
| 항목 | 내용 |
|
||||
| ----------- | ------------------------------------------------------------- |
|
||||
| 서비스 | `as-elapr-service` |
|
||||
| Base prefix | `/as-elapr-service/api/v1` (프록시 경유) |
|
||||
| 인증 | JWT (`Authorization` 헤더) |
|
||||
| 페이징 | Spring `Pageable`: `page`, `size`, `sort` → `PageResponse<T>` |
|
||||
| 엔티티 | `@/entities/elapr` (`elaprTmpltService`, `elaprAplnService`) |
|
||||
|
||||
### 6-2. 전자결재서식 (영역 A)
|
||||
### 6-2. 전자결재서식 (영역 A) — `elapr-tmplt`
|
||||
|
||||
| 기능 | Method | URL | Request DTO | Response DTO | API(클라이언트 함수명) |
|
||||
| ------------------ | ------ | --- | ----------- | ------------ | ---------------------- |
|
||||
| 결재서식 목록 조회 | TBD | TBD | TBD | TBD | TBD |
|
||||
| 결재서식 등록 | TBD | TBD | TBD | TBD | TBD |
|
||||
| 결재서식 수정 | TBD | TBD | TBD | TBD | TBD |
|
||||
| 결재서식 삭제 | TBD | TBD | TBD | TBD | TBD |
|
||||
| 기능 | Method | URL | Request / Query | Response | 클라이언트 |
|
||||
| --------- | ------ | ----------------------------- | ------------------------------------------ | ------------------ | ---------------------------- |
|
||||
| 목록 조회 | GET | `/elapr-tmplt` | Query: `ElaprTmpltSearchParams` + Pageable | `Page<ElaprTmplt>` | `elaprTmpltService.findPage` |
|
||||
| 단건 조회 | GET | `/elapr-tmplt/{elaprTmpltId}` | — | `ElaprTmplt` | `elaprTmpltService.findById` |
|
||||
| 등록 | POST | `/elapr-tmplt` | `ElaprTmpltSaveRequest` | `ElaprTmplt` (201) | `elaprTmpltService.create` |
|
||||
| 수정 | PUT | `/elapr-tmplt/{elaprTmpltId}` | `ElaprTmpltUpdateRequest` | `ElaprTmplt` | `elaprTmpltService.update` |
|
||||
| 삭제 | DELETE | `/elapr-tmplt/{elaprTmpltId}` | — | 204 | `elaprTmpltService.remove` |
|
||||
|
||||
### 6-3. 결재선 (영역 B)
|
||||
**ElaprTmplt (응답)**
|
||||
|
||||
| 기능 | Method | URL | Request DTO | Response DTO | API(클라이언트 함수명) |
|
||||
| ------------------------ | ------ | --- | ------------------------------------ | ------------ | ---------------------- |
|
||||
| 결재선 목록 조회(서식별) | TBD | TBD | `{ elctAtrzTmpltId }` (TBD) | TBD | TBD |
|
||||
| 결재선 추가/일괄 저장 | TBD | TBD | `{ elctAtrzTmpltId, lines[] }` (TBD) | TBD | TBD |
|
||||
| 결재선 수정 | TBD | TBD | TBD | TBD | TBD |
|
||||
| 결재선 삭제 | TBD | TBD | TBD | TBD | TBD |
|
||||
| Field | 타입 | 설명 |
|
||||
| ----------------- | ------- | ------------------ |
|
||||
| `elaprTmpltId` | string | 전자결재서식아이디 |
|
||||
| `elaprTmpltNm` | string | 결재서식명 |
|
||||
| `elaprTmpltExpln` | string? | 서식 설명 |
|
||||
| `docSeComCd` | string? | 문서구분 코드 |
|
||||
| `docSeComNm` | string? | 문서구분명 |
|
||||
| `regDt` | string? | 등록일시 |
|
||||
|
||||
### 6-4. 검토인력 (다이얼로그)
|
||||
**ElaprTmpltSaveRequest**: `elaprTmpltId`_, `elaprTmpltNm`_, `elaprTmpltExpln?`, `docSeComCd?`
|
||||
|
||||
| 기능 | Method | URL | Request DTO | Response DTO | API(클라이언트 함수명) |
|
||||
| ------------------ | ------ | --- | --------------- | ------------ | ---------------------- |
|
||||
| 검토인력 목록 조회 | TBD | TBD | TBD (검색 필터) | TBD | TBD |
|
||||
**ElaprTmpltUpdateRequest**: `elaprTmpltNm`\*, `elaprTmpltExpln?`, `docSeComCd?`
|
||||
|
||||
**ElaprTmpltSearchParams**: `elaprTmpltNm?`, `docSeComCd?`, `regDtStart?`, `regDtEnd?` + `page`, `size`, `sort`
|
||||
|
||||
### 6-3. 결재선 (영역 B) — `elapr-apln`
|
||||
|
||||
| 기능 | Method | URL | Request / Query | Response | 클라이언트 |
|
||||
| --------- | ------ | ---------------------- | ------------------------------- | ----------------- | --------------------------- |
|
||||
| 목록 조회 | GET | `/elapr-apln` | Query: `elaprTmpltId`, Pageable | `Page<ElaprApln>` | `elaprAplnService.findPage` |
|
||||
| 단건 조회 | GET | `/elapr-apln/{aplnId}` | — | `ElaprApln` | `elaprAplnService.findById` |
|
||||
| 등록 | POST | `/elapr-apln` | `ElaprAplnSaveRequest` | `ElaprApln` (201) | `elaprAplnService.create` |
|
||||
| 수정 | PUT | `/elapr-apln/{aplnId}` | `ElaprAplnUpdateRequest` | `ElaprApln` | `elaprAplnService.update` |
|
||||
| 삭제 | DELETE | `/elapr-apln/{aplnId}` | — | 204 | `elaprAplnService.remove` |
|
||||
|
||||
> **일괄 저장 API 없음** — UI의 [결재선 저장]은 변경된 행에 대해 POST/PUT을 순차(또는 병렬) 호출하는 클라이언트 오케스트레이션으로 구현.
|
||||
|
||||
**ElaprApln (응답)**
|
||||
|
||||
| Field | 타입 | 설명 |
|
||||
| -------------- | ------- | ------------------ |
|
||||
| `aplnId` | string | 결재선아이디 |
|
||||
| `elaprTmpltId` | string | 전자결재서식아이디 |
|
||||
| `aprvrUuid` | string? | 결재자 UUID |
|
||||
| `atrzSeComCd` | string? | 결재구분 코드 |
|
||||
| `atrzSeComNm` | string? | 결재구분명 |
|
||||
| `atrzSeqNo` | number? | 결재순서번호 |
|
||||
| `regDt` | string? | 등록일시 |
|
||||
|
||||
**ElaprAplnSaveRequest**: `aplnId`_, `elaprTmpltId`_, `aprvrUuid?`, `atrzSeComCd?`, `atrzSeqNo?`
|
||||
|
||||
**ElaprAplnUpdateRequest**: `elaprTmpltId`\*, `aprvrUuid?`, `atrzSeComCd?`, `atrzSeqNo?`
|
||||
|
||||
### 6-4. 검토인력 (보조)
|
||||
|
||||
| 기능 | Method | URL | 비고 |
|
||||
| ------------------ | ------ | --- | --------------------------- |
|
||||
| 검토인력 목록 조회 | TBD | TBD | 사용자 서비스 또는 별도 API |
|
||||
|
||||
### 6-5. Backend ↔ 화면 필드 매핑 (구 SPEC 대비)
|
||||
|
||||
| 구 SPEC 필드 | 백엔드 필드 |
|
||||
| ----------------- | ---------------------------------------------------- |
|
||||
| `elctAtrzTmpltId` | `elaprTmpltId` |
|
||||
| `atrzTmpltNm` | `elaprTmpltNm` |
|
||||
| `docSeNm` | `docSeComNm` |
|
||||
| `atrzLineId` | `aplnId` |
|
||||
| `atrzSeNm` | `atrzSeComNm` |
|
||||
| `atrzUserNm` | (없음) `aprvrUuid` + 사용자 조회/선택 시 로컬 표시명 |
|
||||
|
||||
---
|
||||
|
||||
@@ -307,52 +360,46 @@ END
|
||||
|
||||
### 7-1. 기술 스택·원칙
|
||||
|
||||
- **공통 컴포넌트**: `@common/components` (버튼, 셀렉트, 인풋, 카드 등 기존 패턴 재사용).
|
||||
- **테이블**: `@tanstack/react-table`, 프로젝트 표준 `useDataTable` 패턴이 있다면 동일하게 적용.
|
||||
- **데이터**: React Query 기반 조회/뮤테이션. API 확정 후 `queryKey` 와 무효화 정책 정리.
|
||||
- **상태**: 영역 A의 **선택된 서식 ID**, 영역 B-1의 **결재자 입력 폼 값**, 영역 B-2의 **임시 결재선 배열**은 페이지 로컬 상태로 관리. 다이얼로그 상태(검토인력 선택 등)는 컴포넌트 로컬 상태 또는 가벼운 상태 관리 훅으로 분리.
|
||||
- **모의 데이터**: 백엔드 명세 확정 전까지 `@faker-js/faker` 로 그리드 목데이터 구성(스크린샷 컬럼/값과 동일 형태).
|
||||
- **공통 컴포넌트**: `@common/components`
|
||||
- **테이블**: `@tanstack/react-table` + 인라인 편집 셀 (`Select`, `Input`, `Button`)
|
||||
- **드래그**: `@dnd-kit` — B-2 행 정렬 (POC 필수)
|
||||
- **데이터**: React Query — `@/entities/elapr` hooks
|
||||
- **상태**: 선택된 `elaprTmpltId`, `editingRowId` (단일 행 편집 잠금), B-2 행 배열(dirty·신규·`aprvrNm`·수정 전 스냅샷)
|
||||
- **모의 데이터**: API 연동 전 `@faker-js/faker`
|
||||
|
||||
### 7-2. 모듈·파일 배치(제안)
|
||||
|
||||
| 구분 | 경로 |
|
||||
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 라우트(App Router) | `apps/as/app/(with-base-layout)/approval-line-management/page.tsx` |
|
||||
| 페이지 컴포넌트 | `apps/as/src/pages/approval-line-management/index.ts`<br/>`.../ui/approval-line-management-page.tsx` |
|
||||
| 위젯(영역 단위) | `apps/as/src/widgets/approval-template-list/`<br/>`apps/as/src/widgets/approval-line-editor/` |
|
||||
| 피처(상호작용) | `apps/as/src/features/approver-picker/` (검토인력 선택 다이얼로그) |
|
||||
| 엔티티(도메인) | `apps/as/src/entities/approval-template/`<br/>`apps/as/src/entities/approval-line/` |
|
||||
| 그리드 컬럼 정의 | `entities/approval-template/model/approval-template-table-columns.tsx`<br/>`entities/approval-line/model/approval-line-table-columns.tsx` |
|
||||
| API 클라이언트 | `entities/approval-template/api/*`, `entities/approval-line/api/*` (TBD) |
|
||||
| 구분 | 경로 |
|
||||
| ------ | ------------------------------------------------------------------ |
|
||||
| 라우트 | `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/` |
|
||||
|
||||
### 7-3. 영역별 UI 매핑
|
||||
|
||||
| 영역 | 주요 UI | 비고 |
|
||||
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
|
||||
| A | 섹션 헤더 + Button(`서식 등록`) + DataTable (행 선택, 행별 Action 버튼) | 선택 행 강조, 행 클릭으로 영역 B 갱신 |
|
||||
| B 헤더 | 상태 라벨 `선택된 서식: {ID}` | 미선택 시 영역 B-1·B-2 비활성 |
|
||||
| B-1 | 읽기 전용 Input(아이디), Select(결재구분), Input+Button(결재자/검토인력 선택), NumberInput(순서번호), Action 버튼 3종(`결재자 추가`/`결재선 저장`/`초기화`) | 폼 유효성 검증 후 그리드에 임시 행 추가 |
|
||||
| B-2 | DataTable (행별 [수정][삭제]), 임시 행 시각적 구분 | 기본 정렬: 결재순서번호 ASC |
|
||||
| 공통 | Dialog(검토인력 선택, 서식 등록/수정, 결재자 수정, 삭제 확인) | 디자인 시스템의 `Dialog` 컴포넌트 사용 |
|
||||
| 영역 | 주요 UI |
|
||||
| ------ | -------------------------------------------------------------------------------------------- |
|
||||
| A | DataTable + 행 선택 + 서식 등록/수정 다이얼로그 |
|
||||
| B 헤더 | 상태 라벨 + `행 추가`, `결재선 저장` |
|
||||
| B-2 | 인라인 편집 DataTable + `editingRowId` 단일 행 잠금 + 드래그 핸들 + 행별 수정/저장/삭제/취소 |
|
||||
| 공통 | 서식 Dialog, 검토인력 선택 Dialog만 |
|
||||
|
||||
### 7-4. 데이터 흐름(요약)
|
||||
|
||||
- **진입 시**: 결재서식 목록 조회(TBD) → 영역 A 그리드 렌더. 영역 B는 안내/비활성 상태.
|
||||
- **서식 선택 시**: 선택된 `elctAtrzTmpltId` 를 페이지 상태로 보관 → 결재선 목록 조회(TBD) → 영역 B-2 그리드 갱신. 영역 B-1 폼 초기화.
|
||||
- **결재자 추가**: 입력 폼 값을 영역 B-2의 **클라이언트 임시 배열**에 push. 결재선아이디는 미발급 상태로 표시(또는 임시 키). 폼 리셋.
|
||||
- **결재선 저장**: 임시 배열 + 기존 그리드 행을 합쳐 일괄 저장 API(TBD) 호출 → 성공 시 그리드 재조회 + 토스트.
|
||||
- **수정/삭제**: 각각의 단건 API(TBD) 호출 후 그리드 재조회.
|
||||
- **검토인력 선택 다이얼로그**: 사용자 목록 조회(TBD) → 단일 선택 → 폼의 결재자명/결재자 ID 갱신.
|
||||
- **서식 등록/수정/삭제**: 다이얼로그 또는 확인 후 단건 API(TBD) 호출 → 영역 A 그리드 재조회. 삭제 대상이 현재 선택된 서식이면 영역 B 초기화.
|
||||
- **진입**: `useElaprTmpltPage` → 영역 A. B 비활성.
|
||||
- **서식 선택**: `useElaprAplnPage({ elaprTmpltId })` → B-2.
|
||||
- **행 추가**: `editingRowId` 없을 때만 가능 → 신규 행 1건 편집 잠금 → 저장/취소 후 해제.
|
||||
- **인라인 수정**: [수정]으로 `editingRowId` 설정 → 해당 행만 편집 → `useUpdateElaprApln` 후 해제.
|
||||
- **드래그**: `editingRowId == null` 일 때만 가능 → 로컬 `atrzSeqNo` 재부여 → 일괄 PUT.
|
||||
- **삭제**: `useDeleteElaprApln` 또는 신규 행 로컬 제거.
|
||||
|
||||
### 7-5. 미확정 / 검증 필요 항목 (TBD)
|
||||
### 7-5. 미확정 / 검증 필요
|
||||
|
||||
- 모든 백엔드 API 경로·DTO·필드명.
|
||||
- `결재선아이디(자동발급)` 입력 박스의 표시 정책: (a) 다음 발급 예정 번호 미리 보여주기, (b) 저장 전에는 빈 칸 또는 자리표시자만, (c) 임시 ID.
|
||||
- `[결재자 추가]` 의 동작: 그리드에 임시 누적(현재 가정) vs 즉시 단건 저장.
|
||||
- 결재구분 코드 값(`접수`/`검토`/`최종승인`)의 코드 매핑.
|
||||
- 문서구분 코드 값(`설치인가`/`기본계획` 등)의 코드 매핑.
|
||||
- 영역 A·B 그리드의 페이지네이션·정렬·검색 필터 유무.
|
||||
- 검토인력 선택 다이얼로그의 검색 필드(부서/직급/이름 등) 및 다중 선택 허용 여부.
|
||||
- 결재선 저장 시 순서번호 중복·누락(예: 1, 2, 4) 허용 정책.
|
||||
- 권한(역할별 등록/수정/삭제 가능 여부).
|
||||
- 검토인력 API 경로·DTO.
|
||||
- `aplnId` / `elaprTmpltId` 발급 정책(클라이언트 생성 규칙).
|
||||
- `@dnd-kit` + react-table 행 인라인 Input 동시 적용 POC.
|
||||
- 결재자 성명 표시: `aprvrUuid` → 사용자명 조회 API.
|
||||
- 영역 A·B 페이지네이션·검색 UI 노출 여부.
|
||||
- 권한(역할별 CRUD).
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
"@common/assets": "workspace:*",
|
||||
"@common/components": "workspace:*",
|
||||
"@common/network": "workspace:*",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@faker-js/faker": "^9.9.0",
|
||||
"@hookform/resolvers": "^4.1.3",
|
||||
"@tanstack/react-query": "^5.75.5",
|
||||
"@tanstack/react-query-devtools": "^5.75.5",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { client } from "@/shared/api/client";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type { Code, CodeService } from "../model/code-interface";
|
||||
|
||||
async function getList(upCd: string): Promise<Code[]> {
|
||||
const response = await client.get<Code[]>(`${ENDPOINT.CMS_SERVICE.CODE}/${upCd}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function getListByUseYn(upCd: string, useYn: string): Promise<Code[]> {
|
||||
const response = await client.get<Code[]>(
|
||||
`${ENDPOINT.CMS_SERVICE.CODE}/${upCd}/${useYn}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function getCodeName(upCd: string, lwpoCd: string): Promise<string> {
|
||||
const response = await client.get<string>(
|
||||
`${ENDPOINT.CMS_SERVICE.CODE}/${upCd}/lwpoCd/${lwpoCd}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const codeService = {
|
||||
getList,
|
||||
getListByUseYn,
|
||||
getCodeName,
|
||||
} satisfies CodeService;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import { codeService } from "./code-service";
|
||||
|
||||
const codeBaseKey = [ENDPOINT.CMS_SERVICE.CODE] as const;
|
||||
|
||||
const CODE_STALE_TIME = 1000 * 60 * 10;
|
||||
|
||||
export function useCodeList(upCd: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [...codeBaseKey, upCd],
|
||||
queryFn: () => codeService.getList(upCd!),
|
||||
enabled: Boolean(upCd),
|
||||
staleTime: CODE_STALE_TIME,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCodeListByUseYn(upCd: string | undefined, useYn: string) {
|
||||
return useQuery({
|
||||
queryKey: [...codeBaseKey, upCd, useYn],
|
||||
queryFn: () => codeService.getListByUseYn(upCd!, useYn),
|
||||
enabled: Boolean(upCd && useYn),
|
||||
staleTime: CODE_STALE_TIME,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCodeName(upCd: string | undefined, lwpoCd: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [...codeBaseKey, upCd, "name", lwpoCd],
|
||||
queryFn: () => codeService.getCodeName(upCd!, lwpoCd!),
|
||||
enabled: Boolean(upCd && lwpoCd),
|
||||
staleTime: CODE_STALE_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { codeService } from "./api/code-service";
|
||||
export { useCodeList, useCodeListByUseYn, useCodeName } from "./api/queries";
|
||||
export { findCodeLabel } from "./lib/find-code-label";
|
||||
export { UP_CD } from "./lib/up-cd-constants";
|
||||
export { CodeSelect } from "./ui/code-select";
|
||||
export type { Code, CodeService } from "./model/code-interface";
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { Code } from "../model/code-interface";
|
||||
|
||||
export function findCodeLabel(codes: Code[] | undefined, lwpoCd?: string): string {
|
||||
if (!lwpoCd) return "";
|
||||
if (!codes?.length) return lwpoCd;
|
||||
return codes.find((c) => c.lwpoCd === lwpoCd)?.lwpoCdNm ?? lwpoCd;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** 결재선 관리 SPEC 상위코드 */
|
||||
export const UP_CD = {
|
||||
DOC_SE: "ASDOCSECD",
|
||||
ATRZ_SE: "ATRZSECD",
|
||||
} as const;
|
||||
@@ -0,0 +1,14 @@
|
||||
/** cms-service CodeResponseDto */
|
||||
export interface Code {
|
||||
lwpoCd: string;
|
||||
upCd: string;
|
||||
lwpoCdNm: string;
|
||||
unit?: string;
|
||||
prntLwpoCd?: string;
|
||||
}
|
||||
|
||||
export interface CodeService {
|
||||
getList(upCd: string): Promise<Code[]>;
|
||||
getListByUseYn(upCd: string, useYn: string): Promise<Code[]>;
|
||||
getCodeName(upCd: string, lwpoCd: string): Promise<string>;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
cn,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@common/components";
|
||||
|
||||
import { useCodeList, useCodeListByUseYn } from "../api/queries";
|
||||
|
||||
interface CodeSelectProps extends React.ComponentPropsWithoutRef<typeof Select> {
|
||||
upCd: string;
|
||||
useYn?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function CodeSelect({
|
||||
upCd,
|
||||
useYn,
|
||||
placeholder = "선택",
|
||||
className,
|
||||
...props
|
||||
}: CodeSelectProps) {
|
||||
const listQuery = useCodeList(useYn ? undefined : upCd);
|
||||
const listByUseYnQuery = useCodeListByUseYn(useYn ? upCd : undefined, useYn ?? "");
|
||||
|
||||
const codes = useYn ? listByUseYnQuery.data : listQuery.data;
|
||||
const isLoading = useYn ? listByUseYnQuery.isPending : listQuery.isPending;
|
||||
|
||||
return (
|
||||
<Select {...props} disabled={props.disabled || isLoading}>
|
||||
<SelectTrigger className={cn("w-[12rem]", className)}>
|
||||
<SelectValue placeholder={isLoading ? "로딩 중…" : placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{codes?.map((item) => (
|
||||
<SelectItem key={item.lwpoCd} value={item.lwpoCd}>
|
||||
{item.lwpoCdNm}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export { CodeSelect };
|
||||
@@ -0,0 +1,51 @@
|
||||
import { client } from "@/shared/api/client";
|
||||
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;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { client } from "@/shared/api/client";
|
||||
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;
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type {
|
||||
ElaprAplnListParams,
|
||||
ElaprAplnSaveRequest,
|
||||
ElaprAplnUpdateRequest,
|
||||
} from "../model/elapr-apln-interface";
|
||||
import type {
|
||||
ElaprTmpltSaveRequest,
|
||||
ElaprTmpltSearchParams,
|
||||
ElaprTmpltUpdateRequest,
|
||||
} from "../model/elapr-tmplt-interface";
|
||||
|
||||
import { elaprAplnService } from "./elapr-apln-service";
|
||||
import { elaprTmpltService } from "./elapr-tmplt-service";
|
||||
|
||||
const elaprTmpltBaseKey = [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_TMPLT] as const;
|
||||
const elaprAplnBaseKey = [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN] as const;
|
||||
|
||||
export function useElaprTmpltPage(params: ElaprTmpltSearchParams) {
|
||||
return useQuery({
|
||||
queryKey: [...elaprTmpltBaseKey, "page", params],
|
||||
queryFn: () => elaprTmpltService.findPage(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useElaprTmplt(elaprTmpltId: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [...elaprTmpltBaseKey, elaprTmpltId],
|
||||
queryFn: () => elaprTmpltService.findById(elaprTmpltId!),
|
||||
enabled: Boolean(elaprTmpltId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateElaprTmplt() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (body: ElaprTmpltSaveRequest) => elaprTmpltService.create(body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: elaprTmpltBaseKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateElaprTmplt() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
elaprTmpltId,
|
||||
body,
|
||||
}: {
|
||||
elaprTmpltId: string;
|
||||
body: ElaprTmpltUpdateRequest;
|
||||
}) => elaprTmpltService.update(elaprTmpltId, body),
|
||||
onSuccess: (_data, { elaprTmpltId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: elaprTmpltBaseKey });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...elaprTmpltBaseKey, elaprTmpltId],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteElaprTmplt() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (elaprTmpltId: string) => elaprTmpltService.remove(elaprTmpltId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: elaprTmpltBaseKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useElaprAplnPage(params: ElaprAplnListParams) {
|
||||
const { elaprTmpltId } = params;
|
||||
|
||||
return useQuery({
|
||||
queryKey: [...elaprAplnBaseKey, "page", params],
|
||||
queryFn: () => elaprAplnService.findPage(params),
|
||||
enabled: Boolean(elaprTmpltId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useElaprApln(aplnId: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [...elaprAplnBaseKey, aplnId],
|
||||
queryFn: () => elaprAplnService.findById(aplnId!),
|
||||
enabled: Boolean(aplnId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateElaprApln() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (body: ElaprAplnSaveRequest) => elaprAplnService.create(body),
|
||||
onSuccess: (_data, { elaprTmpltId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: elaprAplnBaseKey });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...elaprAplnBaseKey, "page", { elaprTmpltId }],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateElaprApln() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
aplnId,
|
||||
body,
|
||||
}: {
|
||||
aplnId: string;
|
||||
body: ElaprAplnUpdateRequest;
|
||||
}) => elaprAplnService.update(aplnId, body),
|
||||
onSuccess: (_data, { body, aplnId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: elaprAplnBaseKey });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...elaprAplnBaseKey, aplnId],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...elaprAplnBaseKey, "page", { elaprTmpltId: body.elaprTmpltId }],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteElaprApln() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
aplnId,
|
||||
}: {
|
||||
aplnId: string;
|
||||
elaprTmpltId: string;
|
||||
}) => elaprAplnService.remove(aplnId),
|
||||
onSuccess: (_data, { elaprTmpltId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: elaprAplnBaseKey });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...elaprAplnBaseKey, "page", { elaprTmpltId }],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export { elaprAplnService } from "./api/elapr-apln-service";
|
||||
export { elaprTmpltService } from "./api/elapr-tmplt-service";
|
||||
export {
|
||||
useCreateElaprApln,
|
||||
useCreateElaprTmplt,
|
||||
useDeleteElaprApln,
|
||||
useDeleteElaprTmplt,
|
||||
useElaprApln,
|
||||
useElaprAplnPage,
|
||||
useElaprTmplt,
|
||||
useElaprTmpltPage,
|
||||
useUpdateElaprApln,
|
||||
useUpdateElaprTmplt,
|
||||
} from "./api/queries";
|
||||
export type {
|
||||
ElaprApln,
|
||||
ElaprAplnListParams,
|
||||
ElaprAplnSaveRequest,
|
||||
ElaprAplnService,
|
||||
ElaprAplnUpdateRequest,
|
||||
} from "./model/elapr-apln-interface";
|
||||
export type {
|
||||
ElaprTmplt,
|
||||
ElaprTmpltSaveRequest,
|
||||
ElaprTmpltSearchParams,
|
||||
ElaprTmpltService,
|
||||
ElaprTmpltUpdateRequest,
|
||||
} from "./model/elapr-tmplt-interface";
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { PageableParams, PageResponse } from "@/shared/model";
|
||||
|
||||
export interface ElaprApln {
|
||||
aplnId: string;
|
||||
elaprTmpltId: string;
|
||||
aprvrUuid?: string;
|
||||
atrzSeComCd?: string;
|
||||
atrzSeComNm?: string;
|
||||
atrzSeqNo?: number;
|
||||
regDt?: string;
|
||||
rgtrUuid?: string;
|
||||
mdfcnDt?: string;
|
||||
mdfrUuid?: string;
|
||||
}
|
||||
|
||||
export interface ElaprAplnListParams extends PageableParams {
|
||||
elaprTmpltId?: string;
|
||||
}
|
||||
|
||||
export interface ElaprAplnSaveRequest {
|
||||
aplnId: string;
|
||||
elaprTmpltId: string;
|
||||
aprvrUuid?: string;
|
||||
atrzSeComCd?: string;
|
||||
atrzSeqNo?: number;
|
||||
}
|
||||
|
||||
export interface ElaprAplnUpdateRequest {
|
||||
elaprTmpltId: string;
|
||||
aprvrUuid?: string;
|
||||
atrzSeComCd?: string;
|
||||
atrzSeqNo?: number;
|
||||
}
|
||||
|
||||
export interface ElaprAplnService {
|
||||
findPage(params: ElaprAplnListParams): Promise<PageResponse<ElaprApln>>;
|
||||
findById(aplnId: string): Promise<ElaprApln>;
|
||||
create(body: ElaprAplnSaveRequest): Promise<ElaprApln>;
|
||||
update(aplnId: string, body: ElaprAplnUpdateRequest): Promise<ElaprApln>;
|
||||
remove(aplnId: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { PageableParams, PageResponse } from "@/shared/model";
|
||||
|
||||
export interface ElaprTmplt {
|
||||
elaprTmpltId: string;
|
||||
elaprTmpltNm: string;
|
||||
elaprTmpltExpln?: string;
|
||||
docSeComCd?: string;
|
||||
docSeComNm?: string;
|
||||
regDt?: string;
|
||||
rgtrUuid?: string;
|
||||
mdfcnDt?: string;
|
||||
mdfrUuid?: string;
|
||||
}
|
||||
|
||||
export interface ElaprTmpltSearchParams extends PageableParams {
|
||||
elaprTmpltNm?: string;
|
||||
docSeComCd?: string;
|
||||
regDtStart?: string;
|
||||
regDtEnd?: string;
|
||||
}
|
||||
|
||||
export interface ElaprTmpltSaveRequest {
|
||||
elaprTmpltId: string;
|
||||
elaprTmpltNm: string;
|
||||
elaprTmpltExpln?: string;
|
||||
docSeComCd?: string;
|
||||
}
|
||||
|
||||
export interface ElaprTmpltUpdateRequest {
|
||||
elaprTmpltNm: string;
|
||||
elaprTmpltExpln?: string;
|
||||
docSeComCd?: string;
|
||||
}
|
||||
|
||||
export interface ElaprTmpltService {
|
||||
findPage(params: ElaprTmpltSearchParams): Promise<PageResponse<ElaprTmplt>>;
|
||||
findById(elaprTmpltId: string): Promise<ElaprTmplt>;
|
||||
create(body: ElaprTmpltSaveRequest): Promise<ElaprTmplt>;
|
||||
update(elaprTmpltId: string, body: ElaprTmpltUpdateRequest): Promise<ElaprTmplt>;
|
||||
remove(elaprTmpltId: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { AprvrPickerDialog } from "./ui/aprvr-picker-dialog";
|
||||
export type { ReviewerOption } from "./model/mock-reviewers";
|
||||
@@ -0,0 +1,17 @@
|
||||
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,99 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
DataTable,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
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";
|
||||
|
||||
interface AprvrPickerDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelect: (reviewer: ReviewerOption) => void;
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<ReviewerOption>();
|
||||
|
||||
function AprvrPickerDialog({ open, onOpenChange, onSelect }: AprvrPickerDialogProps) {
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const reviewers = useMemo(() => createMockReviewers(), []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = keyword.trim().toLowerCase();
|
||||
if (!q) return reviewers;
|
||||
return reviewers.filter(
|
||||
(r) =>
|
||||
r.name.toLowerCase().includes(q) || r.deptNm.toLowerCase().includes(q),
|
||||
);
|
||||
}, [keyword, reviewers]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
columnHelper.accessor("name", { header: "성명" }),
|
||||
columnHelper.accessor("deptNm", { header: "부서" }),
|
||||
columnHelper.display({
|
||||
id: "actions",
|
||||
header: "선택",
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onSelect(row.original);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
선택
|
||||
</Button>
|
||||
),
|
||||
}),
|
||||
],
|
||||
[onOpenChange, onSelect],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: filtered,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<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>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
닫기
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { AprvrPickerDialog };
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ElaprTmpltFormDialog } from "./ui/elapr-tmplt-form-dialog";
|
||||
export type { ElaprTmpltFormValues } from "./ui/elapr-tmplt-form-dialog";
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Input,
|
||||
Label,
|
||||
Textarea,
|
||||
} from "@common/components";
|
||||
|
||||
import { CodeSelect, UP_CD } from "@/entities/code";
|
||||
import type { ElaprTmplt } from "@/entities/elapr";
|
||||
import { generateClientId } from "@/shared/lib";
|
||||
|
||||
export interface ElaprTmpltFormValues {
|
||||
elaprTmpltId: string;
|
||||
elaprTmpltNm: string;
|
||||
docSeComCd: string;
|
||||
elaprTmpltExpln: string;
|
||||
}
|
||||
|
||||
interface ElaprTmpltFormDialogProps {
|
||||
mode: "create" | "edit";
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
initial?: ElaprTmplt | null;
|
||||
isSubmitting?: boolean;
|
||||
onSubmit: (values: ElaprTmpltFormValues) => void;
|
||||
}
|
||||
|
||||
function ElaprTmpltFormDialog({
|
||||
mode,
|
||||
open,
|
||||
onOpenChange,
|
||||
initial,
|
||||
isSubmitting,
|
||||
onSubmit,
|
||||
}: ElaprTmpltFormDialogProps) {
|
||||
const { register, handleSubmit, reset, setValue, watch } = useForm<ElaprTmpltFormValues>({
|
||||
defaultValues: {
|
||||
elaprTmpltId: "",
|
||||
elaprTmpltNm: "",
|
||||
docSeComCd: "",
|
||||
elaprTmpltExpln: "",
|
||||
},
|
||||
});
|
||||
|
||||
const docSeComCd = watch("docSeComCd");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
if (mode === "edit" && initial) {
|
||||
reset({
|
||||
elaprTmpltId: initial.elaprTmpltId,
|
||||
elaprTmpltNm: initial.elaprTmpltNm,
|
||||
docSeComCd: initial.docSeComCd ?? "",
|
||||
elaprTmpltExpln: initial.elaprTmpltExpln ?? "",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
reset({
|
||||
// TBD: elaprTmpltId 발급 정책 — 현재 클라이언트 임시 ID
|
||||
elaprTmpltId: generateClientId("TMPL"),
|
||||
elaprTmpltNm: "",
|
||||
docSeComCd: "",
|
||||
elaprTmpltExpln: "",
|
||||
});
|
||||
}, [open, mode, initial, reset]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{mode === "create" ? "결재서식 등록" : "결재서식 수정"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="space-y-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="elaprTmpltId">전자결재서식아이디</Label>
|
||||
<Input
|
||||
id="elaprTmpltId"
|
||||
readOnly
|
||||
{...register("elaprTmpltId", { required: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="elaprTmpltNm">결재서식명</Label>
|
||||
<Input
|
||||
id="elaprTmpltNm"
|
||||
{...register("elaprTmpltNm", { required: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>문서구분</Label>
|
||||
<CodeSelect
|
||||
upCd={UP_CD.DOC_SE}
|
||||
value={docSeComCd}
|
||||
onValueChange={(v) => setValue("docSeComCd", v)}
|
||||
placeholder="문서구분 선택"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="elaprTmpltExpln">서식 설명</Label>
|
||||
<Textarea id="elaprTmpltExpln" rows={3} {...register("elaprTmpltExpln")} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{mode === "create" ? "등록" : "저장"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { ElaprTmpltFormDialog };
|
||||
@@ -0,0 +1 @@
|
||||
export { ApprovalLineManagementPage } from "./ui/approval-line-management-page";
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
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 { PageTitle } from "@common/components";
|
||||
|
||||
const MENU_PATHS = ["디지털행정지원", "시스템관리", "결재선 관리"];
|
||||
|
||||
function ApprovalLineManagementPage() {
|
||||
const [selectedTmplt, setSelectedTmplt] = useState<ElaprTmplt | null>(null);
|
||||
|
||||
return (
|
||||
<div className="min-h-full bg-container pb-6">
|
||||
<PageTitle title="결재선 관리" menuPaths={MENU_PATHS} />
|
||||
<div className="space-y-6 px-[1.8rem]">
|
||||
<ElaprTmpltList
|
||||
selectedTmpltId={selectedTmplt?.elaprTmpltId ?? null}
|
||||
onSelectTmplt={setSelectedTmplt}
|
||||
/>
|
||||
<ElaprAplnGrid elaprTmpltId={selectedTmplt?.elaprTmpltId ?? null} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { ApprovalLineManagementPage };
|
||||
@@ -4,6 +4,13 @@ export const ENDPOINT = {
|
||||
SILENT_REFRESH: "/user-service/api/v1/users/token/refresh",
|
||||
USERS: "/user-service/api/v1/users",
|
||||
},
|
||||
AS_ELAPR_SERVICE: {
|
||||
ELAPR_TMPLT: "/as-elapr-service/api/v1/elapr-tmplt",
|
||||
ELAPR_APLN: "/as-elapr-service/api/v1/elapr-apln",
|
||||
},
|
||||
CMS_SERVICE: {
|
||||
CODE: "/cms-service/api/v1/code/cache",
|
||||
},
|
||||
};
|
||||
|
||||
export const JWT_EXPIRATION_TIME = 1800000;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/** TBD: elaprTmpltId / aplnId 서버 발급 정책 확정 후 클라이언트 생성 규칙 교체 */
|
||||
export function generateClientId(prefix: string): string {
|
||||
const suffix = crypto.randomUUID().replace(/-/g, "").slice(0, 8).toUpperCase();
|
||||
return `${prefix}-${suffix}`;
|
||||
}
|
||||
@@ -2,3 +2,6 @@ export { getCookie, removeCookie, setCookie } from "./cookie";
|
||||
export { dayjs } from "./dayjs";
|
||||
export { queryClient } from "./query-client";
|
||||
export { useUserStore } from "./use-user-store";
|
||||
export { generateClientId } from "./generate-client-id";
|
||||
export { matchQueryStatus } from "./match-query-status";
|
||||
export { useDataTable } from "./use-data-table";
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { UseQueryResult } from "@tanstack/react-query";
|
||||
|
||||
interface MatchQueryStatusOptions {
|
||||
Pending: ReactNode;
|
||||
Empty: ReactNode;
|
||||
Success: () => ReactNode;
|
||||
Error?: ReactNode;
|
||||
}
|
||||
|
||||
export function matchQueryStatus<T>(
|
||||
query: UseQueryResult<T>,
|
||||
options: MatchQueryStatusOptions,
|
||||
): ReactNode {
|
||||
if (query.isPending) return options.Pending;
|
||||
if (query.isError) return options.Error ?? null;
|
||||
|
||||
const isEmpty =
|
||||
query.data == null ||
|
||||
(Array.isArray(query.data) && query.data.length === 0) ||
|
||||
(typeof query.data === "object" &&
|
||||
query.data !== null &&
|
||||
"content" in query.data &&
|
||||
Array.isArray((query.data as { content: unknown[] }).content) &&
|
||||
(query.data as { content: unknown[] }).content.length === 0);
|
||||
|
||||
if (isEmpty) return options.Empty;
|
||||
|
||||
return options.Success();
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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 { PageResponse } from "@/shared/model";
|
||||
import { NoData } from "@/shared/ui/no-data";
|
||||
|
||||
import { matchQueryStatus } from "./match-query-status";
|
||||
|
||||
interface DataTableOptions<T> {
|
||||
variant?: "default" | "secondary" | "line";
|
||||
className?: HTMLAttributes<HTMLTableElement>["className"];
|
||||
viewPagination?: boolean;
|
||||
onRowClick?: (row: T) => void;
|
||||
}
|
||||
|
||||
interface UseDataTableOptions<T> {
|
||||
query: UseQueryResult<PageResponse<T>>;
|
||||
tableOptions: Omit<TableOptions<T>, "data" | "rowCount">;
|
||||
dataTableOptions?: DataTableOptions<T>;
|
||||
}
|
||||
|
||||
export function useDataTable<T>({
|
||||
query,
|
||||
tableOptions,
|
||||
dataTableOptions,
|
||||
}: UseDataTableOptions<T>) {
|
||||
const table = useReactTable({
|
||||
...tableOptions,
|
||||
data: query.data?.content ?? [],
|
||||
rowCount: query.data?.totalElements ?? 0,
|
||||
});
|
||||
|
||||
return {
|
||||
tableComponent: matchQueryStatus(query, {
|
||||
Pending: <p className="py-8 text-center text-sm text-muted-foreground">로딩 중…</p>,
|
||||
Empty: <NoData />,
|
||||
Success: () => <DataTable table={table} {...dataTableOptions} />,
|
||||
}),
|
||||
tableInstance: table,
|
||||
};
|
||||
}
|
||||
@@ -27,3 +27,5 @@ export interface ApiError {
|
||||
code?: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export type { PageableParams, PageResponse } from "./page";
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
interface SortConfig {
|
||||
empty: boolean;
|
||||
sorted: boolean;
|
||||
unsorted: boolean;
|
||||
}
|
||||
|
||||
export interface PageableParams {
|
||||
page?: number;
|
||||
size?: number;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
export interface PageResponse<T> {
|
||||
content: T[];
|
||||
pageable: {
|
||||
sort: SortConfig;
|
||||
offset: number;
|
||||
pageNumber: number;
|
||||
pageSize: number;
|
||||
paged: boolean;
|
||||
unpaged: boolean;
|
||||
};
|
||||
sort: SortConfig;
|
||||
totalPages: number;
|
||||
totalElements: number;
|
||||
number: number;
|
||||
numberOfElements: number;
|
||||
size: number;
|
||||
first: boolean;
|
||||
last: boolean;
|
||||
empty: boolean;
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export { PageLayout } from "./page-layout";
|
||||
export { NoData } from "./no-data";
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { NoData };
|
||||
@@ -0,0 +1 @@
|
||||
export { ElaprAplnGrid } from "./ui/elapr-apln-grid";
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
|
||||
import type { ElaprApln } from "@/entities/elapr";
|
||||
import { generateClientId } from "@/shared/lib";
|
||||
|
||||
import type { ElaprAplnRow } from "../model/types";
|
||||
|
||||
function toRow(item: ElaprApln): ElaprAplnRow {
|
||||
return { ...item, isNew: false, isDirty: false };
|
||||
}
|
||||
|
||||
interface UseAplnRowsOptions {
|
||||
elaprTmpltId: string | null;
|
||||
serverRows: ElaprApln[] | undefined;
|
||||
}
|
||||
|
||||
export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) {
|
||||
const [rows, setRows] = useState<ElaprAplnRow[]>([]);
|
||||
const [editingRowId, setEditingRowId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!elaprTmpltId) {
|
||||
setRows([]);
|
||||
setEditingRowId(null);
|
||||
return;
|
||||
}
|
||||
setRows((serverRows ?? []).map(toRow));
|
||||
setEditingRowId(null);
|
||||
}, [elaprTmpltId, serverRows]);
|
||||
|
||||
const isEditingLocked = editingRowId != null;
|
||||
|
||||
const addRow = useCallback(() => {
|
||||
if (!elaprTmpltId || isEditingLocked) return;
|
||||
|
||||
const tempId = generateClientId("APLN");
|
||||
const nextSeq = rows.length + 1;
|
||||
|
||||
setRows((prev) => [
|
||||
...prev,
|
||||
{
|
||||
aplnId: tempId,
|
||||
elaprTmpltId,
|
||||
atrzSeqNo: nextSeq,
|
||||
atrzSeComCd: "",
|
||||
aprvrUuid: "",
|
||||
aprvrNm: "",
|
||||
isNew: true,
|
||||
isDirty: true,
|
||||
},
|
||||
]);
|
||||
setEditingRowId(tempId);
|
||||
}, [elaprTmpltId, isEditingLocked, rows.length]);
|
||||
|
||||
const startEdit = useCallback(
|
||||
(aplnId: string) => {
|
||||
if (isEditingLocked) return;
|
||||
setEditingRowId(aplnId);
|
||||
setRows((prev) =>
|
||||
prev.map((row) =>
|
||||
row.aplnId === aplnId
|
||||
? { ...row, snapshot: { ...row } }
|
||||
: row,
|
||||
),
|
||||
);
|
||||
},
|
||||
[isEditingLocked],
|
||||
);
|
||||
|
||||
const updateRow = useCallback(
|
||||
(aplnId: string, patch: Partial<ElaprAplnRow>) => {
|
||||
if (editingRowId !== aplnId) return;
|
||||
setRows((prev) =>
|
||||
prev.map((row) =>
|
||||
row.aplnId === aplnId
|
||||
? { ...row, ...patch, isDirty: true }
|
||||
: row,
|
||||
),
|
||||
);
|
||||
},
|
||||
[editingRowId],
|
||||
);
|
||||
|
||||
const cancelEdit = useCallback(() => {
|
||||
if (!editingRowId) return;
|
||||
|
||||
setRows((prev) => {
|
||||
const target = prev.find((r) => r.aplnId === editingRowId);
|
||||
if (target?.isNew) {
|
||||
return prev.filter((r) => r.aplnId !== editingRowId);
|
||||
}
|
||||
return prev.map((row) => {
|
||||
if (row.aplnId !== editingRowId) return row;
|
||||
if (row.snapshot) {
|
||||
const { snapshot: _s, isNew: _n, isDirty: _d, ...restored } = row.snapshot;
|
||||
return { ...restored, isNew: false, isDirty: false };
|
||||
}
|
||||
return { ...row, isDirty: false };
|
||||
});
|
||||
});
|
||||
setEditingRowId(null);
|
||||
}, [editingRowId]);
|
||||
|
||||
const clearEditing = useCallback(() => {
|
||||
setEditingRowId(null);
|
||||
}, []);
|
||||
|
||||
const removeRowLocal = useCallback((aplnId: string) => {
|
||||
setRows((prev) => prev.filter((r) => r.aplnId !== aplnId));
|
||||
setEditingRowId((id) => (id === aplnId ? null : id));
|
||||
}, []);
|
||||
|
||||
const reorderRows = useCallback(
|
||||
(activeId: string, overId: string) => {
|
||||
if (isEditingLocked) return;
|
||||
setRows((prev) => {
|
||||
const oldIndex = prev.findIndex((r) => r.aplnId === activeId);
|
||||
const newIndex = prev.findIndex((r) => r.aplnId === overId);
|
||||
if (oldIndex < 0 || newIndex < 0) return prev;
|
||||
|
||||
const moved = arrayMove(prev, oldIndex, newIndex).map((row, idx) => ({
|
||||
...row,
|
||||
atrzSeqNo: idx + 1,
|
||||
isDirty: true,
|
||||
}));
|
||||
return moved;
|
||||
});
|
||||
},
|
||||
[isEditingLocked],
|
||||
);
|
||||
|
||||
return {
|
||||
rows,
|
||||
setRows,
|
||||
editingRowId,
|
||||
isEditingLocked,
|
||||
addRow,
|
||||
startEdit,
|
||||
updateRow,
|
||||
cancelEdit,
|
||||
clearEditing,
|
||||
removeRowLocal,
|
||||
reorderRows,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import {
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import { sortableKeyboardCoordinates } from "@dnd-kit/sortable";
|
||||
|
||||
export function useDndSensors() {
|
||||
return useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ElaprApln } from "@/entities/elapr";
|
||||
|
||||
/** 그리드 로컬 행 — API 응답 + UI 보조 필드 */
|
||||
export interface ElaprAplnRow extends ElaprApln {
|
||||
/** 결재자 표시명 — 응답 DTO에 없음, 선택 시 로컬 유지 */
|
||||
aprvrNm?: string;
|
||||
isNew?: boolean;
|
||||
isDirty?: boolean;
|
||||
/** 수정 취소용 스냅샷 */
|
||||
snapshot?: ElaprAplnRow;
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { closestCenter, DndContext, type DragEndEvent } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import {
|
||||
Button,
|
||||
Table,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import {
|
||||
useCreateElaprApln,
|
||||
useDeleteElaprApln,
|
||||
useElaprAplnPage,
|
||||
useUpdateElaprApln,
|
||||
} from "@/entities/elapr";
|
||||
import { AprvrPickerDialog, type ReviewerOption } from "@/features/aprvr-picker";
|
||||
|
||||
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 { SortableAplnRow } from "./sortable-apln-row";
|
||||
|
||||
interface ElaprAplnGridProps {
|
||||
elaprTmpltId: string | null;
|
||||
}
|
||||
|
||||
function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [pickingRowId, setPickingRowId] = useState<string | null>(null);
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
|
||||
const aplnQuery = useElaprAplnPage({
|
||||
elaprTmpltId: elaprTmpltId ?? undefined,
|
||||
page: 0,
|
||||
size: 200,
|
||||
sort: "atrzSeqNo,asc",
|
||||
});
|
||||
|
||||
const createMutation = useCreateElaprApln();
|
||||
const updateMutation = useUpdateElaprApln();
|
||||
const deleteMutation = useDeleteElaprApln();
|
||||
|
||||
const {
|
||||
rows,
|
||||
editingRowId,
|
||||
isEditingLocked,
|
||||
addRow,
|
||||
startEdit,
|
||||
updateRow,
|
||||
cancelEdit,
|
||||
clearEditing,
|
||||
removeRowLocal,
|
||||
reorderRows,
|
||||
} = useAplnRows({
|
||||
elaprTmpltId,
|
||||
serverRows: aplnQuery.data?.content,
|
||||
});
|
||||
|
||||
const sensors = useDndSensors();
|
||||
const isDisabled = !elaprTmpltId;
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
reorderRows(String(active.id), String(over.id));
|
||||
};
|
||||
|
||||
const handleRowSave = async (row: ElaprAplnRow) => {
|
||||
const fieldError = validateAplnRow(row);
|
||||
if (fieldError) {
|
||||
setStatusMessage(fieldError);
|
||||
return;
|
||||
}
|
||||
const dupError = validateUniqueSeqNo(
|
||||
rows.map((r) => (r.aplnId === row.aplnId ? row : r)),
|
||||
);
|
||||
if (dupError) {
|
||||
setStatusMessage(dupError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!elaprTmpltId) return;
|
||||
|
||||
if (row.isNew) {
|
||||
await createMutation.mutateAsync({
|
||||
aplnId: row.aplnId,
|
||||
elaprTmpltId,
|
||||
aprvrUuid: row.aprvrUuid,
|
||||
atrzSeComCd: row.atrzSeComCd,
|
||||
atrzSeqNo: row.atrzSeqNo,
|
||||
});
|
||||
} else {
|
||||
await updateMutation.mutateAsync({
|
||||
aplnId: row.aplnId,
|
||||
body: {
|
||||
elaprTmpltId,
|
||||
aprvrUuid: row.aprvrUuid,
|
||||
atrzSeComCd: row.atrzSeComCd,
|
||||
atrzSeqNo: row.atrzSeqNo,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
clearEditing();
|
||||
setStatusMessage(null);
|
||||
await aplnQuery.refetch();
|
||||
};
|
||||
|
||||
const handleRowDelete = async (row: ElaprAplnRow) => {
|
||||
if (!window.confirm("선택한 결재선을 삭제하시겠습니까?")) return;
|
||||
|
||||
if (row.isNew) {
|
||||
removeRowLocal(row.aplnId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!elaprTmpltId) return;
|
||||
|
||||
await deleteMutation.mutateAsync({ aplnId: row.aplnId, elaprTmpltId });
|
||||
await aplnQuery.refetch();
|
||||
};
|
||||
|
||||
const handleBulkSave = async () => {
|
||||
if (!elaprTmpltId || isEditingLocked) return;
|
||||
|
||||
const dupError = validateUniqueSeqNo(rows);
|
||||
if (dupError) {
|
||||
setStatusMessage(dupError);
|
||||
return;
|
||||
}
|
||||
|
||||
const dirtyRows = rows.filter((r) => r.isDirty || r.isNew);
|
||||
|
||||
for (const row of dirtyRows) {
|
||||
const fieldError = validateAplnRow(row);
|
||||
if (fieldError) {
|
||||
setStatusMessage(fieldError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (row.isNew) {
|
||||
await createMutation.mutateAsync({
|
||||
aplnId: row.aplnId,
|
||||
elaprTmpltId,
|
||||
aprvrUuid: row.aprvrUuid,
|
||||
atrzSeComCd: row.atrzSeComCd,
|
||||
atrzSeqNo: row.atrzSeqNo,
|
||||
});
|
||||
} else {
|
||||
await updateMutation.mutateAsync({
|
||||
aplnId: row.aplnId,
|
||||
body: {
|
||||
elaprTmpltId,
|
||||
aprvrUuid: row.aprvrUuid,
|
||||
atrzSeComCd: row.atrzSeComCd,
|
||||
atrzSeqNo: row.atrzSeqNo,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setStatusMessage("결재선이 저장되었습니다.");
|
||||
await aplnQuery.refetch();
|
||||
};
|
||||
|
||||
const handleReviewerSelect = (reviewer: ReviewerOption) => {
|
||||
if (!pickingRowId) return;
|
||||
updateRow(pickingRowId, {
|
||||
aprvrUuid: reviewer.uuid,
|
||||
aprvrNm: reviewer.name,
|
||||
});
|
||||
setPickingRowId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`rounded-lg border border-border bg-background p-4 ${isDisabled ? "pointer-events-none opacity-50" : ""}`}
|
||||
>
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="text-lg font-semibold">결재선 관리</h3>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{elaprTmpltId
|
||||
? `선택된 서식: ${elaprTmpltId}`
|
||||
: "서식을 선택하면 결재선을 관리할 수 있습니다."}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isDisabled || isEditingLocked}
|
||||
onClick={addRow}
|
||||
>
|
||||
행 추가
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isDisabled || isEditingLocked}
|
||||
onClick={handleBulkSave}
|
||||
>
|
||||
결재선 저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{statusMessage && (
|
||||
<p className="mb-3 text-sm text-primary" role="status">
|
||||
{statusMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{aplnQuery.isPending && elaprTmpltId ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">로딩 중…</p>
|
||||
) : !elaprTmpltId ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
상단 목록에서 결재서식을 선택해 주세요.
|
||||
</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
등록된 결재선이 없습니다. [행 추가]로 결재자를 등록하세요.
|
||||
</p>
|
||||
) : (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<Table variant="line" className="w-full">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10" />
|
||||
<TableHead>결재선아이디</TableHead>
|
||||
<TableHead>결재순서</TableHead>
|
||||
<TableHead>결재구분</TableHead>
|
||||
<TableHead>결재자</TableHead>
|
||||
<TableHead>등록일시</TableHead>
|
||||
<TableHead>관리</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<tbody>
|
||||
<SortableContext
|
||||
items={rows.map((r) => r.aplnId)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{rows.map((row) => (
|
||||
<SortableAplnRow
|
||||
key={row.aplnId}
|
||||
row={row}
|
||||
isEditing={editingRowId === row.aplnId}
|
||||
isLocked={isEditingLocked}
|
||||
dragDisabled={isEditingLocked}
|
||||
onStartEdit={() => startEdit(row.aplnId)}
|
||||
onSave={() => handleRowSave(row)}
|
||||
onCancel={cancelEdit}
|
||||
onDelete={() => handleRowDelete(row)}
|
||||
onChange={(patch) => updateRow(row.aplnId, patch)}
|
||||
onPickAprvr={() => {
|
||||
setPickingRowId(row.aplnId);
|
||||
setPickerOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</tbody>
|
||||
</Table>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
<AprvrPickerDialog
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
onSelect={handleReviewerSelect}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export { ElaprAplnGrid };
|
||||
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
TableCell,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import { CodeSelect, findCodeLabel, UP_CD, useCodeList } from "@/entities/code";
|
||||
|
||||
import type { ElaprAplnRow } from "../model/types";
|
||||
|
||||
interface SortableAplnRowProps {
|
||||
row: ElaprAplnRow;
|
||||
isEditing: boolean;
|
||||
isLocked: boolean;
|
||||
dragDisabled: boolean;
|
||||
onStartEdit: () => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
onDelete: () => void;
|
||||
onChange: (patch: Partial<ElaprAplnRow>) => void;
|
||||
onPickAprvr: () => void;
|
||||
}
|
||||
|
||||
function SortableAplnRow({
|
||||
row,
|
||||
isEditing,
|
||||
isLocked,
|
||||
dragDisabled,
|
||||
onStartEdit,
|
||||
onSave,
|
||||
onCancel,
|
||||
onDelete,
|
||||
onChange,
|
||||
onPickAprvr,
|
||||
}: SortableAplnRowProps) {
|
||||
const { data: atrzSeCodes } = useCodeList(UP_CD.ATRZ_SE);
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: row.aplnId, disabled: dragDisabled });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.6 : 1,
|
||||
};
|
||||
|
||||
const rowDisabled = isLocked && !isEditing;
|
||||
const atrzSeLabel =
|
||||
row.atrzSeComNm ?? findCodeLabel(atrzSeCodes, row.atrzSeComCd);
|
||||
|
||||
return (
|
||||
<TableRow ref={setNodeRef} style={style} className={isEditing ? "bg-accent/30" : undefined}>
|
||||
<TableCell align="center" className="w-10">
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-grab disabled:cursor-not-allowed disabled:opacity-40"
|
||||
disabled={dragDisabled}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label="순서 변경"
|
||||
>
|
||||
<span className="select-none text-muted-foreground" aria-hidden>
|
||||
⋮⋮
|
||||
</span>
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell align="left">{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">
|
||||
{isEditing ? (
|
||||
<CodeSelect
|
||||
upCd={UP_CD.ATRZ_SE}
|
||||
className="min-w-[120px]"
|
||||
value={row.atrzSeComCd ?? ""}
|
||||
onValueChange={(v) =>
|
||||
onChange({
|
||||
atrzSeComCd: v,
|
||||
atrzSeComNm: findCodeLabel(atrzSeCodes, v),
|
||||
})
|
||||
}
|
||||
placeholder="결재구분"
|
||||
/>
|
||||
) : (
|
||||
atrzSeLabel || "—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="left">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-[80px] text-sm">
|
||||
{row.aprvrNm || row.aprvrUuid || "—"}
|
||||
</span>
|
||||
{isEditing && (
|
||||
<Button type="button" size="sm" variant="outline" onClick={onPickAprvr}>
|
||||
선택
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell align="center">{row.isNew ? "—" : (row.regDt ?? "—")}</TableCell>
|
||||
<TableCell align="center">
|
||||
<div className="flex justify-center gap-2">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button type="button" size="sm" onClick={onSave}>
|
||||
저장
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={onCancel}>
|
||||
취소
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={rowDisabled}
|
||||
onClick={onStartEdit}
|
||||
>
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
color="red"
|
||||
disabled={rowDisabled}
|
||||
onClick={onDelete}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export { SortableAplnRow };
|
||||
@@ -0,0 +1 @@
|
||||
export { ElaprTmpltList } from "./ui/elapr-tmplt-list";
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import type { ElaprTmplt } from "@/entities/elapr";
|
||||
|
||||
const columnHelper = createColumnHelper<ElaprTmplt>();
|
||||
|
||||
interface CreateTmpltColumnsOptions {
|
||||
onEdit: (row: ElaprTmplt) => void;
|
||||
onDelete: (row: ElaprTmplt) => void;
|
||||
}
|
||||
|
||||
export function createTmpltColumns({ onEdit, onDelete }: CreateTmpltColumnsOptions) {
|
||||
return [
|
||||
columnHelper.accessor("elaprTmpltId", {
|
||||
header: "전자결재서식아이디",
|
||||
meta: { align: "left" },
|
||||
}),
|
||||
columnHelper.accessor("elaprTmpltNm", {
|
||||
header: "결재서식명",
|
||||
meta: { align: "left" },
|
||||
}),
|
||||
columnHelper.accessor("docSeComNm", {
|
||||
header: "문서구분",
|
||||
cell: ({ getValue, row }) => getValue() ?? row.original.docSeComCd ?? "—",
|
||||
}),
|
||||
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" variant="outline" onClick={() => onEdit(row.original)}>
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
color="red"
|
||||
onClick={() => onDelete(row.original)}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { getCoreRowModel, type RowSelectionState } from "@tanstack/react-table";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import {
|
||||
useCreateElaprTmplt,
|
||||
useDeleteElaprTmplt,
|
||||
useElaprTmpltPage,
|
||||
useUpdateElaprTmplt,
|
||||
type ElaprTmplt,
|
||||
} from "@/entities/elapr";
|
||||
import {
|
||||
ElaprTmpltFormDialog,
|
||||
type ElaprTmpltFormValues,
|
||||
} from "@/features/elapr-tmplt-dialog";
|
||||
import { useDataTable } from "@/shared/lib";
|
||||
|
||||
import { createTmpltColumns } from "../model/columns";
|
||||
|
||||
interface ElaprTmpltListProps {
|
||||
selectedTmpltId: string | null;
|
||||
onSelectTmplt: (tmplt: ElaprTmplt | null) => void;
|
||||
}
|
||||
|
||||
function ElaprTmpltList({ selectedTmpltId, onSelectTmplt }: ElaprTmpltListProps) {
|
||||
const [dialogMode, setDialogMode] = useState<"create" | "edit" | null>(null);
|
||||
const [editingTmplt, setEditingTmplt] = useState<ElaprTmplt | null>(null);
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
|
||||
const tmpltQuery = useElaprTmpltPage({
|
||||
page: 0,
|
||||
size: 50,
|
||||
sort: "regDt,desc",
|
||||
});
|
||||
|
||||
const createMutation = useCreateElaprTmplt();
|
||||
const updateMutation = useUpdateElaprTmplt();
|
||||
const deleteMutation = useDeleteElaprTmplt();
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
createTmpltColumns({
|
||||
onEdit: (row) => {
|
||||
setEditingTmplt(row);
|
||||
setDialogMode("edit");
|
||||
},
|
||||
onDelete: async (row) => {
|
||||
if (!window.confirm("선택한 결재서식을 삭제하시겠습니까?")) return;
|
||||
await deleteMutation.mutateAsync(row.elaprTmpltId);
|
||||
if (selectedTmpltId === row.elaprTmpltId) {
|
||||
onSelectTmplt(null);
|
||||
}
|
||||
},
|
||||
}),
|
||||
[deleteMutation, onSelectTmplt, selectedTmpltId],
|
||||
);
|
||||
|
||||
const { tableComponent } = useDataTable({
|
||||
query: tmpltQuery,
|
||||
tableOptions: {
|
||||
columns,
|
||||
state: { rowSelection },
|
||||
enableRowSelection: true,
|
||||
enableMultiRowSelection: false,
|
||||
getRowId: (row) => row.elaprTmpltId,
|
||||
onRowSelectionChange: (updater) => {
|
||||
const next =
|
||||
typeof updater === "function" ? updater(rowSelection) : updater;
|
||||
setRowSelection(next);
|
||||
const selectedId = Object.keys(next)[0];
|
||||
if (!selectedId) {
|
||||
onSelectTmplt(null);
|
||||
return;
|
||||
}
|
||||
const selected = tmpltQuery.data?.content.find(
|
||||
(t) => t.elaprTmpltId === selectedId,
|
||||
);
|
||||
onSelectTmplt(selected ?? null);
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
},
|
||||
dataTableOptions: {
|
||||
variant: "line",
|
||||
onRowClick: (row) => {
|
||||
setRowSelection({ [row.elaprTmpltId]: true });
|
||||
onSelectTmplt(row);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const handleFormSubmit = async (values: ElaprTmpltFormValues) => {
|
||||
if (dialogMode === "create") {
|
||||
await createMutation.mutateAsync({
|
||||
elaprTmpltId: values.elaprTmpltId,
|
||||
elaprTmpltNm: values.elaprTmpltNm,
|
||||
docSeComCd: values.docSeComCd || undefined,
|
||||
elaprTmpltExpln: values.elaprTmpltExpln || undefined,
|
||||
});
|
||||
} else if (dialogMode === "edit" && editingTmplt) {
|
||||
await updateMutation.mutateAsync({
|
||||
elaprTmpltId: editingTmplt.elaprTmpltId,
|
||||
body: {
|
||||
elaprTmpltNm: values.elaprTmpltNm,
|
||||
docSeComCd: values.docSeComCd || undefined,
|
||||
elaprTmpltExpln: values.elaprTmpltExpln || undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
setDialogMode(null);
|
||||
setEditingTmplt(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-border bg-background p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">전자결재선 관리 목록</h3>
|
||||
<Button type="button" onClick={() => setDialogMode("create")}>
|
||||
서식 등록
|
||||
</Button>
|
||||
</div>
|
||||
{tableComponent}
|
||||
{dialogMode && (
|
||||
<ElaprTmpltFormDialog
|
||||
mode={dialogMode}
|
||||
open={Boolean(dialogMode)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDialogMode(null);
|
||||
setEditingTmplt(null);
|
||||
}
|
||||
}}
|
||||
initial={dialogMode === "edit" ? editingTmplt : null}
|
||||
isSubmitting={createMutation.isPending || updateMutation.isPending}
|
||||
onSubmit={handleFormSubmit}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export { ElaprTmpltList };
|
||||
Generated
+17
-5
@@ -104,10 +104,10 @@ importers:
|
||||
devDependencies:
|
||||
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)
|
||||
postcss:
|
||||
specifier: ^8.4.49
|
||||
version: 8.5.8
|
||||
@@ -138,6 +138,18 @@ importers:
|
||||
'@common/network':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/common/network
|
||||
'@dnd-kit/core':
|
||||
specifier: ^6.3.1
|
||||
version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@dnd-kit/sortable':
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
|
||||
'@dnd-kit/utilities':
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2(react@19.2.4)
|
||||
'@faker-js/faker':
|
||||
specifier: ^9.9.0
|
||||
version: 9.9.0
|
||||
'@hookform/resolvers':
|
||||
specifier: ^4.1.3
|
||||
version: 4.1.3(react-hook-form@7.72.0(react@19.2.4))
|
||||
@@ -177,13 +189,13 @@ importers:
|
||||
version: 3.0.6
|
||||
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)
|
||||
eslint-plugin-react-hooks:
|
||||
specifier: ^5.0.0
|
||||
version: 5.2.0(eslint@9.39.4(jiti@2.6.1))
|
||||
version: 5.2.0(eslint@9.39.4(jiti@1.21.7))
|
||||
postcss:
|
||||
specifier: ^8.4.49
|
||||
version: 8.5.8
|
||||
|
||||
Reference in New Issue
Block a user