Compare commits
127
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1a67f549f | ||
|
|
c0888ea2a4 | ||
|
|
1ead18b53c | ||
|
|
07db5b1fbd | ||
|
|
6110abebb4 | ||
|
|
05e9082831 | ||
|
|
76a634cf9a | ||
|
|
793c673414 | ||
|
|
2a76c279eb | ||
|
|
5d1ba3fd87 | ||
|
|
99db9a0b77 | ||
|
|
4126bc2234 | ||
|
|
d4f1ec095e | ||
|
|
bbf3ce3948 | ||
|
|
fb5b8356c7 | ||
|
|
1333bd040a | ||
|
|
013c07ef59 | ||
|
|
29fd59a321 | ||
|
|
3bc152c341 | ||
|
|
ea274f8f7d | ||
|
|
7ac53e56d7 | ||
|
|
7b1c12e952 | ||
|
|
17dafe6530 | ||
|
|
ef331363f7 | ||
|
|
4f10864c2c | ||
|
|
4b5ceb0e0a | ||
|
|
4fa50161fd | ||
|
|
3d27e07ccb | ||
|
|
ca1ca71a62 | ||
|
|
bdd6c7b804 | ||
|
|
04b8082f04 | ||
|
|
7bf8ec1d35 | ||
|
|
3451b19b96 | ||
|
|
ae7c1ea1c4 | ||
|
|
9c76b955e2 | ||
|
|
6c4bfe2798 | ||
|
|
98189cd14a | ||
|
|
823ce262bb | ||
|
|
34358400e8 | ||
|
|
da9d069a9a | ||
|
|
58a03678e4 | ||
|
|
f5c281cf86 | ||
|
|
c72bcea7d9 | ||
|
|
49cd160e76 | ||
|
|
5ca93a25ca | ||
|
|
55554fc781 | ||
|
|
8e78c10304 | ||
|
|
0eb74b46ba | ||
|
|
f21dde8f7d | ||
|
|
1fc6707bb3 | ||
|
|
44c5d66afa | ||
|
|
04edb49c9f | ||
|
|
e3eb542e59 | ||
|
|
428b199dd3 | ||
|
|
3c4a6efeea | ||
|
|
dae7024043 | ||
|
|
64e5c26e8c | ||
|
|
84ae60edd6 | ||
|
|
d4c9989069 | ||
|
|
6cda413913 | ||
|
|
a11eaf031d | ||
|
|
3c701359fd | ||
|
|
85ba9fe0ab | ||
|
|
71077f4c8e | ||
|
|
4c7ff53d78 | ||
|
|
bdc3a94ef1 | ||
|
|
b31691dd9c | ||
|
|
0647df733a | ||
|
|
0131cae0d7 | ||
|
|
f37b19eab6 | ||
|
|
7c1db5e0e0 | ||
|
|
2c495ab8fd | ||
|
|
ced1b7084d | ||
|
|
b0c871eff7 | ||
|
|
4148a87f32 | ||
|
|
bd83fa3417 | ||
|
|
c0b63a655e | ||
|
|
cc21aaede1 | ||
|
|
0c71c80a25 | ||
|
|
49e42757bc | ||
|
|
0964f442be | ||
|
|
73ceeb1b38 | ||
|
|
06f68ec392 | ||
|
|
e5dc347276 | ||
|
|
7c6c43b334 | ||
|
|
abf53afafc | ||
|
|
fc70963fa9 | ||
|
|
814365d7de | ||
|
|
8c1a976b5b | ||
|
|
66db81071e | ||
|
|
6df6e612bc | ||
|
|
84013993c9 | ||
|
|
3fee2a781f | ||
|
|
acaf2dd9f4 | ||
|
|
55839f0913 | ||
|
|
f68131fc13 | ||
|
|
7b9081a125 | ||
|
|
0947b76a6e | ||
|
|
23c18da399 | ||
|
|
ec18f17c79 | ||
|
|
cdfa1e56e5 | ||
|
|
434c3cfc08 | ||
|
|
aee3449578 | ||
|
|
54d3a35b38 | ||
|
|
b640fffd0d | ||
|
|
981e4aab54 | ||
|
|
6b68894d23 | ||
|
|
97a8b2811e | ||
|
|
2c3839fc5f | ||
|
|
e9caa1b84d | ||
|
|
d2d5502f8d | ||
|
|
ee114dec7e | ||
|
|
d58b2db786 | ||
|
|
bb7cce47ac | ||
|
|
2a8c2ae542 | ||
|
|
56f52194a6 | ||
|
|
b2282accb1 | ||
|
|
9a8350dae8 | ||
|
|
77fd719f96 | ||
|
|
aa831141f6 | ||
|
|
fb148a77c1 | ||
|
|
d8d98aee6d | ||
|
|
87f9b90dbf | ||
|
|
b704db99b1 | ||
|
|
ba42b74578 | ||
|
|
e19057d026 | ||
|
|
f4c64168d1 |
@@ -19,7 +19,7 @@
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
"axios": "^1.8.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"next": "15.3.8",
|
||||
"next": "15.5.18",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
@@ -33,4 +33,4 @@
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { AcntgCodeManagementPage as default } from "@/pages/acntg-code-management";
|
||||
@@ -0,0 +1 @@
|
||||
export { CnstpCodeManagementPage as default } from "@/pages/cnstp-code-management";
|
||||
@@ -1 +0,0 @@
|
||||
export { CodeManagementPage as default } from "@/pages/code-management";
|
||||
@@ -0,0 +1 @@
|
||||
export { RvwMnpwMngPage as default } from "@/pages/rvw-mnpw-mng";
|
||||
@@ -0,0 +1,370 @@
|
||||
# 화면설계서: 검토인력관리
|
||||
|
||||
## 1. 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ---------- | -------------------------------------------- |
|
||||
| 버전 | 0.2 |
|
||||
| 프로세스명 | UI설계 |
|
||||
| 화면명 | 검토인력관리 |
|
||||
| 화면 유형 | [v] 조회 [v] 입력 [ ] 수정 [v] 삭제 [ ] 출력 |
|
||||
| 메뉴 경로 | 디지털행정지원 > 사이트관리 > 검토인력관리 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 화면 목적
|
||||
|
||||
조직도에서 **한 명의 사용자**를 선택한 뒤, 결재 단계(접수·검토·승인·최종승인)별 **인원 배치** 영역에 즉시 등록한다. 등록·목록 갱신은 별도 저장 버튼 없이 **[선택 인원 배치]** 클릭 시 `rvw-mnpw` API로 바로 반영한다. 좌측 조직도는 CMS 조직·사용자 트리 API를, 우측 배치 목록은 검토인력관리 API를 사용한다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 화면 구성
|
||||
|
||||
### 3-1. 전체 레이아웃
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 페이지 타이틀: 검토인력관리 브레드크럼: 디지털행정지원 > 사이트관리 > 검토인력관리 │
|
||||
├─────────────────────────────────┬──────────────────────────────────────────────────────────────────┤
|
||||
│ [영역 A] 조직도 │ [영역 B] 인원 배치 (결재구분별 4블록, 세로 스택) │
|
||||
│ 제목: 조직도 │ │
|
||||
│ [A-1] 검색: │ ┌─ [B-1] 접수 ───────────── count ─ [선택 인원 배치] ─┐ │
|
||||
│ [성명·부서 검색] [조회][초기화]│ │ (배치 목록 또는 빈 상태 문구) │ │
|
||||
│ 선택 안내: │ └──────────────────────────────────────────────────────┘ │
|
||||
│ "선택된 인원: {이름} │ ┌─ [B-2] 검토 ───────────── count ─ [선택 인원 배치] ──┐ │
|
||||
│ ({부서} / {직급})" │ └──────────────────────────────────────────────────────┘ │
|
||||
│ [A-2] 조직 트리 + 사용자 행 │ ┌─ [B-3] 승인 ───────────── count ─ [선택 인원 배치] ──┐ │
|
||||
│ ┌────────────────────┐ │ └──────────────────────────────────────────────────────┘ │
|
||||
│ │ 조직 트리 + 사용자 │ │ ┌─ [B-4] 최종승인 ───────── count ─ [선택 인원 배치] ──┐ │
|
||||
│ │ (단일 선택 강조) │ │ └──────────────────────────────────────────────────────┘ │
|
||||
│ └────────────────────┘ │ │
|
||||
└─────────────────────────────────┴──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3-2. 화면 데이터 흐름
|
||||
|
||||
```
|
||||
BEGIN
|
||||
|
||||
// 최초 진입
|
||||
CALL userOgnzService.findOgnzUserTree()
|
||||
DISPLAY 조직도 트리 (영역 A)
|
||||
|
||||
CALL rvwMnpwMngService.findPage({ size: 충분히 큰 값 }) // 또는 atrzSeComCd별 분할 조회
|
||||
GROUP BY atrzSeComCd
|
||||
DISPLAY 인원 배치 4블록 (영역 B-1 ~ B-4) + 건수 뱃지
|
||||
|
||||
// 영역 A-1: 조직도 검색 (클라이언트 필터 — 서버 API 없음)
|
||||
ON [조회] 버튼 CLICK / 검색 폼 SUBMIT DO
|
||||
SET displayTree = filterOgnzUserTree(원본트리, keyword)
|
||||
SET expandedOgnzSnIds = collectExpandedOgnzSnIds(displayTree) // 매칭 노드 자동 펼침
|
||||
DISPLAY 필터된 트리 (영역 A-2)
|
||||
END ON
|
||||
|
||||
ON [초기화] 버튼 CLICK DO
|
||||
RESET keyword = ""
|
||||
RESET displayTree = 원본트리
|
||||
RESET expandedOgnzSnIds = 빈 Set (또는 기본 접힘)
|
||||
DISPLAY 전체 트리 (영역 A-2)
|
||||
END ON
|
||||
|
||||
// 영역 A-2: 사용자 단일 선택
|
||||
ON 조직도 사용자 행 CLICK DO
|
||||
VALIDATE 동일 행 재클릭이 아닌 경우만 변경
|
||||
SET selectedPerson = { userUuid, userNm, ognzSn, ognzNm, jbgdSeCd, ... }
|
||||
DISPLAY "선택된 인원: {userNm} ({ognzNm} / {직급명})"
|
||||
END ON
|
||||
|
||||
// 영역 B-k: 선택 인원 즉시 배치
|
||||
ON [B-k] [선택 인원 배치] 버튼 CLICK DO
|
||||
VALIDATE selectedPerson 존재
|
||||
SET targetAtrzSeComCd = 블록 k 에 매핑된 결재구분 코드 (§4-2 표)
|
||||
CALL rvwMnpwMngService.save({
|
||||
atrzSeComCd: targetAtrzSeComCd,
|
||||
ognzSn: selectedPerson.ognzSn,
|
||||
userUuid: selectedPerson.userUuid
|
||||
})
|
||||
ON 성공 DO
|
||||
CALL rvwMnpwMngService.findPage (또는 해당 블록 목록만 갱신)
|
||||
DISPLAY [B-k] 배치 목록·건수 뱃지 갱신
|
||||
TOAST "배치되었습니다."
|
||||
END ON
|
||||
ON 실패(이미 존재) DO
|
||||
TOAST (백엔드 메시지 연동)
|
||||
END ON
|
||||
END ON
|
||||
|
||||
// 영역 B-k: 배치 인원 제거 (UI 상 삭제 UX — 스크린샷 미표기, API는 제공됨)
|
||||
ON 배치 인원 [삭제] / [제거] CLICK DO
|
||||
CONFIRM "해당 인원을 배치에서 제거하시겠습니까?"
|
||||
CALL rvwMnpwMngService.delete(atrzSeComCd, ognzSn, userUuid)
|
||||
CALL rvwMnpwMngService.findPage
|
||||
DISPLAY [B-k] 목록·뱃지 갱신
|
||||
END ON
|
||||
|
||||
END
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 영역 A — 조직도
|
||||
|
||||
### 4-1. 표시·상호작용
|
||||
|
||||
| 요소 | 내용 |
|
||||
| ---------------- | --------------------------------------------------------------------- |
|
||||
| 섹션 제목 | 조직도 |
|
||||
| 검색 폼 (A-1) | 성명·부서 키워드 입력 + **[조회]** + **[초기화]** — §4-3 |
|
||||
| 선택 안내 문구 | `선택된 인원: {userNm} ({ognzNm} / {직급표시})` — 미선택 시 안내 문구 |
|
||||
| 트리 (A-2) | 본부 → 팀 계층 + 팀 소속 사용자 목록 (`displayTree` 기준 표시) |
|
||||
| 선택 규칙 | **사용자 1명만** 선택 가능 (라디오형 단일 선택) |
|
||||
| 선택 시각 피드백 | 선택된 사용자 행 강조(배경·테두리) |
|
||||
| 로딩/오류/빈값 | Skeleton / 오류 문구 / "등록된 조직/사용자가 없습니다." |
|
||||
| 검색 결과 없음 | 필터 후 매칭 노드·사용자 0건. "조회된 인원이 없습니다." 문구 노출 |
|
||||
|
||||
### 4-2. 조직도 데이터 매핑
|
||||
|
||||
| UI 표시 | 트리 DTO 필드 (`OgnzUserTreeResponseDto`) | 비고 |
|
||||
| --------------- | ----------------------------------------- | ----------------------------------------- |
|
||||
| 조직명 | `ognzNm` | 접기/펼치기 노드 |
|
||||
| 사용자명 | `users[].userNm` | 클릭 시 선택 |
|
||||
| 부서(안내 문구) | `ognzNm` (소속 팀 노드) | 선택 시 `selectedPerson.ognzNm` |
|
||||
| 직급(안내 문구) | `users[].jbgdSeCd` 또는 `jbpsSeCd` | 공통코드 라벨 변환 |
|
||||
| 저장용 조직 SN | `ognzSn` | 사용자가 속한 **리프/팀 노드**의 `ognzSn` |
|
||||
| 저장용 사용자 | `users[].userUnqId` | API body `userUuid`로 전달 (§6-3) |
|
||||
|
||||
### 4-3. 조직도 검색 (영역 A-1)
|
||||
|
||||
결재선 관리 `AprvrPickerDialog`와 동일한 **클라이언트 사이드 필터** 패턴을 사용한다. 별도 검색 API는 없으며, `useOgnzUserTree`로 받은 **원본 트리**를 `filterOgnzUserTree`로 가공해 `displayTree`에 반영한다.
|
||||
|
||||
#### 필드 정의
|
||||
|
||||
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
|
||||
| -------------- | --------- | ---- | ------ | ----------------- | ----------------------------------------- |
|
||||
| 성명·부서 검색 | Text | N | (빈값) | — (클라이언트만) | `placeholder="성명·부서 검색"` |
|
||||
| 조회 | Button | — | — | — | `type="submit"` — 폼 제출 시 필터 적용 |
|
||||
| 초기화 | Button | — | — | — | `type="button"` — 키워드·트리·펼침 초기화 |
|
||||
|
||||
#### 필터 규칙 (`filterOgnzUserTree`)
|
||||
|
||||
| 대상 | 매칭 조건 |
|
||||
| ---------------- | ----------------------------------------------------- |
|
||||
| 조직명 | `ognzNm`에 키워드 포함 (대소문자 무시) |
|
||||
| 사용자명 | `userNm`에 키워드 포함 (대소문자 무시) |
|
||||
| 조직 매칭 시 | 해당 노드 **소속 사용자 전원** 유지 |
|
||||
| 사용자만 매칭 시 | 매칭 사용자만 남기고 상위 조직 노드는 유지(경로 보존) |
|
||||
| 하위 조직 | 재귀 필터 — 자식·손자 노드 매칭 시 상위도 표시 |
|
||||
| 키워드 빈값 | 원본 트리 전체 반환 |
|
||||
|
||||
#### 조회 후 트리 펼침 (`collectExpandedOgnzSnIds`)
|
||||
|
||||
- **[조회]** 성공 후 필터 결과 트리의 **모든 노드** `ognzSn`을 `expandedOgnzSnIds`에 넣어 자동 펼침
|
||||
- 검색 결과에서 사용자·하위 조직을 바로 확인할 수 있도록 함
|
||||
|
||||
#### 초기화 동작
|
||||
|
||||
| 상태 | 초기화 후 값 |
|
||||
| ------------------- | --------------------------------------- |
|
||||
| `keyword` | `""` |
|
||||
| `displayTree` | `useOgnzUserTree` 원본 `data` |
|
||||
| `expandedOgnzSnIds` | `new Set()` (전체 접힘) |
|
||||
| `selectedPerson` | **유지** — 검색 초기화와 선택 해제 분리 |
|
||||
|
||||
#### 버튼
|
||||
|
||||
| 버튼명 | 동작 |
|
||||
| ------ | --------------------------------------------------------- |
|
||||
| 조회 | `filterOgnzUserTree(data, keyword)` → `displayTree` 갱신 |
|
||||
| 초기화 | 키워드·표시 트리·펼침 상태 리셋 (§3-2 `ON [초기화]` 블록) |
|
||||
|
||||
---
|
||||
|
||||
## 5. 영역 B — 인원 배치
|
||||
|
||||
### 5-1. 블록 공통
|
||||
|
||||
| 요소 | 내용 |
|
||||
| ------------------ | ------------------------------------------------- |
|
||||
| 블록 제목 | 접수 / 검토 / 승인 / 최종승인 |
|
||||
| 건수 뱃지 | 해당 `atrzSeComCd`에 배치된 인원 수 |
|
||||
| 액션 버튼 | **[선택 인원 배치]** — 클릭 시 즉시 `save` (§3-2) |
|
||||
| 빈 상태 | "배치된 인원이 없습니다." |
|
||||
| 배치 목록(있을 때) | 인원명·부서·직급 등 |
|
||||
|
||||
### 5-2. 결재구분 ↔ 블록 매핑
|
||||
|
||||
| 블록 | UI 제목 | `atrzSeComCd` | 비고 |
|
||||
| ---- | -------- | ------------- | -------------------------------------------------------------- |
|
||||
| B-1 | 접수 | `ATRZ00` | 결재선 관리 `ATRZ_RECEIVER_CD`와 동일 |
|
||||
| B-2 | 검토 | `ATRZ01` | 결재선 `ATRZ_REVIEWER_1_CD`. `ATRZ02` 별도 블록 여부 — **TBD** |
|
||||
| B-3 | 승인 | `ATRZ03` | 결재선 `ATRZ_APPROVER_CD` |
|
||||
| B-4 | 최종승인 | `ATRZ99` | 결재선 `ATRZ_FINAL_CD` |
|
||||
|
||||
공통코드 마스터 `ATRZSE` / 화면 셀렉트용 `ATRZSECD`와 정합성은 배포 환경 코드표로 최종 확인.
|
||||
|
||||
### 5-3. 배치 목록 항목 (표시)
|
||||
|
||||
| 표시명 | Field (응답 DTO) | 비고 |
|
||||
| -------- | ---------------- | ---------------------------- |
|
||||
| 사용자명 | `userUuidNm` | `@CodeName` 미적용 시 TBD |
|
||||
| 결재구분 | `atrzSeComCdNm` | 블록 제목과 중복 — 생략 가능 |
|
||||
| 조직 SN | `ognzSn` | 삭제 API path에 사용 |
|
||||
| 사용자 | `userUuid` | 삭제 API path에 사용 |
|
||||
|
||||
### 5-4. 버튼
|
||||
|
||||
| 버튼명 | 위치 | 동작 |
|
||||
| -------------- | ----------- | --------------------------------- |
|
||||
| 선택 인원 배치 | 각 B-k 헤더 | `save` 후 목록·뱃지 갱신 (§3-2) |
|
||||
| 제거 | 목록 항목 | `delete` 후 목록·뱃지 갱신 (§3-2) |
|
||||
|
||||
### 5-5. 검증·제약
|
||||
|
||||
| 규칙 | 내용 |
|
||||
| ------------- | ------------------------------------------------------------------- |
|
||||
| 선택 필수 | `[선택 인원 배치]` 시 `selectedPerson` 없으면 버튼 비활성 또는 안내 |
|
||||
| 단일 선택 | 조직도에서 사용자 1명만 `selectedPerson` 유지 |
|
||||
| 중복 등록 | 동일 PK `(atrzSeComCd, ognzSn, userUuid)` 재등록 시 서버 오류 |
|
||||
| 다건 배치 | 동일 블록에 서로 다른 사용자·조직은 **복수 행** 가능 (PK가 다름) |
|
||||
| 결재구분 변경 | 본 화면 주 흐름은 `save`만 |
|
||||
|
||||
---
|
||||
|
||||
## 6. API/DTO 명세
|
||||
|
||||
### 6-1. 공통
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ------------- | ------------------------------------------------------------- |
|
||||
| 인증 | JWT (`Authorization` 헤더) |
|
||||
| 페이징 | Spring `Pageable`: `page`, `size`, `sort` → `PageResponse<T>` |
|
||||
| 프론트 엔티티 | `@/entities/user-organization`, `@/entities/rvw-mnpw` |
|
||||
|
||||
### 6-2. 조직·사용자 트리 (영역 A) — `user-ognz`
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ----------- | ------------------------------- |
|
||||
| 서비스 | `cms-service` |
|
||||
| Base prefix | `/cms-service/api/v1/user-ognz` |
|
||||
|
||||
| 기능 | Method | URL | Request DTO | Response DTO | API |
|
||||
| ---------------- | ------ | ----------- | ----------- | --------------------------- | ---------------------------------- |
|
||||
| 조직·사용자 트리 | GET | `/treeuser` | — | `OgnzUserTreeResponseDto[]` | `userOgnzService.findOgnzUserTree` |
|
||||
|
||||
**`OgnzUserTreeResponseDto`**: `ognzSn`, `upOgnzSn`, `ognzNm`, `users[]`, `children[]`
|
||||
|
||||
**`OgnzUserTreeUserDto`**: `userUnqId`, `userNm`, `jbpsSeCd`, `jbgdSeCd`
|
||||
|
||||
**React Query**: `useOgnzUserTree`
|
||||
|
||||
### 6-3. 검토인력관리 (영역 B) — `rvw-mnpw`
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ----------- | ------------------------------------- |
|
||||
| 서비스 | `as-service` |
|
||||
| Base prefix | `/as-service/api/v1/rvw-mnpw` |
|
||||
| ENDPOINT | `ENDPOINT.AS_COMMON_SERVICE.RVW_MNPW` |
|
||||
|
||||
| 기능 | Method | URL | Request DTO / Query | Response DTO | API |
|
||||
| --------------- | ------ | ------------------------------------ | -------------------------------- | ----------------------------- | ---------------------------- |
|
||||
| 목록 조회 | GET | `/` | `RvwMnpwMngSearchDto` + Pageable | `Page<RvwMnpwMngResponseDto>` | `rvwMnpwMngService.findPage` |
|
||||
| 상세 조회 | GET | `/{atrzSeComCd}/{ognzSn}/{userUuid}` | Path | `RvwMnpwMngResponseDto` | `rvwMnpwMngService.findById` |
|
||||
| 등록(즉시 배치) | POST | `/` | `RvwMnpwMngSaveRequestDto` | `RvwMnpwMngResponseDto` (201) | `rvwMnpwMngService.save` |
|
||||
| 결재구분 변경 | PUT | `/{atrzSeComCd}/{ognzSn}/{userUuid}` | `RvwMnpwMngUpdateRequestDto` | `RvwMnpwMngResponseDto` | `rvwMnpwMngService.update` |
|
||||
| 삭제 | DELETE | `/{atrzSeComCd}/{ognzSn}/{userUuid}` | Path | `string` | `rvwMnpwMngService.delete` |
|
||||
|
||||
**`RvwMnpwMngSearchDto`**: `atrzSeComCd?`, `ognzSn?`, `userUuid?` + `page`, `size`, `sort`
|
||||
|
||||
**`RvwMnpwMngSaveRequestDto`**: `atrzSeComCd*`, `ognzSn*`, `userUuid*`
|
||||
|
||||
**`RvwMnpwMngUpdateRequestDto`**: `newAtrzSeComCd*` — 본 화면 기본 흐름에서는 미사용
|
||||
|
||||
**`RvwMnpwMngResponseDto`**: `atrzSeComCd`, `atrzSeComCdNm?`, `ognzSn`, `userUuid`, `userUuidNm?`, `rgtr?`, `regDt?`
|
||||
|
||||
### 6-4. 조직도 ↔ 검토인력 필드 매핑 (저장 시)
|
||||
|
||||
| 조직도 (`OgnzUserTreeUserDto` + 노드) | `RvwMnpwMngSaveRequestDto` | 비고 |
|
||||
| ------------------------------------- | -------------------------- | --------------------- |
|
||||
| (블록 k의 `atrzSeComCd`) | `atrzSeComCd` | §5-2 표 |
|
||||
| 소속 팀 `ognzSn` | `ognzSn` | |
|
||||
| `userUnqId` | `userUuid` | 문자열(UUID)로 직렬화 |
|
||||
|
||||
---
|
||||
|
||||
## 7. UI 구현 가이드
|
||||
|
||||
### 7-1. 기술 스택·원칙
|
||||
|
||||
- **공통 컴포넌트**: `@common/components` (`Button`, `Badge`, `Card` 또는 섹션 레이아웃)
|
||||
- **데이터**: React Query — `useOgnzUserTree`, `useRvwMnpwMngPage`, `useSaveRvwMnpwMng`, `useDeleteRvwMnpwMng`
|
||||
- **상태**: `selectedPerson`(단일), `keyword`, `displayTree`, `expandedOgnzSnIds`, 블록별 배치 목록(서버 데이터 파생)
|
||||
- **조직도 검색**: `filterOgnzUserTree`, `collectExpandedOgnzSnIds` (`widgets/ognz-user-tree/lib` 또는 페이지 `lib`)
|
||||
- **저장 UX**: 별도 화면 저장 없음 — **[선택 인원 배치]** = `save` mutation 즉시 호출
|
||||
- **조직도 UI**: 결재선 관리 `ognz-user-tree.tsx`를 **widgets**로 승격해 공통화
|
||||
|
||||
### 7-2. 모듈·파일 배치(제안)
|
||||
|
||||
| 구분 | 경로 |
|
||||
| -------- | ---------------------------------------------------------------------------------- |
|
||||
| 라우트 | `apps/as/app/(with-base-layout)/rvw-mnpw-mng/page.tsx` |
|
||||
| 페이지 | `apps/as/src/pages/rvw-mnpw-mng/` |
|
||||
| UI | `.../ui/rvw-mnpw-mng-page.tsx`, `rvw-mnpw-assignment-panel.tsx` 등 |
|
||||
| model | `.../model/selected-ognz-user.ts`, `rvw-mnpw-block-config.ts` (`atrzSeComCd` 상수) |
|
||||
| lib | `.../lib/group-rvw-mnpw-by-atrz-se.ts` (목록 → B-1~B-4 분배) |
|
||||
| **위젯** | `apps/as/src/widgets/ognz-user-tree/` — 아래 이전 대상 |
|
||||
| 엔티티 | `entities/user-organization/`, `entities/rvw-mnpw/` (구현 완료) |
|
||||
|
||||
#### widgets/ognz-user-tree 이전 (결재선 → 공통)
|
||||
|
||||
| 현재 (결재선 관리) | 이전 후 (widgets) |
|
||||
| ------------------------------------------------------ | ------------------------------------------------------------------------------------ |
|
||||
| `pages/approval-line-management/ui/ognz-user-tree.tsx` | `widgets/ognz-user-tree/ui/ognz-user-tree.tsx` |
|
||||
| `pages/.../ui/ognz-user-tree-node.tsx` | `widgets/ognz-user-tree/ui/ognz-user-tree-node.tsx` |
|
||||
| `pages/.../model/reviewer-option.ts` | `widgets/ognz-user-tree/model/selected-ognz-user.ts` (이름·필드 확장: `ognzSn` 필수) |
|
||||
| `pages/.../lib/filter-ognz-user-tree.ts` | `widgets/ognz-user-tree/lib/filter-ognz-user-tree.ts` (**필수** — 검색 공통화) |
|
||||
| `widgets/ognz-user-tree/index.ts` | `OgnzUserTree`, `OgnzUserTreeNode`, 타입 export |
|
||||
|
||||
결재선 `AprvrPickerDialog`는 `@/widgets/ognz-user-tree` import로 교체. 검토인력관리 페이지는 **인라인 조직도**(다이얼로그 없음)로 동일 위젯 사용.
|
||||
|
||||
**`SelectedOgnzUser`** — `ReviewerOption` 확장:
|
||||
|
||||
```typescript
|
||||
export interface SelectedOgnzUser {
|
||||
userUuid: string; // OgnzUserTreeUserDto.userUnqId
|
||||
userNm: string;
|
||||
ognzSn: number;
|
||||
ognzNm: string;
|
||||
jbgdSeCd?: string;
|
||||
jbpsSeCd?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 7-3. 영역별 UI 매핑
|
||||
|
||||
| 영역 | 주요 UI |
|
||||
| ---- | -------------------------------------------------------------------- |
|
||||
| A-1 | 검색 폼 (`Input` + 조회/초기화) — `AprvrPickerDialog` 90~103행 패턴 |
|
||||
| A-2 | `OgnzUserTree(nodes={displayTree})` + 선택 안내 + `useOgnzUserTree` |
|
||||
| B-k | 제목·Badge(count)·`선택 인원 배치`·배치 목록(또는 empty) |
|
||||
| 공통 | 페이지 헤더·브레드크럼·2열 레이아웃 (`grid` / `flex`, 모바일은 스택) |
|
||||
|
||||
### 7-4. 다이얼로그·라우팅
|
||||
|
||||
| 항목 | 내용 |
|
||||
| ---------- | ----------------------------------- |
|
||||
| 다이얼로그 | **없음** (조직도 인라인, 즉시 저장) |
|
||||
| 라우팅 | 메뉴 `검토인력관리` 단일 페이지 |
|
||||
|
||||
### 7-5. 데이터 흐름(구현 요약)
|
||||
|
||||
- **진입**: `useOgnzUserTree` → `displayTree = data` + `useRvwMnpwMngPage({ page: 0, size: N })` → 4패널.
|
||||
- **검색**: `keyword` 로컬 상태 → **[조회]** 시 `filterOgnzUserTree` + `collectExpandedOgnzSnIds` → `OgnzUserTree`에 `displayTree` 전달.
|
||||
- **초기화**: `keyword`·`displayTree`·`expandedOgnzSnIds` 리셋 (`selectedPerson` 유지).
|
||||
- **선택**: 트리 `onSelect` → `selectedPerson` (반드시 `ognzSn` 포함).
|
||||
- **배치**: `useSaveRvwMnpwMng().mutate({ atrzSeComCd, ognzSn, userUuid })` → `onSuccess`에서 `rvw-mnpw` query invalidate.
|
||||
- **삭제**: 목록 항목에서 `useDeleteRvwMnpwMng` — UI 확정 후 연결.
|
||||
|
||||
### 7-6. 미확정 / 검증 필요
|
||||
|
||||
- [ ] `ATRZ02`(검토2) 별도 블록 필요 여부
|
||||
- [ ] 권한(역할별 CRUD)
|
||||
Vendored
-6
@@ -1,6 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference types="next/navigation-types/compat/navigation" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -27,7 +27,7 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.474.0",
|
||||
"next": "15.3.8",
|
||||
"next": "15.5.18",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"tus-js-client": "^4.3.1",
|
||||
"zod": "^3.24.2"
|
||||
|
||||
@@ -2,12 +2,14 @@ import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
|
||||
import type {
|
||||
AcntgSeMngResponseDto,
|
||||
AcntgSeMngSaveRequestDto,
|
||||
AcntgSeMngSearchDto,
|
||||
AcntgSeMngService,
|
||||
AcntgSeMngTreeDto,
|
||||
import {
|
||||
ACNTG_SE_SAVE_MODE,
|
||||
type AcntgSeMngPutRequestDto,
|
||||
type AcntgSeMngResponseDto,
|
||||
type AcntgSeMngSaveRequestDto,
|
||||
type AcntgSeMngSearchDto,
|
||||
type AcntgSeMngService,
|
||||
type AcntgSeMngTreeDto,
|
||||
} from "../model/acntg-interface";
|
||||
|
||||
const BASE = ENDPOINT.AS_COMMON_SERVICE.ACNTG_SE;
|
||||
@@ -27,13 +29,17 @@ async function findById(acntgSeCd: string): Promise<AcntgSeMngResponseDto> {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function save(body: AcntgSeMngSaveRequestDto): Promise<string> {
|
||||
const response = await client.post<string>(BASE, body);
|
||||
async function saveMulti(body: AcntgSeMngSaveRequestDto[]): Promise<AcntgSeMngResponseDto[]> {
|
||||
const response = await client.post<AcntgSeMngResponseDto[]>(BASE, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function update(acntgSeCd: string, body: AcntgSeMngSaveRequestDto): Promise<string> {
|
||||
const response = await client.put<string>(`${BASE}/${acntgSeCd}`, body);
|
||||
async function update(acntgSeCd: string, body: AcntgSeMngPutRequestDto): Promise<string> {
|
||||
const response = await client.put<string>(`${BASE}/${acntgSeCd}`, {
|
||||
...body,
|
||||
acntgSeCd,
|
||||
mode: ACNTG_SE_SAVE_MODE.UPDATE,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -46,7 +52,7 @@ export const acntgSeMngService = {
|
||||
findAll,
|
||||
findTree,
|
||||
findById,
|
||||
save,
|
||||
saveMulti,
|
||||
update,
|
||||
delete: deleteAcntgSeCd,
|
||||
} satisfies AcntgSeMngService;
|
||||
|
||||
@@ -2,7 +2,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type { AcntgSeMngSaveRequestDto, AcntgSeMngSearchDto } from "../model/acntg-interface";
|
||||
import type {
|
||||
AcntgSeMngPutRequestDto,
|
||||
AcntgSeMngSaveRequestDto,
|
||||
AcntgSeMngSearchDto,
|
||||
} from "../model/acntg-interface";
|
||||
|
||||
import { acntgSeMngService } from "./acntg-service";
|
||||
|
||||
@@ -31,11 +35,11 @@ export function useAcntgSeMng(acntgSeCd: string | null | undefined) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveAcntgSeMng() {
|
||||
export function useSaveMultiAcntgSeMng() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (body: AcntgSeMngSaveRequestDto) => acntgSeMngService.save(body),
|
||||
mutationFn: (body: AcntgSeMngSaveRequestDto[]) => acntgSeMngService.saveMulti(body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: acntgSeMngBaseKey });
|
||||
},
|
||||
@@ -46,7 +50,7 @@ export function useUpdateAcntgSeMng() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ acntgSeCd, body }: { acntgSeCd: string; body: AcntgSeMngSaveRequestDto }) =>
|
||||
mutationFn: ({ acntgSeCd, body }: { acntgSeCd: string; body: AcntgSeMngPutRequestDto }) =>
|
||||
acntgSeMngService.update(acntgSeCd, body),
|
||||
onSuccess: (_data, { acntgSeCd }) => {
|
||||
queryClient.invalidateQueries({ queryKey: acntgSeMngBaseKey });
|
||||
|
||||
@@ -4,7 +4,7 @@ export {
|
||||
useAcntgSeMngPage,
|
||||
useAcntgSeMngTree,
|
||||
useDeleteAcntgSeMng,
|
||||
useSaveAcntgSeMng,
|
||||
useSaveMultiAcntgSeMng,
|
||||
useUpdateAcntgSeMng,
|
||||
} from "./api/queries";
|
||||
export type {
|
||||
@@ -13,5 +13,7 @@ export type {
|
||||
AcntgSeMngSearchDto,
|
||||
AcntgSeMngService,
|
||||
AcntgSeMngTreeDto,
|
||||
AcntgSeSaveMode,
|
||||
BizCstTreeDto,
|
||||
} from "./model/acntg-interface";
|
||||
export { ACNTG_SE_SAVE_MODE } from "./model/acntg-interface";
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import type { PageableParams, PageResponse } from "@/shared/model";
|
||||
|
||||
export const ACNTG_SE_SAVE_MODE = {
|
||||
CREATE: "C",
|
||||
UPDATE: "U",
|
||||
DELETE: "D",
|
||||
} as const;
|
||||
|
||||
export type AcntgSeSaveMode = (typeof ACNTG_SE_SAVE_MODE)[keyof typeof ACNTG_SE_SAVE_MODE];
|
||||
|
||||
/** 백엔드 AcntgSeMngResponseDto */
|
||||
export interface AcntgSeMngResponseDto {
|
||||
acntgSeCd: string;
|
||||
@@ -24,6 +32,17 @@ export interface AcntgSeMngSearchDto extends PageableParams {
|
||||
|
||||
/** 백엔드 AcntgSeMngSaveRequestDto */
|
||||
export interface AcntgSeMngSaveRequestDto {
|
||||
acntgSeCd: string;
|
||||
mode: AcntgSeSaveMode;
|
||||
acntgSeCdNm: string;
|
||||
upAcntgSeCd?: string;
|
||||
cdLvNo: number;
|
||||
useYn?: string;
|
||||
expln?: string;
|
||||
}
|
||||
|
||||
/** PUT /{acntgSeCd} 요청 (mode는 서비스에서 U로 설정) */
|
||||
export interface AcntgSeMngPutRequestDto {
|
||||
acntgSeCd: string;
|
||||
acntgSeCdNm: string;
|
||||
upAcntgSeCd?: string;
|
||||
@@ -61,7 +80,7 @@ export interface AcntgSeMngService {
|
||||
findAll(params: AcntgSeMngSearchDto): Promise<PageResponse<AcntgSeMngResponseDto>>;
|
||||
findTree(): Promise<AcntgSeMngTreeDto[]>;
|
||||
findById(acntgSeCd: string): Promise<AcntgSeMngResponseDto>;
|
||||
save(body: AcntgSeMngSaveRequestDto): Promise<string>;
|
||||
update(acntgSeCd: string, body: AcntgSeMngSaveRequestDto): Promise<string>;
|
||||
saveMulti(body: AcntgSeMngSaveRequestDto[]): Promise<AcntgSeMngResponseDto[]>;
|
||||
update(acntgSeCd: string, body: AcntgSeMngPutRequestDto): Promise<string>;
|
||||
delete(acntgSeCd: string): Promise<string>;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface CklstTreeNode {
|
||||
rvwCn: string;
|
||||
bchckUseYn: "Y" | "N";
|
||||
sortSeq: number;
|
||||
// optional: backend may include the taskSeComCd per node
|
||||
taskSeComCd?: string;
|
||||
}
|
||||
|
||||
export interface CklstTreeSearchParams {
|
||||
|
||||
@@ -2,12 +2,14 @@ import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
|
||||
import type {
|
||||
CnstpSeMngResponseDto,
|
||||
CnstpSeMngSaveRequestDto,
|
||||
CnstpSeMngSearchDto,
|
||||
CnstpSeMngService,
|
||||
CnstpSeMngTreeDto,
|
||||
import {
|
||||
CNSTP_SE_SAVE_MODE,
|
||||
type CnstpSeMngPutRequestDto,
|
||||
type CnstpSeMngResponseDto,
|
||||
type CnstpSeMngSaveRequestDto,
|
||||
type CnstpSeMngSearchDto,
|
||||
type CnstpSeMngService,
|
||||
type CnstpSeMngTreeDto,
|
||||
} from "../model/cnstp-interface";
|
||||
|
||||
const BASE = ENDPOINT.AS_COMMON_SERVICE.CNSTP_SE;
|
||||
@@ -27,13 +29,17 @@ async function findById(cnstpSeCd: string): Promise<CnstpSeMngResponseDto> {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function save(body: CnstpSeMngSaveRequestDto): Promise<string> {
|
||||
const response = await client.post<string>(BASE, body);
|
||||
async function saveMulti(body: CnstpSeMngSaveRequestDto[]): Promise<CnstpSeMngResponseDto[]> {
|
||||
const response = await client.post<CnstpSeMngResponseDto[]>(BASE, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function update(cnstpSeCd: string, body: CnstpSeMngSaveRequestDto): Promise<string> {
|
||||
const response = await client.put<string>(`${BASE}/${cnstpSeCd}`, body);
|
||||
async function update(cnstpSeCd: string, body: CnstpSeMngPutRequestDto): Promise<string> {
|
||||
const response = await client.put<string>(`${BASE}/${cnstpSeCd}`, {
|
||||
...body,
|
||||
cnstpSeCd,
|
||||
mode: CNSTP_SE_SAVE_MODE.UPDATE,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -46,7 +52,7 @@ export const cnstpSeMngService = {
|
||||
findAll,
|
||||
findTree,
|
||||
findById,
|
||||
save,
|
||||
saveMulti,
|
||||
update,
|
||||
delete: deleteCnstpSeCd,
|
||||
} satisfies CnstpSeMngService;
|
||||
|
||||
@@ -2,7 +2,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type { CnstpSeMngSaveRequestDto, CnstpSeMngSearchDto } from "../model/cnstp-interface";
|
||||
import type {
|
||||
CnstpSeMngPutRequestDto,
|
||||
CnstpSeMngSaveRequestDto,
|
||||
CnstpSeMngSearchDto,
|
||||
} from "../model/cnstp-interface";
|
||||
|
||||
import { cnstpSeMngService } from "./cnstp-service";
|
||||
|
||||
@@ -31,11 +35,11 @@ export function useCnstpSeMng(cnstpSeCd: string | null | undefined) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveCnstpSeMng() {
|
||||
export function useSaveMultiCnstpSeMng() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (body: CnstpSeMngSaveRequestDto) => cnstpSeMngService.save(body),
|
||||
mutationFn: (body: CnstpSeMngSaveRequestDto[]) => cnstpSeMngService.saveMulti(body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: cnstpSeMngBaseKey });
|
||||
},
|
||||
@@ -46,7 +50,7 @@ export function useUpdateCnstpSeMng() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ cnstpSeCd, body }: { cnstpSeCd: string; body: CnstpSeMngSaveRequestDto }) =>
|
||||
mutationFn: ({ cnstpSeCd, body }: { cnstpSeCd: string; body: CnstpSeMngPutRequestDto }) =>
|
||||
cnstpSeMngService.update(cnstpSeCd, body),
|
||||
onSuccess: (_data, { cnstpSeCd }) => {
|
||||
queryClient.invalidateQueries({ queryKey: cnstpSeMngBaseKey });
|
||||
|
||||
@@ -4,7 +4,7 @@ export {
|
||||
useCnstpSeMngPage,
|
||||
useCnstpSeMngTree,
|
||||
useDeleteCnstpSeMng,
|
||||
useSaveCnstpSeMng,
|
||||
useSaveMultiCnstpSeMng,
|
||||
useUpdateCnstpSeMng,
|
||||
} from "./api/queries";
|
||||
export type {
|
||||
@@ -14,4 +14,6 @@ export type {
|
||||
CnstpSeMngSearchDto,
|
||||
CnstpSeMngService,
|
||||
CnstpSeMngTreeDto,
|
||||
CnstpSeSaveMode,
|
||||
} from "./model/cnstp-interface";
|
||||
export { CNSTP_SE_SAVE_MODE } from "./model/cnstp-interface";
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import type { PageableParams, PageResponse } from "@/shared/model";
|
||||
|
||||
export const CNSTP_SE_SAVE_MODE = {
|
||||
CREATE: "C",
|
||||
UPDATE: "U",
|
||||
DELETE: "D",
|
||||
} as const;
|
||||
|
||||
export type CnstpSeSaveMode = (typeof CNSTP_SE_SAVE_MODE)[keyof typeof CNSTP_SE_SAVE_MODE];
|
||||
|
||||
/** 백엔드 CnstpSeMngResponseDto */
|
||||
export interface CnstpSeMngResponseDto {
|
||||
cnstpSeCd: string;
|
||||
@@ -24,6 +32,17 @@ export interface CnstpSeMngSearchDto extends PageableParams {
|
||||
|
||||
/** 백엔드 CnstpSeMngSaveRequestDto */
|
||||
export interface CnstpSeMngSaveRequestDto {
|
||||
cnstpSeCd: string;
|
||||
mode: CnstpSeSaveMode;
|
||||
cnstpSeCdNm?: string;
|
||||
upCnstpCd?: string;
|
||||
cdLvNo?: number;
|
||||
useYn?: string;
|
||||
expln?: string;
|
||||
}
|
||||
|
||||
/** PUT /{cnstpSeCd} 요청 (mode는 서비스에서 U로 설정) */
|
||||
export interface CnstpSeMngPutRequestDto {
|
||||
cnstpSeCd: string;
|
||||
cnstpSeCdNm?: string;
|
||||
upCnstpCd?: string;
|
||||
@@ -61,7 +80,7 @@ export interface CnstpSeMngService {
|
||||
findAll(params: CnstpSeMngSearchDto): Promise<PageResponse<CnstpSeMngResponseDto>>;
|
||||
findTree(): Promise<CnstpSeMngTreeDto[]>;
|
||||
findById(cnstpSeCd: string): Promise<CnstpSeMngResponseDto>;
|
||||
save(body: CnstpSeMngSaveRequestDto): Promise<string>;
|
||||
update(cnstpSeCd: string, body: CnstpSeMngSaveRequestDto): Promise<string>;
|
||||
saveMulti(body: CnstpSeMngSaveRequestDto[]): Promise<CnstpSeMngResponseDto[]>;
|
||||
update(cnstpSeCd: string, body: CnstpSeMngPutRequestDto): Promise<string>;
|
||||
delete(cnstpSeCd: string): Promise<string>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type {
|
||||
ElaprDmndResponse,
|
||||
ElaprDmndSaveRequest,
|
||||
ElaprDmndService,
|
||||
} from "../model/elapr-dmnd-interface";
|
||||
|
||||
async function create(body: ElaprDmndSaveRequest): Promise<ElaprDmndResponse> {
|
||||
const response = await client.post<ElaprDmndResponse>(ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const ElaprDmndApi: ElaprDmndService = {
|
||||
create,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { client } from "@/shared/api";
|
||||
|
||||
export async function processElaprPrgrs(elaprPrgrsId: string, atrzPrgrsSttsComCd = "ATRZ_STTS_003"): Promise<void> {
|
||||
await client.put(`/as-elapr-service/api/v1/elapr-prgrs-stts/${elaprPrgrsId}/process`, {
|
||||
atrzPrgrsSttsComCd,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export interface ElaprDmndSaveRequest {
|
||||
elaprDocNo: string;
|
||||
elaprTmpltId: string;
|
||||
dmndTtl: string;
|
||||
dmndCn: string;
|
||||
}
|
||||
|
||||
export interface ElaprDmndResponse {
|
||||
elaprDmndId: string;
|
||||
elaprDocNo: string;
|
||||
elaprTmpltId: string;
|
||||
dmndTtl: string;
|
||||
dmndCn: string;
|
||||
atrzSttsComCd: string;
|
||||
rqstrUuid: string;
|
||||
regDt: string;
|
||||
updtDt: string;
|
||||
}
|
||||
|
||||
export interface ElaprDmndService {
|
||||
create(body: ElaprDmndSaveRequest): Promise<ElaprDmndResponse>;
|
||||
}
|
||||
@@ -29,6 +29,13 @@ export class InstlSncnApiService implements InstlSncnMngService {
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
public async techReview(payload: InstlSncnApplySaveRequestDto): Promise<string> {
|
||||
const response = await client.post<string>(
|
||||
`${ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN}/tech-review`,
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const InstlSncnApi = new InstlSncnApiService();
|
||||
|
||||
@@ -8,4 +8,5 @@ export {
|
||||
type InstlSncnMng,
|
||||
type InstlSncnMngSearchParams,
|
||||
type SpringPage,
|
||||
type ElaprDocPrgrsClientResponseDto,
|
||||
} from "./model/instl-sncn-interface";
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface InstlSncnMngSearchParams {
|
||||
}
|
||||
|
||||
export interface InstlSncnMng {
|
||||
gugun: string;
|
||||
sido: string;
|
||||
instlSncnDocNo: string;
|
||||
admdstCd: string;
|
||||
sidoNm?: string;
|
||||
@@ -88,6 +90,7 @@ export interface InstlSncnCklstResultSaveDto {
|
||||
cklstRvwSn: number;
|
||||
rvwStpComCd: string;
|
||||
selected: "Y" | "N" | null;
|
||||
picRvwCn?: string | null;
|
||||
}
|
||||
|
||||
/** 설치인가 신청 - 필수첨부파일 저장 DTO(배열 원소) */
|
||||
@@ -107,13 +110,36 @@ export interface InstlSncnApplySaveRequestDto {
|
||||
|
||||
export type ReviewStepCode = "LOCAL" | "ENV" | "KECO";
|
||||
|
||||
|
||||
|
||||
/** 전자결재 진행상태 (클라이언트 응답용) */
|
||||
export interface ElaprDocPrgrsClientResponseDto {
|
||||
elaprDocNo?: string;
|
||||
elaprDmndId?: string;
|
||||
elaprPrgrsId?: string;
|
||||
atrzSeqNo?: number;
|
||||
atrzSeComCd?: string;
|
||||
atrzSeComNm?: string;
|
||||
aprvrUuid?: string;
|
||||
aprvrUuidNm?: string;
|
||||
atrzPrgrsSttsComCd?: string;
|
||||
atrzPrgrsSttsComNm?: string;
|
||||
rjctRsn?: string | null;
|
||||
atrzDt?: string | null;
|
||||
jbpsSeNm?: string;
|
||||
jbgdSeNm?: string;
|
||||
ognzNm?: string;
|
||||
}
|
||||
|
||||
// det 응답에 전자결재 진행상태 목록을 포함
|
||||
export interface InstlSncnMngDetResponse {
|
||||
detDto: InstlSncnMng;
|
||||
bizSclList: any[];
|
||||
bizCstList: any[];
|
||||
cklstResultMap?: Record<ReviewStepCode, any[]>;
|
||||
cklstResultList?: any[];
|
||||
esntlFileList: any[];
|
||||
etcFileList: any[];
|
||||
elaprDocPrgrsList?: ElaprDocPrgrsClientResponseDto[] | null;
|
||||
}
|
||||
|
||||
export interface InstlSncnMngService {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
import type {
|
||||
RvwMnpwMngSaveRequestDto,
|
||||
RvwMnpwMngSearchDto,
|
||||
RvwMnpwMngUpdateRequestDto,
|
||||
} from "../model/rvw-mnpw-interface";
|
||||
|
||||
import { rvwMnpwMngService } from "./rvw-mnpw-service";
|
||||
|
||||
const rvwMnpwMngBaseKey = [ENDPOINT.AS_COMMON_SERVICE.RVW_MNPW] as const;
|
||||
|
||||
export function useRvwMnpwMngPage(params: RvwMnpwMngSearchDto, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: [...rvwMnpwMngBaseKey, "findPage", params],
|
||||
queryFn: () => rvwMnpwMngService.findPage(params),
|
||||
enabled: options?.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRvwMnpwMng(
|
||||
atrzSeComCd: string | null | undefined,
|
||||
ognzSn: number | null | undefined,
|
||||
userUuid: string | null | undefined,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: [...rvwMnpwMngBaseKey, "findById", atrzSeComCd, ognzSn, userUuid],
|
||||
queryFn: () => rvwMnpwMngService.findById(atrzSeComCd!, ognzSn!, userUuid!),
|
||||
enabled: Boolean(atrzSeComCd && ognzSn != null && userUuid),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveRvwMnpwMng() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (body: RvwMnpwMngSaveRequestDto) => rvwMnpwMngService.save(body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: rvwMnpwMngBaseKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateRvwMnpwMng() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
atrzSeComCd,
|
||||
ognzSn,
|
||||
userUuid,
|
||||
body,
|
||||
}: {
|
||||
atrzSeComCd: string;
|
||||
ognzSn: number;
|
||||
userUuid: string;
|
||||
body: RvwMnpwMngUpdateRequestDto;
|
||||
}) => rvwMnpwMngService.update(atrzSeComCd, ognzSn, userUuid, body),
|
||||
onSuccess: (_data, { atrzSeComCd, ognzSn, userUuid }) => {
|
||||
queryClient.invalidateQueries({ queryKey: rvwMnpwMngBaseKey });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...rvwMnpwMngBaseKey, "findById", atrzSeComCd, ognzSn, userUuid],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteRvwMnpwMng() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
atrzSeComCd,
|
||||
ognzSn,
|
||||
userUuid,
|
||||
}: {
|
||||
atrzSeComCd: string;
|
||||
ognzSn: number;
|
||||
userUuid: string;
|
||||
}) => rvwMnpwMngService.delete(atrzSeComCd, ognzSn, userUuid),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: rvwMnpwMngBaseKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
|
||||
import type {
|
||||
RvwMnpwMngResponseDto,
|
||||
RvwMnpwMngSaveRequestDto,
|
||||
RvwMnpwMngSearchDto,
|
||||
RvwMnpwMngService,
|
||||
RvwMnpwMngUpdateRequestDto,
|
||||
} from "../model/rvw-mnpw-interface";
|
||||
|
||||
const BASE = ENDPOINT.AS_COMMON_SERVICE.RVW_MNPW;
|
||||
|
||||
function pkPath(atrzSeComCd: string, ognzSn: number, userUuid: string): string {
|
||||
return `${BASE}/${atrzSeComCd}/${ognzSn}/${userUuid}`;
|
||||
}
|
||||
|
||||
async function findPage(params: RvwMnpwMngSearchDto): Promise<PageResponse<RvwMnpwMngResponseDto>> {
|
||||
const response = await client.get<PageResponse<RvwMnpwMngResponseDto>>(BASE, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findById(
|
||||
atrzSeComCd: string,
|
||||
ognzSn: number,
|
||||
userUuid: string,
|
||||
): Promise<RvwMnpwMngResponseDto> {
|
||||
const response = await client.get<RvwMnpwMngResponseDto>(pkPath(atrzSeComCd, ognzSn, userUuid));
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function save(body: RvwMnpwMngSaveRequestDto): Promise<RvwMnpwMngResponseDto> {
|
||||
const response = await client.post<RvwMnpwMngResponseDto>(BASE, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function update(
|
||||
atrzSeComCd: string,
|
||||
ognzSn: number,
|
||||
userUuid: string,
|
||||
body: RvwMnpwMngUpdateRequestDto,
|
||||
): Promise<RvwMnpwMngResponseDto> {
|
||||
const response = await client.put<RvwMnpwMngResponseDto>(
|
||||
pkPath(atrzSeComCd, ognzSn, userUuid),
|
||||
body,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function deleteRvwMnpw(
|
||||
atrzSeComCd: string,
|
||||
ognzSn: number,
|
||||
userUuid: string,
|
||||
): Promise<string> {
|
||||
const response = await client.delete<string>(pkPath(atrzSeComCd, ognzSn, userUuid));
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const rvwMnpwMngService = {
|
||||
findPage,
|
||||
findById,
|
||||
save,
|
||||
update,
|
||||
delete: deleteRvwMnpw,
|
||||
} satisfies RvwMnpwMngService;
|
||||
@@ -0,0 +1,15 @@
|
||||
export {
|
||||
useDeleteRvwMnpwMng,
|
||||
useRvwMnpwMng,
|
||||
useRvwMnpwMngPage,
|
||||
useSaveRvwMnpwMng,
|
||||
useUpdateRvwMnpwMng,
|
||||
} from "./api/queries";
|
||||
export { rvwMnpwMngService } from "./api/rvw-mnpw-service";
|
||||
export type {
|
||||
RvwMnpwMngResponseDto,
|
||||
RvwMnpwMngSaveRequestDto,
|
||||
RvwMnpwMngSearchDto,
|
||||
RvwMnpwMngService,
|
||||
RvwMnpwMngUpdateRequestDto,
|
||||
} from "./model/rvw-mnpw-interface";
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { PageableParams, PageResponse } from "@/shared/model";
|
||||
|
||||
/** 백엔드 RvwMnpwMngResponseDto */
|
||||
export interface RvwMnpwMngResponseDto {
|
||||
atrzSeComCd: string;
|
||||
atrzSeComCdNm?: string;
|
||||
ognzSn: number;
|
||||
userUuid: string;
|
||||
userUuidNm?: string;
|
||||
ognzNm?: string;
|
||||
jbpsSeNm?: string;
|
||||
jbgdSeNm?: string;
|
||||
rgtr?: string;
|
||||
regDt?: string;
|
||||
}
|
||||
|
||||
/** 백엔드 RvwMnpwMngSearchDto + Pageable */
|
||||
export interface RvwMnpwMngSearchDto extends PageableParams {
|
||||
atrzSeComCd?: string;
|
||||
ognzSn?: number;
|
||||
userUuid?: string;
|
||||
}
|
||||
|
||||
/** 백엔드 RvwMnpwMngSaveRequestDto */
|
||||
export interface RvwMnpwMngSaveRequestDto {
|
||||
atrzSeComCd: string;
|
||||
ognzSn: number;
|
||||
userUuid: string;
|
||||
}
|
||||
|
||||
/** 백엔드 RvwMnpwMngUpdateRequestDto */
|
||||
export interface RvwMnpwMngUpdateRequestDto {
|
||||
newAtrzSeComCd: string;
|
||||
}
|
||||
|
||||
/** Controller public 메서드명·시그니처와 동일 */
|
||||
export interface RvwMnpwMngService {
|
||||
findPage(params: RvwMnpwMngSearchDto): Promise<PageResponse<RvwMnpwMngResponseDto>>;
|
||||
findById(atrzSeComCd: string, ognzSn: number, userUuid: string): Promise<RvwMnpwMngResponseDto>;
|
||||
save(body: RvwMnpwMngSaveRequestDto): Promise<RvwMnpwMngResponseDto>;
|
||||
update(
|
||||
atrzSeComCd: string,
|
||||
ognzSn: number,
|
||||
userUuid: string,
|
||||
body: RvwMnpwMngUpdateRequestDto,
|
||||
): Promise<RvwMnpwMngResponseDto>;
|
||||
delete(atrzSeComCd: string, ognzSn: number, userUuid: string): Promise<string>;
|
||||
}
|
||||
@@ -4,6 +4,8 @@ export interface OgnzUserTreeUserDto {
|
||||
userNm: string;
|
||||
jbpsSeCd: string;
|
||||
jbgdSeCd: string;
|
||||
jbpsSeNm: string;
|
||||
jbgdSeNm: string;
|
||||
}
|
||||
|
||||
/** OgnzUserTreeResponseDto */
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { AcntgCodeManagementPage } from "./ui/acntg-code-management-page";
|
||||
@@ -0,0 +1,245 @@
|
||||
import type { AcntgSeMngResponseDto } from "@/entities/acntg";
|
||||
|
||||
import type {
|
||||
AcntgDisplaySlot,
|
||||
AcntgSeBaseEntry,
|
||||
AcntgSeGridState,
|
||||
} from "../model/acntg-se-grid-types";
|
||||
import type { AcntgSeRow } from "../model/acntg-se-row-types";
|
||||
import { buildAcntgDisplayRows } from "../model/build-acntg-display-rows";
|
||||
|
||||
export function createLoadedRowKey(index: number, acntgSeCd: string): string {
|
||||
return `loaded-${index}-${acntgSeCd}`;
|
||||
}
|
||||
|
||||
export function createDraftRowKey(): string {
|
||||
return `draft-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
export function createEmptyGridState(): AcntgSeGridState {
|
||||
return {
|
||||
baseByKey: {},
|
||||
structure: [],
|
||||
drafts: {},
|
||||
patches: {},
|
||||
deletedKeys: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
function toInitialRow(entry: AcntgSeBaseEntry): AcntgSeRow {
|
||||
return {
|
||||
...entry,
|
||||
isNew: false,
|
||||
isDirty: false,
|
||||
isDeleted: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildStructureFromBaseRows(
|
||||
baseByKey: Record<string, AcntgSeBaseEntry>,
|
||||
): AcntgDisplaySlot[] {
|
||||
const rows = Object.values(baseByKey).map(toInitialRow);
|
||||
const display = buildAcntgDisplayRows(rows);
|
||||
const cdToRowKey = new Map(rows.map((row) => [row.acntgSeCd, row.rowKey]));
|
||||
|
||||
return display.map(({ row, depth }) => ({
|
||||
rowKey: row.rowKey,
|
||||
depth,
|
||||
parentRowKey: row.upAcntgSeCd?.trim() ? (cdToRowKey.get(row.upAcntgSeCd.trim()) ?? null) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
export function hydrateGridFromServer(content: AcntgSeMngResponseDto[]): AcntgSeGridState {
|
||||
const baseByKey: Record<string, AcntgSeBaseEntry> = {};
|
||||
|
||||
content.forEach((item, index) => {
|
||||
const rowKey = createLoadedRowKey(index, item.acntgSeCd);
|
||||
baseByKey[rowKey] = { ...item, rowKey };
|
||||
});
|
||||
|
||||
return {
|
||||
baseByKey,
|
||||
structure: buildStructureFromBaseRows(baseByKey),
|
||||
drafts: {},
|
||||
patches: {},
|
||||
deletedKeys: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveParentAcntgSeCd(
|
||||
state: AcntgSeGridState,
|
||||
parentRowKey: string | null,
|
||||
): string | undefined {
|
||||
if (!parentRowKey) return undefined;
|
||||
const parent = mergeAcntgRow(state, parentRowKey);
|
||||
const cd = parent?.acntgSeCd?.trim();
|
||||
return cd || undefined;
|
||||
}
|
||||
|
||||
export function mergeAcntgRow(state: AcntgSeGridState, rowKey: string): AcntgSeRow | null {
|
||||
const draft = state.drafts[rowKey];
|
||||
if (draft) {
|
||||
return {
|
||||
rowKey,
|
||||
acntgSeCd: draft.acntgSeCd,
|
||||
acntgSeCdNm: draft.acntgSeCdNm,
|
||||
expln: draft.expln,
|
||||
useYn: draft.useYn,
|
||||
cdLvNo: draft.cdLvNo,
|
||||
upAcntgSeCd: resolveParentAcntgSeCd(state, draft.parentRowKey),
|
||||
isNew: true,
|
||||
isDirty: true,
|
||||
isDeleted: false,
|
||||
};
|
||||
}
|
||||
|
||||
const base = state.baseByKey[rowKey];
|
||||
if (!base) return null;
|
||||
|
||||
const patch = state.patches[rowKey] ?? {};
|
||||
const hasPatch = Object.keys(patch).length > 0;
|
||||
|
||||
return {
|
||||
...base,
|
||||
...patch,
|
||||
rowKey,
|
||||
isNew: false,
|
||||
isDirty: hasPatch,
|
||||
isDeleted: state.deletedKeys.has(rowKey),
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeAllAcntgRows(state: AcntgSeGridState): AcntgSeRow[] {
|
||||
const keys = new Set([...Object.keys(state.baseByKey), ...Object.keys(state.drafts)]);
|
||||
|
||||
return [...keys]
|
||||
.map((rowKey) => mergeAcntgRow(state, rowKey))
|
||||
.filter((row): row is AcntgSeRow => row != null);
|
||||
}
|
||||
|
||||
export function hasAcntgGridPendingChanges(state: AcntgSeGridState): boolean {
|
||||
return (
|
||||
state.deletedKeys.size > 0 ||
|
||||
Object.keys(state.drafts).length > 0 ||
|
||||
Object.keys(state.patches).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function collectDescendantRowKeys(
|
||||
structure: AcntgDisplaySlot[],
|
||||
rootRowKey: string,
|
||||
): Set<string> {
|
||||
const result = new Set<string>([rootRowKey]);
|
||||
const queue = [rootRowKey];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const parentKey = queue.shift()!;
|
||||
for (const slot of structure) {
|
||||
if (slot.parentRowKey === parentKey && !result.has(slot.rowKey)) {
|
||||
result.add(slot.rowKey);
|
||||
queue.push(slot.rowKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function insertRootSlot(
|
||||
structure: AcntgDisplaySlot[],
|
||||
slot: AcntgDisplaySlot,
|
||||
): AcntgDisplaySlot[] {
|
||||
const firstRootIndex = structure.findIndex((item) => item.parentRowKey === null);
|
||||
if (firstRootIndex === -1) return [slot, ...structure];
|
||||
return [...structure.slice(0, firstRootIndex), slot, ...structure.slice(firstRootIndex)];
|
||||
}
|
||||
|
||||
export function insertChildSlot(
|
||||
structure: AcntgDisplaySlot[],
|
||||
parentRowKey: string,
|
||||
slot: AcntgDisplaySlot,
|
||||
): AcntgDisplaySlot[] {
|
||||
const parentIndex = structure.findIndex((item) => item.rowKey === parentRowKey);
|
||||
if (parentIndex === -1) return [...structure, slot];
|
||||
|
||||
const insertAt = parentIndex + 1;
|
||||
return [...structure.slice(0, insertAt), slot, ...structure.slice(insertAt)];
|
||||
}
|
||||
|
||||
export function removeSlotsFromStructure(
|
||||
structure: AcntgDisplaySlot[],
|
||||
rowKeysToRemove: Set<string>,
|
||||
): AcntgDisplaySlot[] {
|
||||
return structure.filter((slot) => !rowKeysToRemove.has(slot.rowKey));
|
||||
}
|
||||
|
||||
export function patchAcntgGridRow(
|
||||
state: AcntgSeGridState,
|
||||
rowKey: string,
|
||||
patch: Partial<AcntgSeRow>,
|
||||
): AcntgSeGridState {
|
||||
const draft = state.drafts[rowKey];
|
||||
if (draft) {
|
||||
return {
|
||||
...state,
|
||||
drafts: {
|
||||
...state.drafts,
|
||||
[rowKey]: {
|
||||
...draft,
|
||||
...(patch.acntgSeCd !== undefined ? { acntgSeCd: patch.acntgSeCd } : {}),
|
||||
...(patch.acntgSeCdNm !== undefined ? { acntgSeCdNm: patch.acntgSeCdNm ?? "" } : {}),
|
||||
...(patch.expln !== undefined ? { expln: patch.expln ?? "" } : {}),
|
||||
...(patch.useYn !== undefined ? { useYn: patch.useYn } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { rowKey: _rk, isNew: _n, isDirty: _d, isDeleted: _del, ...dtoPatch } = patch;
|
||||
|
||||
return {
|
||||
...state,
|
||||
patches: {
|
||||
...state.patches,
|
||||
[rowKey]: { ...state.patches[rowKey], ...dtoPatch },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function markAcntgRowsDeleted(
|
||||
state: AcntgSeGridState,
|
||||
rootRowKey: string,
|
||||
): AcntgSeGridState {
|
||||
const keysToDelete = collectDescendantRowKeys(state.structure, rootRowKey);
|
||||
const deletedKeys = new Set(state.deletedKeys);
|
||||
for (const key of keysToDelete) {
|
||||
deletedKeys.add(key);
|
||||
}
|
||||
return { ...state, deletedKeys };
|
||||
}
|
||||
|
||||
export function validateDraftParentAcntgSeCd(
|
||||
state: AcntgSeGridState,
|
||||
rowKey: string,
|
||||
): string | null {
|
||||
const draft = state.drafts[rowKey];
|
||||
if (!draft?.parentRowKey) return null;
|
||||
|
||||
const parent = mergeAcntgRow(state, draft.parentRowKey);
|
||||
if (!parent?.acntgSeCd?.trim()) {
|
||||
return "하위 항목을 추가하려면 상위 코드를 먼저 입력하세요.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function removeDraftFromGrid(state: AcntgSeGridState, rowKey: string): AcntgSeGridState {
|
||||
const { [rowKey]: _removed, ...restDrafts } = state.drafts;
|
||||
const keysToRemove = collectDescendantRowKeys(state.structure, rowKey);
|
||||
|
||||
return {
|
||||
...state,
|
||||
drafts: restDrafts,
|
||||
structure: removeSlotsFromStructure(state.structure, keysToRemove),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ACNTG_SE_SAVE_MODE, type AcntgSeMngSaveRequestDto } from "@/entities/acntg";
|
||||
|
||||
import type { AcntgSeRow } from "../model/acntg-se-row-types";
|
||||
|
||||
import { getVisibleRows } from "./row-state";
|
||||
|
||||
function compareCdLvNoDesc(a: AcntgSeRow, b: AcntgSeRow): number {
|
||||
return (b.cdLvNo ?? 1) - (a.cdLvNo ?? 1);
|
||||
}
|
||||
|
||||
function compareCdLvNoAsc(a: AcntgSeRow, b: AcntgSeRow): number {
|
||||
return (a.cdLvNo ?? 1) - (b.cdLvNo ?? 1);
|
||||
}
|
||||
|
||||
export function buildSaveMultiPayload(rows: AcntgSeRow[]): AcntgSeMngSaveRequestDto[] {
|
||||
const payload: AcntgSeMngSaveRequestDto[] = [];
|
||||
|
||||
const deletedRows = rows.filter((row) => row.isDeleted && !row.isNew).sort(compareCdLvNoDesc);
|
||||
|
||||
for (const row of deletedRows) {
|
||||
payload.push({
|
||||
mode: ACNTG_SE_SAVE_MODE.DELETE,
|
||||
acntgSeCd: row.acntgSeCd,
|
||||
acntgSeCdNm: row.acntgSeCdNm?.trim() || "",
|
||||
upAcntgSeCd: row.upAcntgSeCd,
|
||||
cdLvNo: row.cdLvNo,
|
||||
useYn: row.useYn,
|
||||
expln: row.expln?.trim() || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const visibleRows = getVisibleRows(rows).sort(compareCdLvNoAsc);
|
||||
|
||||
for (const row of visibleRows) {
|
||||
if (row.isNew) {
|
||||
payload.push({
|
||||
mode: ACNTG_SE_SAVE_MODE.CREATE,
|
||||
acntgSeCd: row.acntgSeCd.trim(),
|
||||
acntgSeCdNm: row.acntgSeCdNm.trim(),
|
||||
upAcntgSeCd: row.upAcntgSeCd,
|
||||
cdLvNo: row.cdLvNo,
|
||||
useYn: row.useYn,
|
||||
expln: row.expln?.trim() || undefined,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (row.isDirty) {
|
||||
payload.push({
|
||||
mode: ACNTG_SE_SAVE_MODE.UPDATE,
|
||||
acntgSeCd: row.acntgSeCd.trim(),
|
||||
acntgSeCdNm: row.acntgSeCdNm.trim(),
|
||||
upAcntgSeCd: row.upAcntgSeCd,
|
||||
cdLvNo: row.cdLvNo,
|
||||
useYn: row.useYn,
|
||||
expln: row.expln?.trim() || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { AcntgSeRow } from "../model/acntg-se-row-types";
|
||||
|
||||
export function getVisibleRows(rows: AcntgSeRow[]): AcntgSeRow[] {
|
||||
return rows.filter((row) => !row.isDeleted);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { AcntgSeMngResponseDto } from "@/entities/acntg";
|
||||
|
||||
import type { AcntgSeGridState } from "../model/acntg-se-grid-types";
|
||||
import type { AcntgSeRow } from "../model/acntg-se-row-types";
|
||||
|
||||
import {
|
||||
createDraftRowKey,
|
||||
createEmptyGridState,
|
||||
hasAcntgGridPendingChanges,
|
||||
hydrateGridFromServer,
|
||||
insertChildSlot,
|
||||
insertRootSlot,
|
||||
markAcntgRowsDeleted,
|
||||
mergeAcntgRow,
|
||||
mergeAllAcntgRows,
|
||||
patchAcntgGridRow,
|
||||
removeDraftFromGrid,
|
||||
validateDraftParentAcntgSeCd,
|
||||
} from "./acntg-se-grid";
|
||||
import { buildSaveMultiPayload } from "./build-save-multi-payload";
|
||||
|
||||
function validateAcntgSeRow(row: AcntgSeRow): string | null {
|
||||
if (!row.acntgSeCd.trim()) return "코드를 입력하세요.";
|
||||
if (!row.acntgSeCdNm?.trim()) return "코드명을 입력하세요.";
|
||||
const parentCd = row.upAcntgSeCd?.trim();
|
||||
if (parentCd && row.acntgSeCd.trim() === parentCd) {
|
||||
return "상위 코드와 동일한 코드는 사용할 수 없습니다.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface UseAcntgSeRowsOptions {
|
||||
serverContent: AcntgSeMngResponseDto[] | undefined;
|
||||
onAlert: (message: string) => void;
|
||||
onSave: (payload: ReturnType<typeof buildSaveMultiPayload>) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useAcntgSeRows({ serverContent, onAlert, onSave }: UseAcntgSeRowsOptions) {
|
||||
const [grid, setGrid] = useState<AcntgSeGridState>(createEmptyGridState);
|
||||
const hasPendingRef = useRef(false);
|
||||
|
||||
hasPendingRef.current = hasAcntgGridPendingChanges(grid);
|
||||
|
||||
useEffect(() => {
|
||||
if (serverContent == null) return;
|
||||
if (hasPendingRef.current) return;
|
||||
setGrid(hydrateGridFromServer(serverContent));
|
||||
}, [serverContent]);
|
||||
|
||||
const displayRows = useMemo(() => {
|
||||
return grid.structure
|
||||
.filter((slot) => !grid.deletedKeys.has(slot.rowKey))
|
||||
.map((slot) => {
|
||||
const row = mergeAcntgRow(grid, slot.rowKey);
|
||||
return row ? { row, depth: slot.depth } : null;
|
||||
})
|
||||
.filter((item): item is { row: AcntgSeRow; depth: number } => item != null);
|
||||
}, [grid]);
|
||||
|
||||
const canSave = hasAcntgGridPendingChanges(grid);
|
||||
|
||||
const updateRow = useCallback((rowKey: string, patch: Partial<AcntgSeRow>) => {
|
||||
setGrid((prev) => patchAcntgGridRow(prev, rowKey, patch));
|
||||
}, []);
|
||||
|
||||
const addRootRow = useCallback(() => {
|
||||
const rowKey = createDraftRowKey();
|
||||
setGrid((prev) => ({
|
||||
...prev,
|
||||
drafts: {
|
||||
...prev.drafts,
|
||||
[rowKey]: {
|
||||
rowKey,
|
||||
parentRowKey: null,
|
||||
cdLvNo: 1,
|
||||
acntgSeCd: "",
|
||||
acntgSeCdNm: "",
|
||||
expln: "",
|
||||
useYn: "Y",
|
||||
},
|
||||
},
|
||||
structure: insertRootSlot(prev.structure, { rowKey, depth: 0, parentRowKey: null }),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const addChildRow = useCallback((parent: AcntgSeRow) => {
|
||||
const rowKey = createDraftRowKey();
|
||||
const slot = {
|
||||
rowKey,
|
||||
depth: 0,
|
||||
parentRowKey: parent.rowKey,
|
||||
};
|
||||
|
||||
setGrid((prev) => {
|
||||
const parentSlot = prev.structure.find((item) => item.rowKey === parent.rowKey);
|
||||
const depth = (parentSlot?.depth ?? 0) + 1;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
drafts: {
|
||||
...prev.drafts,
|
||||
[rowKey]: {
|
||||
rowKey,
|
||||
parentRowKey: parent.rowKey,
|
||||
cdLvNo: (parent.cdLvNo ?? 1) + 1,
|
||||
acntgSeCd: "",
|
||||
acntgSeCdNm: "",
|
||||
expln: "",
|
||||
useYn: "Y",
|
||||
},
|
||||
},
|
||||
structure: insertChildSlot(prev.structure, parent.rowKey, {
|
||||
...slot,
|
||||
depth,
|
||||
}),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeDraft = useCallback((rowKey: string) => {
|
||||
setGrid((prev) => removeDraftFromGrid(prev, rowKey));
|
||||
}, []);
|
||||
|
||||
const markDeleted = useCallback((rowKey: string) => {
|
||||
setGrid((prev) => markAcntgRowsDeleted(prev, rowKey));
|
||||
}, []);
|
||||
|
||||
const resetFromServer = useCallback((content: AcntgSeMngResponseDto[]) => {
|
||||
setGrid(hydrateGridFromServer(content));
|
||||
}, []);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
const mergedRows = mergeAllAcntgRows(grid);
|
||||
const payload = buildSaveMultiPayload(mergedRows);
|
||||
if (payload.length === 0) return false;
|
||||
|
||||
for (const row of mergedRows) {
|
||||
if (!row.isDeleted && (row.isNew || row.isDirty)) {
|
||||
const parentCodeError = validateDraftParentAcntgSeCd(grid, row.rowKey);
|
||||
if (parentCodeError) {
|
||||
onAlert(parentCodeError);
|
||||
return false;
|
||||
}
|
||||
|
||||
const fieldError = validateAcntgSeRow(row);
|
||||
if (fieldError) {
|
||||
onAlert(fieldError);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await onSave(payload);
|
||||
return true;
|
||||
}, [grid, onAlert, onSave]);
|
||||
|
||||
return {
|
||||
displayRows,
|
||||
canSave,
|
||||
updateRow,
|
||||
addRootRow,
|
||||
addChildRow,
|
||||
removeDraft,
|
||||
markDeleted,
|
||||
resetFromServer,
|
||||
save,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { AcntgSeMngResponseDto } from "@/entities/acntg";
|
||||
|
||||
export interface AcntgDisplaySlot {
|
||||
rowKey: string;
|
||||
depth: number;
|
||||
parentRowKey: string | null;
|
||||
}
|
||||
|
||||
export interface AcntgSeDraft {
|
||||
rowKey: string;
|
||||
parentRowKey: string | null;
|
||||
cdLvNo: number;
|
||||
acntgSeCd: string;
|
||||
acntgSeCdNm: string;
|
||||
expln: string;
|
||||
useYn: string;
|
||||
}
|
||||
|
||||
export interface AcntgSeBaseEntry extends AcntgSeMngResponseDto {
|
||||
rowKey: string;
|
||||
}
|
||||
|
||||
export interface AcntgSeGridState {
|
||||
baseByKey: Record<string, AcntgSeBaseEntry>;
|
||||
structure: AcntgDisplaySlot[];
|
||||
drafts: Record<string, AcntgSeDraft>;
|
||||
patches: Record<string, Partial<AcntgSeMngResponseDto>>;
|
||||
deletedKeys: Set<string>;
|
||||
}
|
||||
|
||||
export interface AcntgSeDisplayItem {
|
||||
rowKey: string;
|
||||
depth: number;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { AcntgSeMngResponseDto } from "@/entities/acntg";
|
||||
|
||||
export interface AcntgSeRow extends AcntgSeMngResponseDto {
|
||||
rowKey: string;
|
||||
isNew: boolean;
|
||||
isDirty: boolean;
|
||||
isDeleted: boolean;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { AcntgSeRow } from "./acntg-se-row-types";
|
||||
|
||||
export interface AcntgSeDisplayRow {
|
||||
row: AcntgSeRow;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
export function formatCdLvNo(lvNo?: number): string {
|
||||
return `${lvNo ?? 1}단계`;
|
||||
}
|
||||
|
||||
export function canAddChildRow(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function formatTreeBranchPrefix(depth: number): string {
|
||||
if (depth <= 0) return "";
|
||||
const vertical = "\u2502 ";
|
||||
const branch = "\u251C\u2500";
|
||||
return vertical.repeat(depth - 1) + branch;
|
||||
}
|
||||
|
||||
function isRootRow(row: AcntgSeRow): boolean {
|
||||
return (row.cdLvNo ?? 1) === 1 && !row.upAcntgSeCd?.trim();
|
||||
}
|
||||
|
||||
function rowOrderIndex(rows: AcntgSeRow[], row: AcntgSeRow): number {
|
||||
return rows.findIndex((candidate) => candidate.rowKey === row.rowKey);
|
||||
}
|
||||
|
||||
function pushDisplayRow(
|
||||
result: AcntgSeDisplayRow[],
|
||||
displayedRowKeys: Set<string>,
|
||||
row: AcntgSeRow,
|
||||
depth: number,
|
||||
) {
|
||||
if (displayedRowKeys.has(row.rowKey)) return;
|
||||
displayedRowKeys.add(row.rowKey);
|
||||
result.push({ row, depth });
|
||||
}
|
||||
|
||||
export function buildAcntgDisplayRows(rows: AcntgSeRow[]): AcntgSeDisplayRow[] {
|
||||
const result: AcntgSeDisplayRow[] = [];
|
||||
const displayedRowKeys = new Set<string>();
|
||||
|
||||
const roots = rows.filter(isRootRow);
|
||||
|
||||
function appendSubtree(parent: AcntgSeRow, depth: number) {
|
||||
const children = rows
|
||||
.filter(
|
||||
(row) =>
|
||||
row.rowKey !== parent.rowKey &&
|
||||
row.upAcntgSeCd != null &&
|
||||
row.upAcntgSeCd === parent.acntgSeCd &&
|
||||
row.acntgSeCd !== parent.acntgSeCd,
|
||||
)
|
||||
.sort((a, b) => rowOrderIndex(rows, a) - rowOrderIndex(rows, b));
|
||||
|
||||
for (const child of children) {
|
||||
pushDisplayRow(result, displayedRowKeys, child, depth);
|
||||
appendSubtree(child, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const root of roots) {
|
||||
pushDisplayRow(result, displayedRowKeys, root, 0);
|
||||
appendSubtree(root, 1);
|
||||
}
|
||||
|
||||
const orphans = rows.filter((row) => !displayedRowKeys.has(row.rowKey));
|
||||
|
||||
for (const orphan of orphans) {
|
||||
pushDisplayRow(result, displayedRowKeys, orphan, Math.max(0, (orphan.cdLvNo ?? 1) - 1));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { useAcntgSeMngPage, useSaveMultiAcntgSeMng } from "@/entities/acntg";
|
||||
import {
|
||||
Button,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import { useAcntgSeRows } from "../lib/use-acntg-se-rows";
|
||||
import type { AcntgSeRow } from "../model/acntg-se-row-types";
|
||||
|
||||
import { AcntgSeRowComponent } from "./acntg-se-row";
|
||||
|
||||
function AcntgCodeManagementPage() {
|
||||
const { confirm } = useConfirm();
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
|
||||
const pageQuery = useAcntgSeMngPage({
|
||||
page: 0,
|
||||
size: 500,
|
||||
sort: "cdLvNo,asc",
|
||||
});
|
||||
|
||||
const saveMultiMutation = useSaveMultiAcntgSeMng();
|
||||
|
||||
const handlePersist = useCallback(
|
||||
async (payload: Parameters<typeof saveMultiMutation.mutateAsync>[0]) => {
|
||||
await saveMultiMutation.mutateAsync(payload);
|
||||
},
|
||||
[saveMultiMutation],
|
||||
);
|
||||
|
||||
const {
|
||||
displayRows,
|
||||
canSave,
|
||||
updateRow,
|
||||
addRootRow,
|
||||
addChildRow,
|
||||
removeDraft,
|
||||
markDeleted,
|
||||
resetFromServer,
|
||||
save,
|
||||
} = useAcntgSeRows({
|
||||
serverContent: pageQuery.data?.content,
|
||||
onAlert: alert,
|
||||
onSave: handlePersist,
|
||||
});
|
||||
|
||||
const handleDelete = async (row: AcntgSeRow) => {
|
||||
if (!(await confirm("삭제하시겠습니까?"))) return;
|
||||
|
||||
if (row.isNew) {
|
||||
removeDraft(row.rowKey);
|
||||
return;
|
||||
}
|
||||
|
||||
markDeleted(row.rowKey);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!(await confirm("저장하시겠습니까?"))) return;
|
||||
|
||||
const saved = await save();
|
||||
if (!saved) return;
|
||||
|
||||
alert("저장되었습니다.");
|
||||
const result = await pageQuery.refetch();
|
||||
resetFromServer(result.data?.content ?? []);
|
||||
};
|
||||
|
||||
const isSaving = saveMultiMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-[1.8rem] pb-6">
|
||||
<section className="card card-border mx-0 bg-background pb-6 pt-0">
|
||||
<div className="card-header flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="text-2xl font-semibold">회계코드 관리</h3>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button type="button" color="green" onClick={addRootRow}>
|
||||
코드 추가
|
||||
</Button>
|
||||
<Button type="button" disabled={!canSave || isSaving} onClick={handleSave}>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pageQuery.isPending ? (
|
||||
<div className="flex flex-col gap-2 px-4 pb-4">
|
||||
<Skeleton className="h-[4rem]" />
|
||||
<Skeleton className="h-[16rem]" />
|
||||
</div>
|
||||
) : displayRows.length === 0 ? (
|
||||
<p className="py-8 text-center text-lg text-muted-foreground">
|
||||
등록된 코드가 없습니다. [코드 추가]로 1단계 코드를 등록하세요.
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-[60rem] overflow-y-auto pt-3">
|
||||
<Table className="w-full">
|
||||
<colgroup>
|
||||
<col width="15%" />
|
||||
<col width="20%" />
|
||||
<col width="35%" />
|
||||
<col width="12%" />
|
||||
<col width="8%" />
|
||||
<col width="10%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>코드</TableHead>
|
||||
<TableHead>코드명</TableHead>
|
||||
<TableHead>설명</TableHead>
|
||||
<TableHead>사용 여부</TableHead>
|
||||
<TableHead>구분</TableHead>
|
||||
<TableHead>관리</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{displayRows.map(({ row, depth }) => (
|
||||
<AcntgSeRowComponent
|
||||
key={row.rowKey}
|
||||
row={row}
|
||||
depth={depth}
|
||||
onChange={(patch) => updateRow(row.rowKey, patch)}
|
||||
onAddChild={() => addChildRow(row)}
|
||||
onDelete={() => handleDelete(row)}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { AcntgCodeManagementPage };
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
TableCell,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import type { AcntgSeRow } from "../model/acntg-se-row-types";
|
||||
import {
|
||||
canAddChildRow,
|
||||
formatCdLvNo,
|
||||
formatTreeBranchPrefix,
|
||||
} from "../model/build-acntg-display-rows";
|
||||
|
||||
interface AcntgSeRowProps {
|
||||
row: AcntgSeRow;
|
||||
depth: number;
|
||||
onChange: (patch: Partial<AcntgSeRow>) => void;
|
||||
onAddChild: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function AcntgSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: AcntgSeRowProps) {
|
||||
const levelLabel = formatCdLvNo(row.cdLvNo);
|
||||
const branchPrefix = formatTreeBranchPrefix(depth);
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className="flex min-w-[10rem] items-center gap-1">
|
||||
{branchPrefix ? (
|
||||
<span className="shrink-0 font-mono text-muted">{branchPrefix}</span>
|
||||
) : null}
|
||||
<Input
|
||||
value={row.acntgSeCd}
|
||||
onChange={(e) => onChange({ acntgSeCd: e.target.value })}
|
||||
placeholder="코드"
|
||||
maxLength={10}
|
||||
disabled={!row.isNew}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={row.acntgSeCdNm ?? ""}
|
||||
onChange={(e) => onChange({ acntgSeCdNm: e.target.value })}
|
||||
placeholder="코드명"
|
||||
maxLength={100}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={row.expln ?? ""}
|
||||
onChange={(e) => onChange({ expln: e.target.value })}
|
||||
placeholder="설명"
|
||||
maxLength={1000}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<RadioGroup
|
||||
value={row.useYn === "N" ? "N" : "Y"}
|
||||
onValueChange={(v) => onChange({ useYn: v })}
|
||||
className="flex justify-center gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="Y" id={`${row.rowKey}-use-y`} />
|
||||
<Label htmlFor={`${row.rowKey}-use-y`}>예</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="N" id={`${row.rowKey}-use-n`} />
|
||||
<Label htmlFor={`${row.rowKey}-use-n`}>아니오</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</TableCell>
|
||||
<TableCell align="center">{levelLabel}</TableCell>
|
||||
<TableCell align="center">
|
||||
<div className="flex justify-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="white"
|
||||
disabled={!canAddChildRow()}
|
||||
onClick={onAddChild}
|
||||
>
|
||||
하위추가
|
||||
</Button>
|
||||
<Button type="button" size="sm" color="gray" onClick={onDelete}>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export { AcntgSeRowComponent };
|
||||
@@ -1,10 +1,7 @@
|
||||
import type { ElaprAplnRow } from "../model/apln-row-types";
|
||||
|
||||
export const ATRZ_RECEIVER_CD = "ATRZ00";
|
||||
export const ATRZ_REVIEWER_1_CD = "ATRZ01";
|
||||
export const ATRZ_REVIEWER_2_CD = "ATRZ02";
|
||||
export const ATRZ_APPROVER_CD = "ATRZ03";
|
||||
export const ATRZ_FINAL_CD = "ATRZ99";
|
||||
const ATRZ_RECEIVER_CD = "ATRZ01";
|
||||
const ATRZ_FINAL_CD = "ATRZ99";
|
||||
|
||||
export function getVisibleRows(rows: ElaprAplnRow[]): ElaprAplnRow[] {
|
||||
return rows.filter((row) => !row.isDeleted);
|
||||
|
||||
@@ -35,10 +35,10 @@ export function createTmpltColumns({ onEdit, onDelete }: CreateTmpltColumnsOptio
|
||||
header: "관리",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Button type="button" size="sm" onClick={() => onEdit(row.original)}>
|
||||
<Button type="button" size="sm" color="white" onClick={() => onEdit(row.original)}>
|
||||
수정
|
||||
</Button>
|
||||
<Button type="button" size="sm" color="red" onClick={() => onDelete(row.original)}>
|
||||
<Button type="button" size="sm" color="gray" onClick={() => onDelete(row.original)}>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OgnzUserTreeUserDto } from "@/entities/user-organization";
|
||||
import type { RvwMnpwMngResponseDto } from "@/entities/rvw-mnpw";
|
||||
|
||||
export interface ReviewerOption {
|
||||
uuid: string;
|
||||
@@ -6,10 +6,10 @@ export interface ReviewerOption {
|
||||
deptNm: string;
|
||||
}
|
||||
|
||||
export function toReviewerOption(user: OgnzUserTreeUserDto, ognzNm: string): ReviewerOption {
|
||||
export function fromRvwMnpwMng(item: RvwMnpwMngResponseDto): ReviewerOption {
|
||||
return {
|
||||
uuid: user.userUnqId,
|
||||
name: user.userNm,
|
||||
deptNm: ognzNm,
|
||||
uuid: item.userUuid,
|
||||
name: item.userUuidNm ?? "",
|
||||
deptNm: item.ognzNm ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { useOgnzUserTree } from "@/entities/user-organization";
|
||||
import { useRvwMnpwMngPage } from "@/entities/rvw-mnpw";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
@@ -10,110 +8,109 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Input,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import { collectExpandedOgnzSnIds, filterOgnzUserTree } from "../lib/filter-ognz-user-tree";
|
||||
import type { ReviewerOption } from "../model/reviewer-option";
|
||||
import { fromRvwMnpwMng, type ReviewerOption } from "../model/reviewer-option";
|
||||
|
||||
import { OgnzUserTree } from "./ognz-user-tree";
|
||||
const PAGE_SIZE = 500;
|
||||
|
||||
interface AprvrPickerDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
atrzSeComCd: string;
|
||||
onSelect: (reviewer: ReviewerOption) => void;
|
||||
}
|
||||
|
||||
function AprvrPickerDialog({ open, onOpenChange, onSelect }: AprvrPickerDialogProps) {
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [displayTree, setDisplayTree] = useState<ReturnType<typeof filterOgnzUserTree>>([]);
|
||||
const [expandedOgnzSnIds, setExpandedOgnzSnIds] = useState<Set<number>>(new Set());
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>();
|
||||
function AprvrPickerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
atrzSeComCd,
|
||||
onSelect,
|
||||
}: AprvrPickerDialogProps) {
|
||||
const { data, isPending, isError } = useRvwMnpwMngPage(
|
||||
{
|
||||
atrzSeComCd,
|
||||
page: 0,
|
||||
size: PAGE_SIZE,
|
||||
sort: "userUuidNm,asc",
|
||||
},
|
||||
{ enabled: open },
|
||||
);
|
||||
|
||||
const { data, isPending, isError } = useOgnzUserTree({ enabled: open });
|
||||
const items = data?.content ?? [];
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setKeyword("");
|
||||
setSelectedUserId(undefined);
|
||||
setExpandedOgnzSnIds(new Set());
|
||||
setDisplayTree(data ?? []);
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setKeyword("");
|
||||
setSelectedUserId(undefined);
|
||||
setExpandedOgnzSnIds(new Set());
|
||||
return;
|
||||
}
|
||||
|
||||
setDisplayTree(data ?? []);
|
||||
}, [open, data]);
|
||||
|
||||
const handleToggle = (ognzSn: number, isExpanded: boolean) => {
|
||||
setExpandedOgnzSnIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (isExpanded) next.add(ognzSn);
|
||||
else next.delete(ognzSn);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const filtered = filterOgnzUserTree(data ?? [], keyword);
|
||||
setDisplayTree(filtered);
|
||||
setExpandedOgnzSnIds(collectExpandedOgnzSnIds(filtered));
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
resetState();
|
||||
};
|
||||
|
||||
const handleSelect = (reviewer: ReviewerOption) => {
|
||||
setSelectedUserId(reviewer.uuid);
|
||||
onSelect(reviewer);
|
||||
const handleSelect = (item: (typeof items)[number]) => {
|
||||
onSelect(fromRvwMnpwMng(item));
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[30rem]" aria-describedby={undefined}>
|
||||
<DialogContent className="max-w-[56rem]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>검토인력 선택</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="card card-border !m-0 w-full">
|
||||
<form onSubmit={handleSubmit} className="flex items-center gap-2">
|
||||
<Input
|
||||
className="h-[2.5rem] !text-[1.3rem]"
|
||||
placeholder="성명·부서 검색"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
/>
|
||||
<Button type="submit" size="sm">
|
||||
조회
|
||||
</Button>
|
||||
<Button type="button" size="sm" color="white" onClick={handleReset}>
|
||||
초기화
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="h-[36rem] overflow-auto">
|
||||
<OgnzUserTree
|
||||
nodes={displayTree}
|
||||
isPending={isPending}
|
||||
isError={isError}
|
||||
expandedOgnzSnIds={expandedOgnzSnIds}
|
||||
selectedUserId={selectedUserId}
|
||||
onToggle={handleToggle}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-[36rem] overflow-auto">
|
||||
{isPending ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-[4rem]" />
|
||||
<Skeleton className="h-[16rem]" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<p className="py-8 text-center text-lg text-muted-foreground">
|
||||
검토인력 목록을 불러오지 못했습니다.
|
||||
</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="py-8 text-center text-lg text-muted-foreground">
|
||||
배치된 검토인력이 없습니다.
|
||||
</p>
|
||||
) : (
|
||||
<Table className="w-full">
|
||||
<colgroup>
|
||||
<col width="25%" />
|
||||
<col width="25%" />
|
||||
<col width="15%" />
|
||||
<col width="15%" />
|
||||
<col width="20%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>성명</TableHead>
|
||||
<TableHead>부서</TableHead>
|
||||
<TableHead>직위</TableHead>
|
||||
<TableHead>직급</TableHead>
|
||||
<TableHead>선택</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow
|
||||
key={`${item.atrzSeComCd}-${item.ognzSn}-${item.userUuid}`}
|
||||
>
|
||||
<TableCell align="center">
|
||||
{item.userUuidNm || item.userUuid}
|
||||
</TableCell>
|
||||
<TableCell align="center">{item.ognzNm || "-"}</TableCell>
|
||||
<TableCell align="center">{item.jbpsSeNm || "-"}</TableCell>
|
||||
<TableCell align="center">{item.jbgdSeNm || "-"}</TableCell>
|
||||
<TableCell align="center">
|
||||
<Button type="button" size="sm" onClick={() => handleSelect(item)}>
|
||||
선택
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -75,6 +75,7 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
|
||||
const isDisabled = !elaprTmpltId;
|
||||
const serverRows = aplnQuery.data?.content ?? [];
|
||||
const canSave = hasUnsavedChanges(rows, serverRows);
|
||||
const pickingRow = pickingRowKey ? rows.find((r) => r.rowKey === pickingRowKey) : undefined;
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
@@ -151,11 +152,11 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
color="green"
|
||||
disabled={isDisabled || isEditingLocked}
|
||||
onClick={addRow}
|
||||
>
|
||||
+ 결재선 추가
|
||||
결재선 추가
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -236,6 +237,7 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
|
||||
<AprvrPickerDialog
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
atrzSeComCd={pickingRow?.atrzSeComCd ?? ""}
|
||||
onSelect={handleReviewerSelect}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -114,7 +114,7 @@ function ElaprTmpltList({ selectedTmpltId, onSelectTmplt }: ElaprTmpltListProps)
|
||||
<section className="card card-border mx-0 bg-background pt-0">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="text-2xl font-semibold">전자결재선 관리 목록</h3>
|
||||
<Button type="button" onClick={() => setDialogMode("create")}>
|
||||
<Button type="button" color="green" onClick={() => setDialogMode("create")}>
|
||||
서식 등록
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -36,7 +36,7 @@ function SortableAplnRow({
|
||||
onChange,
|
||||
onPickAprvr,
|
||||
}: SortableAplnRowProps) {
|
||||
const { data: atrzSeCodes } = useCodeList("ATRZSECD");
|
||||
const { data: atrzSeCodes } = useCodeList("ATRZSE");
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: row.rowKey,
|
||||
@@ -50,7 +50,7 @@ function SortableAplnRow({
|
||||
};
|
||||
|
||||
const rowDisabled = isLocked && !isEditing;
|
||||
const atrzSeLabel = row.atrzSeComNm ?? findCodeLabel(atrzSeCodes, row.atrzSeComCd);
|
||||
const atrzSeLabel = row.atrzSeComNm || findCodeLabel(atrzSeCodes, row.atrzSeComCd);
|
||||
|
||||
return (
|
||||
<TableRow ref={setNodeRef} style={style} className={isEditing ? "bg-accent/30" : undefined}>
|
||||
@@ -72,7 +72,7 @@ function SortableAplnRow({
|
||||
<TableCell align="center">
|
||||
{isEditing ? (
|
||||
<CodeSelect
|
||||
upCd="ATRZSECD"
|
||||
upCd="ATRZSE"
|
||||
className="min-w-[120px]"
|
||||
value={row.atrzSeComCd ?? ""}
|
||||
onValueChange={(v) =>
|
||||
@@ -111,10 +111,22 @@ function SortableAplnRow({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button type="button" size="sm" disabled={rowDisabled} onClick={onStartEdit}>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="white"
|
||||
disabled={rowDisabled}
|
||||
onClick={onStartEdit}
|
||||
>
|
||||
수정
|
||||
</Button>
|
||||
<Button type="button" size="sm" color="red" disabled={rowDisabled} onClick={onDelete}>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="gray"
|
||||
disabled={rowDisabled}
|
||||
onClick={onDelete}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</>
|
||||
|
||||
@@ -41,7 +41,7 @@ export function createCklstArtclColumns({ onEdit, onDelete }: CreateCklstArtclCo
|
||||
<Button type="button" size="sm" color="white" onClick={() => onEdit(row.original)}>
|
||||
관리
|
||||
</Button>
|
||||
<Button type="button" size="sm" color="red" onClick={() => onDelete(row.original)}>
|
||||
<Button type="button" size="sm" color="gray" onClick={() => onDelete(row.original)}>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -54,7 +54,7 @@ export function createCklstMngColumns({ onEdit, onDelete }: CreateCklstMngColumn
|
||||
<Button type="button" size="sm" color="white" onClick={() => onEdit(row.original)}>
|
||||
관리
|
||||
</Button>
|
||||
<Button type="button" size="sm" color="red" onClick={() => onDelete(row.original)}>
|
||||
<Button type="button" size="sm" color="gray" onClick={() => onDelete(row.original)}>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -100,7 +100,12 @@ function CklstArtclList({ cklstSn }: CklstArtclListProps) {
|
||||
<section className="card card-border mx-0 bg-background pt-0">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="text-2xl font-semibold">점검항목 관리</h3>
|
||||
<Button type="button" disabled={isDisabled} onClick={() => setDialogMode("create")}>
|
||||
<Button
|
||||
type="button"
|
||||
color="green"
|
||||
disabled={isDisabled}
|
||||
onClick={() => setDialogMode("create")}
|
||||
>
|
||||
점검항목 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -123,7 +123,7 @@ function CklstMngList({ selectedCklstSn, onSelectCklst }: CklstMngListProps) {
|
||||
<section className="card card-border mx-0 bg-background pt-0">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="text-2xl font-semibold">점검표 관리</h3>
|
||||
<Button type="button" onClick={() => setDialogMode("create")}>
|
||||
<Button type="button" color="green" onClick={() => setDialogMode("create")}>
|
||||
점검표 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -248,12 +248,11 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) {
|
||||
>
|
||||
미리보기
|
||||
</Button>
|
||||
<Button type="button" disabled={isDisabled} onClick={addRootRow}>
|
||||
<Button type="button" color="green" disabled={isDisabled} onClick={addRootRow}>
|
||||
체크리스트 추가
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="green"
|
||||
disabled={getDirtyRows().length === 0 || isDisabled || isSaving}
|
||||
onClick={handleSave}
|
||||
>
|
||||
|
||||
@@ -103,7 +103,7 @@ function CklstRvwRowComponent({
|
||||
>
|
||||
하위추가
|
||||
</Button>
|
||||
<Button type="button" size="sm" color="red" onClick={onDelete}>
|
||||
<Button type="button" size="sm" color="gray" onClick={onDelete}>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { CnstpCodeManagementPage } from "./ui/cnstp-code-management-page";
|
||||
@@ -0,0 +1,62 @@
|
||||
import { CNSTP_SE_SAVE_MODE, type CnstpSeMngSaveRequestDto } from "@/entities/cnstp";
|
||||
|
||||
import type { CnstpSeRow } from "../model/cnstp-se-row-types";
|
||||
|
||||
import { getVisibleRows } from "./row-state";
|
||||
|
||||
function compareCdLvNoDesc(a: CnstpSeRow, b: CnstpSeRow): number {
|
||||
return (b.cdLvNo ?? 1) - (a.cdLvNo ?? 1);
|
||||
}
|
||||
|
||||
function compareCdLvNoAsc(a: CnstpSeRow, b: CnstpSeRow): number {
|
||||
return (a.cdLvNo ?? 1) - (b.cdLvNo ?? 1);
|
||||
}
|
||||
|
||||
export function buildSaveMultiPayload(rows: CnstpSeRow[]): CnstpSeMngSaveRequestDto[] {
|
||||
const payload: CnstpSeMngSaveRequestDto[] = [];
|
||||
|
||||
const deletedRows = rows.filter((row) => row.isDeleted && !row.isNew).sort(compareCdLvNoDesc);
|
||||
|
||||
for (const row of deletedRows) {
|
||||
payload.push({
|
||||
mode: CNSTP_SE_SAVE_MODE.DELETE,
|
||||
cnstpSeCd: row.cnstpSeCd,
|
||||
cnstpSeCdNm: row.cnstpSeCdNm?.trim() || undefined,
|
||||
upCnstpCd: row.upCnstpCd,
|
||||
cdLvNo: row.cdLvNo,
|
||||
useYn: row.useYn,
|
||||
expln: row.expln?.trim() || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const visibleRows = getVisibleRows(rows).sort(compareCdLvNoAsc);
|
||||
|
||||
for (const row of visibleRows) {
|
||||
if (row.isNew) {
|
||||
payload.push({
|
||||
mode: CNSTP_SE_SAVE_MODE.CREATE,
|
||||
cnstpSeCd: row.cnstpSeCd.trim(),
|
||||
cnstpSeCdNm: row.cnstpSeCdNm?.trim() || undefined,
|
||||
upCnstpCd: row.upCnstpCd,
|
||||
cdLvNo: row.cdLvNo,
|
||||
useYn: row.useYn,
|
||||
expln: row.expln?.trim() || undefined,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (row.isDirty) {
|
||||
payload.push({
|
||||
mode: CNSTP_SE_SAVE_MODE.UPDATE,
|
||||
cnstpSeCd: row.cnstpSeCd.trim(),
|
||||
cnstpSeCdNm: row.cnstpSeCdNm?.trim() || undefined,
|
||||
upCnstpCd: row.upCnstpCd,
|
||||
cdLvNo: row.cdLvNo,
|
||||
useYn: row.useYn,
|
||||
expln: row.expln?.trim() || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import type { CnstpSeMngResponseDto } from "@/entities/cnstp";
|
||||
|
||||
import { buildCnstpDisplayRows } from "../model/build-cnstp-display-rows";
|
||||
import type {
|
||||
CnstpDisplaySlot,
|
||||
CnstpSeBaseEntry,
|
||||
CnstpSeGridState,
|
||||
} from "../model/cnstp-se-grid-types";
|
||||
import type { CnstpSeRow } from "../model/cnstp-se-row-types";
|
||||
|
||||
export function createLoadedRowKey(index: number, cnstpSeCd: string): string {
|
||||
return `loaded-${index}-${cnstpSeCd}`;
|
||||
}
|
||||
|
||||
export function createDraftRowKey(): string {
|
||||
return `draft-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
export function createEmptyGridState(): CnstpSeGridState {
|
||||
return {
|
||||
baseByKey: {},
|
||||
structure: [],
|
||||
drafts: {},
|
||||
patches: {},
|
||||
deletedKeys: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
function toInitialRow(entry: CnstpSeBaseEntry): CnstpSeRow {
|
||||
return {
|
||||
...entry,
|
||||
isNew: false,
|
||||
isDirty: false,
|
||||
isDeleted: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildStructureFromBaseRows(
|
||||
baseByKey: Record<string, CnstpSeBaseEntry>,
|
||||
): CnstpDisplaySlot[] {
|
||||
const rows = Object.values(baseByKey).map(toInitialRow);
|
||||
const display = buildCnstpDisplayRows(rows);
|
||||
const cdToRowKey = new Map(rows.map((row) => [row.cnstpSeCd, row.rowKey]));
|
||||
|
||||
return display.map(({ row, depth }) => ({
|
||||
rowKey: row.rowKey,
|
||||
depth,
|
||||
parentRowKey: row.upCnstpCd?.trim() ? (cdToRowKey.get(row.upCnstpCd.trim()) ?? null) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
export function hydrateGridFromServer(content: CnstpSeMngResponseDto[]): CnstpSeGridState {
|
||||
const baseByKey: Record<string, CnstpSeBaseEntry> = {};
|
||||
|
||||
content.forEach((item, index) => {
|
||||
const rowKey = createLoadedRowKey(index, item.cnstpSeCd);
|
||||
baseByKey[rowKey] = { ...item, rowKey };
|
||||
});
|
||||
|
||||
return {
|
||||
baseByKey,
|
||||
structure: buildStructureFromBaseRows(baseByKey),
|
||||
drafts: {},
|
||||
patches: {},
|
||||
deletedKeys: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveParentCnstpSeCd(
|
||||
state: CnstpSeGridState,
|
||||
parentRowKey: string | null,
|
||||
): string | undefined {
|
||||
if (!parentRowKey) return undefined;
|
||||
const parent = mergeCnstpRow(state, parentRowKey);
|
||||
const cd = parent?.cnstpSeCd?.trim();
|
||||
return cd || undefined;
|
||||
}
|
||||
|
||||
export function mergeCnstpRow(state: CnstpSeGridState, rowKey: string): CnstpSeRow | null {
|
||||
const draft = state.drafts[rowKey];
|
||||
if (draft) {
|
||||
return {
|
||||
rowKey,
|
||||
cnstpSeCd: draft.cnstpSeCd,
|
||||
cnstpSeCdNm: draft.cnstpSeCdNm,
|
||||
expln: draft.expln,
|
||||
useYn: draft.useYn,
|
||||
cdLvNo: draft.cdLvNo,
|
||||
upCnstpCd: resolveParentCnstpSeCd(state, draft.parentRowKey),
|
||||
isNew: true,
|
||||
isDirty: true,
|
||||
isDeleted: false,
|
||||
};
|
||||
}
|
||||
|
||||
const base = state.baseByKey[rowKey];
|
||||
if (!base) return null;
|
||||
|
||||
const patch = state.patches[rowKey] ?? {};
|
||||
const hasPatch = Object.keys(patch).length > 0;
|
||||
|
||||
return {
|
||||
...base,
|
||||
...patch,
|
||||
rowKey,
|
||||
isNew: false,
|
||||
isDirty: hasPatch,
|
||||
isDeleted: state.deletedKeys.has(rowKey),
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeAllCnstpRows(state: CnstpSeGridState): CnstpSeRow[] {
|
||||
const keys = new Set([...Object.keys(state.baseByKey), ...Object.keys(state.drafts)]);
|
||||
|
||||
return [...keys]
|
||||
.map((rowKey) => mergeCnstpRow(state, rowKey))
|
||||
.filter((row): row is CnstpSeRow => row != null);
|
||||
}
|
||||
|
||||
export function hasCnstpGridPendingChanges(state: CnstpSeGridState): boolean {
|
||||
return (
|
||||
state.deletedKeys.size > 0 ||
|
||||
Object.keys(state.drafts).length > 0 ||
|
||||
Object.keys(state.patches).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function collectDescendantRowKeys(
|
||||
structure: CnstpDisplaySlot[],
|
||||
rootRowKey: string,
|
||||
): Set<string> {
|
||||
const result = new Set<string>([rootRowKey]);
|
||||
const queue = [rootRowKey];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const parentKey = queue.shift()!;
|
||||
for (const slot of structure) {
|
||||
if (slot.parentRowKey === parentKey && !result.has(slot.rowKey)) {
|
||||
result.add(slot.rowKey);
|
||||
queue.push(slot.rowKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function insertRootSlot(
|
||||
structure: CnstpDisplaySlot[],
|
||||
slot: CnstpDisplaySlot,
|
||||
): CnstpDisplaySlot[] {
|
||||
const firstRootIndex = structure.findIndex((item) => item.parentRowKey === null);
|
||||
if (firstRootIndex === -1) return [slot, ...structure];
|
||||
return [...structure.slice(0, firstRootIndex), slot, ...structure.slice(firstRootIndex)];
|
||||
}
|
||||
|
||||
export function insertChildSlot(
|
||||
structure: CnstpDisplaySlot[],
|
||||
parentRowKey: string,
|
||||
slot: CnstpDisplaySlot,
|
||||
): CnstpDisplaySlot[] {
|
||||
const parentIndex = structure.findIndex((item) => item.rowKey === parentRowKey);
|
||||
if (parentIndex === -1) return [...structure, slot];
|
||||
|
||||
const insertAt = parentIndex + 1;
|
||||
return [...structure.slice(0, insertAt), slot, ...structure.slice(insertAt)];
|
||||
}
|
||||
|
||||
export function removeSlotsFromStructure(
|
||||
structure: CnstpDisplaySlot[],
|
||||
rowKeysToRemove: Set<string>,
|
||||
): CnstpDisplaySlot[] {
|
||||
return structure.filter((slot) => !rowKeysToRemove.has(slot.rowKey));
|
||||
}
|
||||
|
||||
export function patchCnstpGridRow(
|
||||
state: CnstpSeGridState,
|
||||
rowKey: string,
|
||||
patch: Partial<CnstpSeRow>,
|
||||
): CnstpSeGridState {
|
||||
const draft = state.drafts[rowKey];
|
||||
if (draft) {
|
||||
return {
|
||||
...state,
|
||||
drafts: {
|
||||
...state.drafts,
|
||||
[rowKey]: {
|
||||
...draft,
|
||||
...(patch.cnstpSeCd !== undefined ? { cnstpSeCd: patch.cnstpSeCd } : {}),
|
||||
...(patch.cnstpSeCdNm !== undefined ? { cnstpSeCdNm: patch.cnstpSeCdNm ?? "" } : {}),
|
||||
...(patch.expln !== undefined ? { expln: patch.expln ?? "" } : {}),
|
||||
...(patch.useYn !== undefined ? { useYn: patch.useYn } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { rowKey: _rk, isNew: _n, isDirty: _d, isDeleted: _del, ...dtoPatch } = patch;
|
||||
|
||||
return {
|
||||
...state,
|
||||
patches: {
|
||||
...state.patches,
|
||||
[rowKey]: { ...state.patches[rowKey], ...dtoPatch },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function markCnstpRowsDeleted(
|
||||
state: CnstpSeGridState,
|
||||
rootRowKey: string,
|
||||
): CnstpSeGridState {
|
||||
const keysToDelete = collectDescendantRowKeys(state.structure, rootRowKey);
|
||||
const deletedKeys = new Set(state.deletedKeys);
|
||||
for (const key of keysToDelete) {
|
||||
deletedKeys.add(key);
|
||||
}
|
||||
return { ...state, deletedKeys };
|
||||
}
|
||||
|
||||
export function validateDraftParentCnstpSeCd(
|
||||
state: CnstpSeGridState,
|
||||
rowKey: string,
|
||||
): string | null {
|
||||
const draft = state.drafts[rowKey];
|
||||
if (!draft?.parentRowKey) return null;
|
||||
|
||||
const parent = mergeCnstpRow(state, draft.parentRowKey);
|
||||
if (!parent?.cnstpSeCd?.trim()) {
|
||||
return "하위 항목을 추가하려면 상위 코드를 먼저 입력하세요.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function removeDraftFromGrid(state: CnstpSeGridState, rowKey: string): CnstpSeGridState {
|
||||
const { [rowKey]: _removed, ...restDrafts } = state.drafts;
|
||||
const keysToRemove = collectDescendantRowKeys(state.structure, rowKey);
|
||||
|
||||
return {
|
||||
...state,
|
||||
drafts: restDrafts,
|
||||
structure: removeSlotsFromStructure(state.structure, keysToRemove),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { CnstpSeRow } from "../model/cnstp-se-row-types";
|
||||
|
||||
export function getVisibleRows(rows: CnstpSeRow[]): CnstpSeRow[] {
|
||||
return rows.filter((row) => !row.isDeleted);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { CnstpSeMngResponseDto } from "@/entities/cnstp";
|
||||
|
||||
import type { CnstpSeGridState } from "../model/cnstp-se-grid-types";
|
||||
import type { CnstpSeRow } from "../model/cnstp-se-row-types";
|
||||
|
||||
import { buildSaveMultiPayload } from "./build-save-multi-payload";
|
||||
import {
|
||||
createDraftRowKey,
|
||||
createEmptyGridState,
|
||||
hasCnstpGridPendingChanges,
|
||||
hydrateGridFromServer,
|
||||
insertChildSlot,
|
||||
insertRootSlot,
|
||||
markCnstpRowsDeleted,
|
||||
mergeAllCnstpRows,
|
||||
mergeCnstpRow,
|
||||
patchCnstpGridRow,
|
||||
removeDraftFromGrid,
|
||||
validateDraftParentCnstpSeCd,
|
||||
} from "./cnstp-se-grid";
|
||||
|
||||
function validateCnstpSeRow(row: CnstpSeRow): string | null {
|
||||
if (!row.cnstpSeCd.trim()) return "코드를 입력하세요.";
|
||||
const parentCd = row.upCnstpCd?.trim();
|
||||
if (parentCd && row.cnstpSeCd.trim() === parentCd) {
|
||||
return "상위 코드와 동일한 코드는 사용할 수 없습니다.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface UseCnstpSeRowsOptions {
|
||||
serverContent: CnstpSeMngResponseDto[] | undefined;
|
||||
onAlert: (message: string) => void;
|
||||
onSave: (payload: ReturnType<typeof buildSaveMultiPayload>) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useCnstpSeRows({ serverContent, onAlert, onSave }: UseCnstpSeRowsOptions) {
|
||||
const [grid, setGrid] = useState<CnstpSeGridState>(createEmptyGridState);
|
||||
const hasPendingRef = useRef(false);
|
||||
|
||||
hasPendingRef.current = hasCnstpGridPendingChanges(grid);
|
||||
|
||||
useEffect(() => {
|
||||
if (serverContent == null) return;
|
||||
if (hasPendingRef.current) return;
|
||||
setGrid(hydrateGridFromServer(serverContent));
|
||||
}, [serverContent]);
|
||||
|
||||
const displayRows = useMemo(() => {
|
||||
return grid.structure
|
||||
.filter((slot) => !grid.deletedKeys.has(slot.rowKey))
|
||||
.map((slot) => {
|
||||
const row = mergeCnstpRow(grid, slot.rowKey);
|
||||
return row ? { row, depth: slot.depth } : null;
|
||||
})
|
||||
.filter((item): item is { row: CnstpSeRow; depth: number } => item != null);
|
||||
}, [grid]);
|
||||
|
||||
const canSave = hasCnstpGridPendingChanges(grid);
|
||||
|
||||
const updateRow = useCallback((rowKey: string, patch: Partial<CnstpSeRow>) => {
|
||||
setGrid((prev) => patchCnstpGridRow(prev, rowKey, patch));
|
||||
}, []);
|
||||
|
||||
const addRootRow = useCallback(() => {
|
||||
const rowKey = createDraftRowKey();
|
||||
setGrid((prev) => ({
|
||||
...prev,
|
||||
drafts: {
|
||||
...prev.drafts,
|
||||
[rowKey]: {
|
||||
rowKey,
|
||||
parentRowKey: null,
|
||||
cdLvNo: 1,
|
||||
cnstpSeCd: "",
|
||||
cnstpSeCdNm: "",
|
||||
expln: "",
|
||||
useYn: "Y",
|
||||
},
|
||||
},
|
||||
structure: insertRootSlot(prev.structure, { rowKey, depth: 0, parentRowKey: null }),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const addChildRow = useCallback((parent: CnstpSeRow) => {
|
||||
const rowKey = createDraftRowKey();
|
||||
|
||||
setGrid((prev) => {
|
||||
const parentSlot = prev.structure.find((item) => item.rowKey === parent.rowKey);
|
||||
const depth = (parentSlot?.depth ?? 0) + 1;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
drafts: {
|
||||
...prev.drafts,
|
||||
[rowKey]: {
|
||||
rowKey,
|
||||
parentRowKey: parent.rowKey,
|
||||
cdLvNo: (parent.cdLvNo ?? 1) + 1,
|
||||
cnstpSeCd: "",
|
||||
cnstpSeCdNm: "",
|
||||
expln: "",
|
||||
useYn: "Y",
|
||||
},
|
||||
},
|
||||
structure: insertChildSlot(prev.structure, parent.rowKey, {
|
||||
rowKey,
|
||||
depth,
|
||||
parentRowKey: parent.rowKey,
|
||||
}),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeDraft = useCallback((rowKey: string) => {
|
||||
setGrid((prev) => removeDraftFromGrid(prev, rowKey));
|
||||
}, []);
|
||||
|
||||
const markDeleted = useCallback((rowKey: string) => {
|
||||
setGrid((prev) => markCnstpRowsDeleted(prev, rowKey));
|
||||
}, []);
|
||||
|
||||
const resetFromServer = useCallback((content: CnstpSeMngResponseDto[]) => {
|
||||
setGrid(hydrateGridFromServer(content));
|
||||
}, []);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
const mergedRows = mergeAllCnstpRows(grid);
|
||||
const payload = buildSaveMultiPayload(mergedRows);
|
||||
if (payload.length === 0) return false;
|
||||
|
||||
for (const row of mergedRows) {
|
||||
if (!row.isDeleted && (row.isNew || row.isDirty)) {
|
||||
const parentCodeError = validateDraftParentCnstpSeCd(grid, row.rowKey);
|
||||
if (parentCodeError) {
|
||||
onAlert(parentCodeError);
|
||||
return false;
|
||||
}
|
||||
|
||||
const fieldError = validateCnstpSeRow(row);
|
||||
if (fieldError) {
|
||||
onAlert(fieldError);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await onSave(payload);
|
||||
return true;
|
||||
}, [grid, onAlert, onSave]);
|
||||
|
||||
return {
|
||||
displayRows,
|
||||
canSave,
|
||||
updateRow,
|
||||
addRootRow,
|
||||
addChildRow,
|
||||
removeDraft,
|
||||
markDeleted,
|
||||
resetFromServer,
|
||||
save,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { CnstpSeRow } from "./cnstp-se-row-types";
|
||||
|
||||
export interface CnstpSeDisplayRow {
|
||||
row: CnstpSeRow;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
export function formatCdLvNo(lvNo?: number): string {
|
||||
return `${lvNo ?? 1}단계`;
|
||||
}
|
||||
|
||||
export function canAddChildRow(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function formatTreeBranchPrefix(depth: number): string {
|
||||
if (depth <= 0) return "";
|
||||
const vertical = "\u2502 ";
|
||||
const branch = "\u251C\u2500";
|
||||
return vertical.repeat(depth - 1) + branch;
|
||||
}
|
||||
|
||||
function isRootRow(row: CnstpSeRow): boolean {
|
||||
return (row.cdLvNo ?? 1) === 1 && !row.upCnstpCd?.trim();
|
||||
}
|
||||
|
||||
function rowOrderIndex(rows: CnstpSeRow[], row: CnstpSeRow): number {
|
||||
return rows.findIndex((candidate) => candidate.rowKey === row.rowKey);
|
||||
}
|
||||
|
||||
function pushDisplayRow(
|
||||
result: CnstpSeDisplayRow[],
|
||||
displayedRowKeys: Set<string>,
|
||||
row: CnstpSeRow,
|
||||
depth: number,
|
||||
) {
|
||||
if (displayedRowKeys.has(row.rowKey)) return;
|
||||
displayedRowKeys.add(row.rowKey);
|
||||
result.push({ row, depth });
|
||||
}
|
||||
|
||||
export function buildCnstpDisplayRows(rows: CnstpSeRow[]): CnstpSeDisplayRow[] {
|
||||
const result: CnstpSeDisplayRow[] = [];
|
||||
const displayedRowKeys = new Set<string>();
|
||||
|
||||
const roots = rows.filter(isRootRow);
|
||||
|
||||
function appendSubtree(parent: CnstpSeRow, depth: number) {
|
||||
const children = rows
|
||||
.filter(
|
||||
(row) =>
|
||||
row.rowKey !== parent.rowKey &&
|
||||
row.upCnstpCd != null &&
|
||||
row.upCnstpCd === parent.cnstpSeCd &&
|
||||
row.cnstpSeCd !== parent.cnstpSeCd,
|
||||
)
|
||||
.sort((a, b) => rowOrderIndex(rows, a) - rowOrderIndex(rows, b));
|
||||
|
||||
for (const child of children) {
|
||||
pushDisplayRow(result, displayedRowKeys, child, depth);
|
||||
appendSubtree(child, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const root of roots) {
|
||||
pushDisplayRow(result, displayedRowKeys, root, 0);
|
||||
appendSubtree(root, 1);
|
||||
}
|
||||
|
||||
const orphans = rows.filter((row) => !displayedRowKeys.has(row.rowKey));
|
||||
|
||||
for (const orphan of orphans) {
|
||||
pushDisplayRow(result, displayedRowKeys, orphan, Math.max(0, (orphan.cdLvNo ?? 1) - 1));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { CnstpSeMngResponseDto } from "@/entities/cnstp";
|
||||
|
||||
export interface CnstpDisplaySlot {
|
||||
rowKey: string;
|
||||
depth: number;
|
||||
parentRowKey: string | null;
|
||||
}
|
||||
|
||||
export interface CnstpSeDraft {
|
||||
rowKey: string;
|
||||
parentRowKey: string | null;
|
||||
cdLvNo: number;
|
||||
cnstpSeCd: string;
|
||||
cnstpSeCdNm: string;
|
||||
expln: string;
|
||||
useYn: string;
|
||||
}
|
||||
|
||||
export interface CnstpSeBaseEntry extends CnstpSeMngResponseDto {
|
||||
rowKey: string;
|
||||
}
|
||||
|
||||
export interface CnstpSeGridState {
|
||||
baseByKey: Record<string, CnstpSeBaseEntry>;
|
||||
structure: CnstpDisplaySlot[];
|
||||
drafts: Record<string, CnstpSeDraft>;
|
||||
patches: Record<string, Partial<CnstpSeMngResponseDto>>;
|
||||
deletedKeys: Set<string>;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { CnstpSeMngResponseDto } from "@/entities/cnstp";
|
||||
|
||||
export interface CnstpSeRow extends CnstpSeMngResponseDto {
|
||||
rowKey: string;
|
||||
isNew: boolean;
|
||||
isDirty: boolean;
|
||||
isDeleted: boolean;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { useCnstpSeMngPage, useSaveMultiCnstpSeMng } from "@/entities/cnstp";
|
||||
import {
|
||||
Button,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import { useCnstpSeRows } from "../lib/use-cnstp-se-rows";
|
||||
import type { CnstpSeRow } from "../model/cnstp-se-row-types";
|
||||
|
||||
import { CnstpSeRowComponent } from "./cnstp-se-row";
|
||||
|
||||
function CnstpCodeManagementPage() {
|
||||
const { confirm } = useConfirm();
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
|
||||
const pageQuery = useCnstpSeMngPage({
|
||||
page: 0,
|
||||
size: 500,
|
||||
sort: "cdLvNo,asc",
|
||||
});
|
||||
|
||||
const saveMultiMutation = useSaveMultiCnstpSeMng();
|
||||
|
||||
const handlePersist = useCallback(
|
||||
async (payload: Parameters<typeof saveMultiMutation.mutateAsync>[0]) => {
|
||||
await saveMultiMutation.mutateAsync(payload);
|
||||
},
|
||||
[saveMultiMutation],
|
||||
);
|
||||
|
||||
const {
|
||||
displayRows,
|
||||
canSave,
|
||||
updateRow,
|
||||
addRootRow,
|
||||
addChildRow,
|
||||
removeDraft,
|
||||
markDeleted,
|
||||
resetFromServer,
|
||||
save,
|
||||
} = useCnstpSeRows({
|
||||
serverContent: pageQuery.data?.content,
|
||||
onAlert: alert,
|
||||
onSave: handlePersist,
|
||||
});
|
||||
|
||||
const handleDelete = async (row: CnstpSeRow) => {
|
||||
if (!(await confirm("삭제하시겠습니까?"))) return;
|
||||
|
||||
if (row.isNew) {
|
||||
removeDraft(row.rowKey);
|
||||
return;
|
||||
}
|
||||
|
||||
markDeleted(row.rowKey);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!(await confirm("저장하시겠습니까?"))) return;
|
||||
|
||||
const saved = await save();
|
||||
if (!saved) return;
|
||||
|
||||
alert("저장되었습니다.");
|
||||
const result = await pageQuery.refetch();
|
||||
resetFromServer(result.data?.content ?? []);
|
||||
};
|
||||
|
||||
const isSaving = saveMultiMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-[1.8rem] pb-6">
|
||||
<section className="card card-border mx-0 bg-background pb-6 pt-0">
|
||||
<div className="card-header flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="text-2xl font-semibold">공종코드 관리</h3>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button type="button" color="green" onClick={addRootRow}>
|
||||
코드 추가
|
||||
</Button>
|
||||
<Button type="button" disabled={!canSave || isSaving} onClick={handleSave}>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pageQuery.isPending ? (
|
||||
<div className="flex flex-col gap-2 px-4 pb-4">
|
||||
<Skeleton className="h-[4rem]" />
|
||||
<Skeleton className="h-[16rem]" />
|
||||
</div>
|
||||
) : displayRows.length === 0 ? (
|
||||
<p className="py-8 text-center text-lg text-muted-foreground">
|
||||
등록된 코드가 없습니다. [코드 추가]로 1단계 코드를 등록하세요.
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-[60rem] overflow-y-auto pt-3">
|
||||
<Table className="w-full">
|
||||
<colgroup>
|
||||
<col width="15%" />
|
||||
<col width="20%" />
|
||||
<col width="35%" />
|
||||
<col width="12%" />
|
||||
<col width="8%" />
|
||||
<col width="10%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>코드</TableHead>
|
||||
<TableHead>코드명</TableHead>
|
||||
<TableHead>설명</TableHead>
|
||||
<TableHead>사용 여부</TableHead>
|
||||
<TableHead>구분</TableHead>
|
||||
<TableHead>관리</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{displayRows.map(({ row, depth }) => (
|
||||
<CnstpSeRowComponent
|
||||
key={row.rowKey}
|
||||
row={row}
|
||||
depth={depth}
|
||||
onChange={(patch) => updateRow(row.rowKey, patch)}
|
||||
onAddChild={() => addChildRow(row)}
|
||||
onDelete={() => handleDelete(row)}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { CnstpCodeManagementPage };
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
TableCell,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import {
|
||||
canAddChildRow,
|
||||
formatCdLvNo,
|
||||
formatTreeBranchPrefix,
|
||||
} from "../model/build-cnstp-display-rows";
|
||||
import type { CnstpSeRow } from "../model/cnstp-se-row-types";
|
||||
|
||||
interface CnstpSeRowProps {
|
||||
row: CnstpSeRow;
|
||||
depth: number;
|
||||
onChange: (patch: Partial<CnstpSeRow>) => void;
|
||||
onAddChild: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function CnstpSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: CnstpSeRowProps) {
|
||||
const levelLabel = formatCdLvNo(row.cdLvNo);
|
||||
const branchPrefix = formatTreeBranchPrefix(depth);
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className="flex min-w-[10rem] items-center gap-1">
|
||||
{branchPrefix ? (
|
||||
<span className="shrink-0 font-mono text-muted">{branchPrefix}</span>
|
||||
) : null}
|
||||
<Input
|
||||
value={row.cnstpSeCd}
|
||||
onChange={(e) => onChange({ cnstpSeCd: e.target.value })}
|
||||
placeholder="코드"
|
||||
maxLength={10}
|
||||
disabled={!row.isNew}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={row.cnstpSeCdNm ?? ""}
|
||||
onChange={(e) => onChange({ cnstpSeCdNm: e.target.value })}
|
||||
placeholder="코드명"
|
||||
maxLength={100}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={row.expln ?? ""}
|
||||
onChange={(e) => onChange({ expln: e.target.value })}
|
||||
placeholder="설명"
|
||||
maxLength={1000}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<RadioGroup
|
||||
value={row.useYn === "N" ? "N" : "Y"}
|
||||
onValueChange={(v) => onChange({ useYn: v })}
|
||||
className="flex justify-center gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="Y" id={`${row.rowKey}-use-y`} />
|
||||
<Label htmlFor={`${row.rowKey}-use-y`}>예</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="N" id={`${row.rowKey}-use-n`} />
|
||||
<Label htmlFor={`${row.rowKey}-use-n`}>아니오</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</TableCell>
|
||||
<TableCell align="center">{levelLabel}</TableCell>
|
||||
<TableCell align="center">
|
||||
<div className="flex justify-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="white"
|
||||
disabled={!canAddChildRow()}
|
||||
onClick={onAddChild}
|
||||
>
|
||||
하위추가
|
||||
</Button>
|
||||
<Button type="button" size="sm" color="gray" onClick={onDelete}>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export { CnstpSeRowComponent };
|
||||
@@ -1 +0,0 @@
|
||||
export { CodeManagementPage } from "./ui/code-management-page";
|
||||
@@ -1,61 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
|
||||
import type { AcntgSeMngResponseDto } from "@/entities/acntg";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
const columnHelper = createColumnHelper<AcntgSeMngResponseDto>();
|
||||
|
||||
interface CreateAcntgSeColumnsOptions {
|
||||
onEdit: (row: AcntgSeMngResponseDto) => void;
|
||||
onDelete: (row: AcntgSeMngResponseDto) => void;
|
||||
}
|
||||
|
||||
function formatUseYn(value: string | undefined) {
|
||||
if (value === "Y") return "사용";
|
||||
if (value === "N") return "미사용";
|
||||
return "-";
|
||||
}
|
||||
|
||||
function formatOptional(value: string | undefined) {
|
||||
return value?.trim() ? value : "-";
|
||||
}
|
||||
|
||||
export function createAcntgSeColumns({ onEdit, onDelete }: CreateAcntgSeColumnsOptions) {
|
||||
return [
|
||||
columnHelper.accessor("acntgSeCd", {
|
||||
header: "코드",
|
||||
meta: { align: "center" },
|
||||
}),
|
||||
columnHelper.accessor("acntgSeCdNm", {
|
||||
header: "코드명",
|
||||
meta: { align: "left" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("expln", {
|
||||
header: "설명",
|
||||
meta: { align: "left" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("useYn", {
|
||||
header: "사용여부",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatUseYn(getValue()),
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "actions",
|
||||
header: "관리",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-center gap-2">
|
||||
<Button type="button" size="sm" color="white" onClick={() => onEdit(row.original)}>
|
||||
수정
|
||||
</Button>
|
||||
<Button type="button" size="sm" color="red" onClick={() => onDelete(row.original)}>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
|
||||
import type { CnstpSeMngResponseDto } from "@/entities/cnstp";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
const columnHelper = createColumnHelper<CnstpSeMngResponseDto>();
|
||||
|
||||
interface CreateCnstpSeColumnsOptions {
|
||||
onEdit: (row: CnstpSeMngResponseDto) => void;
|
||||
onDelete: (row: CnstpSeMngResponseDto) => void;
|
||||
}
|
||||
|
||||
function formatUseYn(value: string | undefined) {
|
||||
if (value === "Y") return "사용";
|
||||
if (value === "N") return "미사용";
|
||||
return "-";
|
||||
}
|
||||
|
||||
function formatOptional(value: string | undefined) {
|
||||
return value?.trim() ? value : "-";
|
||||
}
|
||||
|
||||
export function createCnstpSeColumns({ onEdit, onDelete }: CreateCnstpSeColumnsOptions) {
|
||||
return [
|
||||
columnHelper.accessor("cnstpSeCd", {
|
||||
header: "코드",
|
||||
meta: { align: "center" },
|
||||
}),
|
||||
columnHelper.accessor("cnstpSeCdNm", {
|
||||
header: "코드명",
|
||||
meta: { align: "left" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("expln", {
|
||||
header: "설명",
|
||||
meta: { align: "left" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("useYn", {
|
||||
header: "사용여부",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatUseYn(getValue()),
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "actions",
|
||||
header: "관리",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-center gap-2">
|
||||
<Button type="button" size="sm" color="white" onClick={() => onEdit(row.original)}>
|
||||
수정
|
||||
</Button>
|
||||
<Button type="button" size="sm" color="red" onClick={() => onDelete(row.original)}>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { useAcntgSeMng } from "@/entities/acntg";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Input,
|
||||
Label,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
Skeleton,
|
||||
} from "@common/components";
|
||||
|
||||
export interface AcntgSeFormValues {
|
||||
acntgSeCd: string;
|
||||
acntgSeCdNm: string;
|
||||
expln: string;
|
||||
useYn: "Y" | "N";
|
||||
}
|
||||
|
||||
interface AcntgSeFormDialogProps {
|
||||
mode: "create" | "edit";
|
||||
open: boolean;
|
||||
acntgSeCd?: string | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
isSubmitting?: boolean;
|
||||
onSubmit: (values: AcntgSeFormValues) => void;
|
||||
}
|
||||
|
||||
function AcntgSeFormDialog({
|
||||
mode,
|
||||
open,
|
||||
acntgSeCd,
|
||||
onOpenChange,
|
||||
isSubmitting,
|
||||
onSubmit,
|
||||
}: AcntgSeFormDialogProps) {
|
||||
const detailQuery = useAcntgSeMng(mode === "edit" ? acntgSeCd : null);
|
||||
|
||||
const { register, handleSubmit, reset, setValue, watch } = useForm<AcntgSeFormValues>({
|
||||
defaultValues: {
|
||||
acntgSeCd: "",
|
||||
acntgSeCdNm: "",
|
||||
expln: "",
|
||||
useYn: "Y",
|
||||
},
|
||||
});
|
||||
|
||||
const useYn = watch("useYn");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
if (mode === "edit") {
|
||||
if (!detailQuery.data) return;
|
||||
reset({
|
||||
acntgSeCd: detailQuery.data.acntgSeCd,
|
||||
acntgSeCdNm: detailQuery.data.acntgSeCdNm ?? "",
|
||||
expln: detailQuery.data.expln ?? "",
|
||||
useYn: detailQuery.data.useYn === "N" ? "N" : "Y",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
reset({
|
||||
acntgSeCd: "",
|
||||
acntgSeCdNm: "",
|
||||
expln: "",
|
||||
useYn: "Y",
|
||||
});
|
||||
}, [open, mode, detailQuery.data, reset]);
|
||||
|
||||
const isLoading = mode === "edit" && detailQuery.isPending;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[52rem]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{mode === "create" ? "회계코드 등록" : "회계코드 수정"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-[4rem]" />
|
||||
<Skeleton className="h-[4rem]" />
|
||||
<Skeleton className="h-[4rem]" />
|
||||
</div>
|
||||
) : (
|
||||
<form className="space-y-6" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="acntgSeCd">코드</Label>
|
||||
<Input
|
||||
id="acntgSeCd"
|
||||
placeholder="코드 입력"
|
||||
maxLength={10}
|
||||
readOnly={mode === "edit"}
|
||||
{...register("acntgSeCd", { required: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="acntgSeCdNm">코드명</Label>
|
||||
<Input
|
||||
id="acntgSeCdNm"
|
||||
placeholder="코드명 입력"
|
||||
maxLength={100}
|
||||
{...register("acntgSeCdNm", { required: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="expln">설명</Label>
|
||||
<Input id="expln" placeholder="설명 입력" maxLength={1000} {...register("expln")} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>사용여부</Label>
|
||||
<RadioGroup
|
||||
value={useYn}
|
||||
onValueChange={(v) => setValue("useYn", v as "Y" | "N")}
|
||||
className="flex gap-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="Y" id="acntg-use-y" />
|
||||
<Label htmlFor="acntg-use-y">사용</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="N" id="acntg-use-n" />
|
||||
<Label htmlFor="acntg-use-n">미사용</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" color="white" onClick={() => onOpenChange(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { AcntgSeFormDialog };
|
||||
@@ -1,144 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
|
||||
|
||||
import { useDataTable } from "@/shared/lib";
|
||||
import {
|
||||
type AcntgSeMngResponseDto,
|
||||
useAcntgSeMng,
|
||||
useAcntgSeMngPage,
|
||||
useDeleteAcntgSeMng,
|
||||
useSaveAcntgSeMng,
|
||||
useUpdateAcntgSeMng,
|
||||
} from "@/entities/acntg";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import { createAcntgSeColumns } from "../model/acntg-se-table-columns";
|
||||
|
||||
import { AcntgSeFormDialog, type AcntgSeFormValues } from "./acntg-se-form-dialog";
|
||||
|
||||
interface AcntgSeSectionProps {
|
||||
pagination: PaginationState;
|
||||
onPaginationChange: (pagination: PaginationState) => void;
|
||||
}
|
||||
|
||||
function AcntgSeSection({ pagination, onPaginationChange }: AcntgSeSectionProps) {
|
||||
const { confirm } = useConfirm();
|
||||
const [dialogMode, setDialogMode] = useState<"create" | "edit" | null>(null);
|
||||
const [editingCd, setEditingCd] = useState<string | null>(null);
|
||||
|
||||
const pageQuery = useAcntgSeMngPage({
|
||||
page: pagination.pageIndex,
|
||||
size: pagination.pageSize,
|
||||
});
|
||||
|
||||
const detailQuery = useAcntgSeMng(dialogMode === "edit" ? editingCd : null);
|
||||
const saveMutation = useSaveAcntgSeMng();
|
||||
const updateMutation = useUpdateAcntgSeMng();
|
||||
const deleteMutation = useDeleteAcntgSeMng();
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (row: AcntgSeMngResponseDto) => {
|
||||
if (!(await confirm("삭제하시겠습니까?"))) return;
|
||||
await deleteMutation.mutateAsync(row.acntgSeCd);
|
||||
},
|
||||
[confirm, deleteMutation],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
createAcntgSeColumns({
|
||||
onEdit: (row) => {
|
||||
setEditingCd(row.acntgSeCd);
|
||||
setDialogMode("edit");
|
||||
},
|
||||
onDelete: handleDelete,
|
||||
}),
|
||||
[handleDelete],
|
||||
);
|
||||
|
||||
const { tableComponent } = useDataTable({
|
||||
query: pageQuery,
|
||||
tableOptions: {
|
||||
columns,
|
||||
state: { pagination },
|
||||
onPaginationChange: (updater) => {
|
||||
const next = typeof updater === "function" ? updater(pagination) : updater;
|
||||
onPaginationChange(next);
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
},
|
||||
dataTableOptions: {
|
||||
viewPagination: true,
|
||||
},
|
||||
});
|
||||
|
||||
const handleFormSubmit = async (values: AcntgSeFormValues) => {
|
||||
if (!values.acntgSeCd.trim() || !values.acntgSeCdNm.trim()) return;
|
||||
|
||||
if (dialogMode === "create") {
|
||||
if (!(await confirm("저장하시겠습니까?"))) return;
|
||||
await saveMutation.mutateAsync({
|
||||
acntgSeCd: values.acntgSeCd.trim(),
|
||||
acntgSeCdNm: values.acntgSeCdNm.trim(),
|
||||
expln: values.expln.trim() || undefined,
|
||||
useYn: values.useYn,
|
||||
cdLvNo: 1,
|
||||
});
|
||||
} else if (dialogMode === "edit" && detailQuery.data) {
|
||||
if (!(await confirm("수정하시겠습니까?"))) return;
|
||||
await updateMutation.mutateAsync({
|
||||
acntgSeCd: detailQuery.data.acntgSeCd,
|
||||
body: {
|
||||
acntgSeCd: detailQuery.data.acntgSeCd,
|
||||
acntgSeCdNm: values.acntgSeCdNm.trim(),
|
||||
expln: values.expln.trim() || undefined,
|
||||
useYn: values.useYn,
|
||||
upAcntgSeCd: detailQuery.data.upAcntgSeCd,
|
||||
cdLvNo: detailQuery.data.cdLvNo,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setDialogMode(null);
|
||||
setEditingCd(null);
|
||||
};
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
setEditingCd(null);
|
||||
setDialogMode("create");
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="card card-border mx-0 bg-background pb-6 pt-0">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="text-2xl font-semibold">회계코드 관리</h3>
|
||||
<Button type="button" onClick={handleOpenCreate}>
|
||||
등록
|
||||
</Button>
|
||||
</div>
|
||||
{tableComponent}
|
||||
{dialogMode ? (
|
||||
<AcntgSeFormDialog
|
||||
mode={dialogMode}
|
||||
open={Boolean(dialogMode)}
|
||||
acntgSeCd={editingCd}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDialogMode(null);
|
||||
setEditingCd(null);
|
||||
}
|
||||
}}
|
||||
isSubmitting={saveMutation.isPending || updateMutation.isPending}
|
||||
onSubmit={handleFormSubmit}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export { AcntgSeSection };
|
||||
@@ -1,151 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { useCnstpSeMng } from "@/entities/cnstp";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Input,
|
||||
Label,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
Skeleton,
|
||||
} from "@common/components";
|
||||
|
||||
export interface CnstpSeFormValues {
|
||||
cnstpSeCd: string;
|
||||
cnstpSeCdNm: string;
|
||||
expln: string;
|
||||
useYn: "Y" | "N";
|
||||
}
|
||||
|
||||
interface CnstpSeFormDialogProps {
|
||||
mode: "create" | "edit";
|
||||
open: boolean;
|
||||
cnstpSeCd?: string | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
isSubmitting?: boolean;
|
||||
onSubmit: (values: CnstpSeFormValues) => void;
|
||||
}
|
||||
|
||||
function CnstpSeFormDialog({
|
||||
mode,
|
||||
open,
|
||||
cnstpSeCd,
|
||||
onOpenChange,
|
||||
isSubmitting,
|
||||
onSubmit,
|
||||
}: CnstpSeFormDialogProps) {
|
||||
const detailQuery = useCnstpSeMng(mode === "edit" ? cnstpSeCd : null);
|
||||
|
||||
const { register, handleSubmit, reset, setValue, watch } = useForm<CnstpSeFormValues>({
|
||||
defaultValues: {
|
||||
cnstpSeCd: "",
|
||||
cnstpSeCdNm: "",
|
||||
expln: "",
|
||||
useYn: "Y",
|
||||
},
|
||||
});
|
||||
|
||||
const useYn = watch("useYn");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
if (mode === "edit") {
|
||||
if (!detailQuery.data) return;
|
||||
reset({
|
||||
cnstpSeCd: detailQuery.data.cnstpSeCd,
|
||||
cnstpSeCdNm: detailQuery.data.cnstpSeCdNm ?? "",
|
||||
expln: detailQuery.data.expln ?? "",
|
||||
useYn: detailQuery.data.useYn === "N" ? "N" : "Y",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
reset({
|
||||
cnstpSeCd: "",
|
||||
cnstpSeCdNm: "",
|
||||
expln: "",
|
||||
useYn: "Y",
|
||||
});
|
||||
}, [open, mode, detailQuery.data, reset]);
|
||||
|
||||
const isLoading = mode === "edit" && detailQuery.isPending;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[52rem]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{mode === "create" ? "공종코드 등록" : "공종코드 수정"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-[4rem]" />
|
||||
<Skeleton className="h-[4rem]" />
|
||||
<Skeleton className="h-[4rem]" />
|
||||
</div>
|
||||
) : (
|
||||
<form className="space-y-6" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cnstpSeCd">코드</Label>
|
||||
<Input
|
||||
id="cnstpSeCd"
|
||||
placeholder="코드 입력"
|
||||
maxLength={10}
|
||||
readOnly={mode === "edit"}
|
||||
{...register("cnstpSeCd", { required: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cnstpSeCdNm">코드명</Label>
|
||||
<Input
|
||||
id="cnstpSeCdNm"
|
||||
placeholder="코드명 입력"
|
||||
maxLength={100}
|
||||
{...register("cnstpSeCdNm")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="expln">설명</Label>
|
||||
<Input id="expln" placeholder="설명 입력" maxLength={1000} {...register("expln")} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>사용여부</Label>
|
||||
<RadioGroup
|
||||
value={useYn}
|
||||
onValueChange={(v) => setValue("useYn", v as "Y" | "N")}
|
||||
className="flex gap-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="Y" id="cnstp-use-y" />
|
||||
<Label htmlFor="cnstp-use-y">사용</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="N" id="cnstp-use-n" />
|
||||
<Label htmlFor="cnstp-use-n">미사용</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" color="white" onClick={() => onOpenChange(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { CnstpSeFormDialog };
|
||||
@@ -1,147 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
|
||||
|
||||
import { useDataTable } from "@/shared/lib";
|
||||
import {
|
||||
type CnstpSeMngResponseDto,
|
||||
useCnstpSeMng,
|
||||
useCnstpSeMngPage,
|
||||
useDeleteCnstpSeMng,
|
||||
useSaveCnstpSeMng,
|
||||
useUpdateCnstpSeMng,
|
||||
} from "@/entities/cnstp";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import { createCnstpSeColumns } from "../model/cnstp-se-table-columns";
|
||||
|
||||
import { CnstpSeFormDialog, type CnstpSeFormValues } from "./cnstp-se-form-dialog";
|
||||
|
||||
const DEFAULT_PAGE_INDEX = 0;
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
|
||||
interface CnstpSeSectionProps {
|
||||
pagination: PaginationState;
|
||||
onPaginationChange: (pagination: PaginationState) => void;
|
||||
}
|
||||
|
||||
function CnstpSeSection({ pagination, onPaginationChange }: CnstpSeSectionProps) {
|
||||
const { confirm } = useConfirm();
|
||||
const [dialogMode, setDialogMode] = useState<"create" | "edit" | null>(null);
|
||||
const [editingCd, setEditingCd] = useState<string | null>(null);
|
||||
|
||||
const pageQuery = useCnstpSeMngPage({
|
||||
page: pagination.pageIndex,
|
||||
size: pagination.pageSize,
|
||||
});
|
||||
|
||||
const detailQuery = useCnstpSeMng(dialogMode === "edit" ? editingCd : null);
|
||||
const saveMutation = useSaveCnstpSeMng();
|
||||
const updateMutation = useUpdateCnstpSeMng();
|
||||
const deleteMutation = useDeleteCnstpSeMng();
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (row: CnstpSeMngResponseDto) => {
|
||||
if (!(await confirm("삭제하시겠습니까?"))) return;
|
||||
await deleteMutation.mutateAsync(row.cnstpSeCd);
|
||||
},
|
||||
[confirm, deleteMutation],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
createCnstpSeColumns({
|
||||
onEdit: (row) => {
|
||||
setEditingCd(row.cnstpSeCd);
|
||||
setDialogMode("edit");
|
||||
},
|
||||
onDelete: handleDelete,
|
||||
}),
|
||||
[handleDelete],
|
||||
);
|
||||
|
||||
const { tableComponent } = useDataTable({
|
||||
query: pageQuery,
|
||||
tableOptions: {
|
||||
columns,
|
||||
state: { pagination },
|
||||
onPaginationChange: (updater) => {
|
||||
const next = typeof updater === "function" ? updater(pagination) : updater;
|
||||
onPaginationChange(next);
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
},
|
||||
dataTableOptions: {
|
||||
viewPagination: true,
|
||||
},
|
||||
});
|
||||
|
||||
const handleFormSubmit = async (values: CnstpSeFormValues) => {
|
||||
if (!values.cnstpSeCd.trim()) return;
|
||||
|
||||
if (dialogMode === "create") {
|
||||
if (!(await confirm("저장하시겠습니까?"))) return;
|
||||
await saveMutation.mutateAsync({
|
||||
cnstpSeCd: values.cnstpSeCd.trim(),
|
||||
cnstpSeCdNm: values.cnstpSeCdNm.trim() || undefined,
|
||||
expln: values.expln.trim() || undefined,
|
||||
useYn: values.useYn,
|
||||
cdLvNo: 1,
|
||||
});
|
||||
} else if (dialogMode === "edit" && detailQuery.data) {
|
||||
if (!(await confirm("수정하시겠습니까?"))) return;
|
||||
await updateMutation.mutateAsync({
|
||||
cnstpSeCd: detailQuery.data.cnstpSeCd,
|
||||
body: {
|
||||
cnstpSeCd: detailQuery.data.cnstpSeCd,
|
||||
cnstpSeCdNm: values.cnstpSeCdNm.trim() || undefined,
|
||||
expln: values.expln.trim() || undefined,
|
||||
useYn: values.useYn,
|
||||
upCnstpCd: detailQuery.data.upCnstpCd,
|
||||
cdLvNo: detailQuery.data.cdLvNo,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setDialogMode(null);
|
||||
setEditingCd(null);
|
||||
};
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
setEditingCd(null);
|
||||
setDialogMode("create");
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="card card-border mx-0 bg-background pb-6 pt-0">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="text-2xl font-semibold">공종코드 관리</h3>
|
||||
<Button type="button" onClick={handleOpenCreate}>
|
||||
등록
|
||||
</Button>
|
||||
</div>
|
||||
{tableComponent}
|
||||
{dialogMode ? (
|
||||
<CnstpSeFormDialog
|
||||
mode={dialogMode}
|
||||
open={Boolean(dialogMode)}
|
||||
cnstpSeCd={editingCd}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDialogMode(null);
|
||||
setEditingCd(null);
|
||||
}
|
||||
}}
|
||||
isSubmitting={saveMutation.isPending || updateMutation.isPending}
|
||||
onSubmit={handleFormSubmit}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export { CnstpSeSection, DEFAULT_PAGE_INDEX, DEFAULT_PAGE_SIZE };
|
||||
@@ -1,30 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
|
||||
import { AcntgSeSection } from "./acntg-se-section";
|
||||
import { CnstpSeSection, DEFAULT_PAGE_INDEX, DEFAULT_PAGE_SIZE } from "./cnstp-se-section";
|
||||
|
||||
function CodeManagementPage() {
|
||||
const [cnstpPagination, setCnstpPagination] = useState<PaginationState>({
|
||||
pageIndex: DEFAULT_PAGE_INDEX,
|
||||
pageSize: DEFAULT_PAGE_SIZE,
|
||||
});
|
||||
const [acntgPagination, setAcntgPagination] = useState<PaginationState>({
|
||||
pageIndex: DEFAULT_PAGE_INDEX,
|
||||
pageSize: DEFAULT_PAGE_SIZE,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-[1.8rem] pb-6">
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<CnstpSeSection pagination={cnstpPagination} onPaginationChange={setCnstpPagination} />
|
||||
<AcntgSeSection pagination={acntgPagination} onPaginationChange={setAcntgPagination} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { CodeManagementPage };
|
||||
@@ -68,10 +68,10 @@ export function createEsntlAtchFileColumns({
|
||||
header: "관리",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Button type="button" size="sm" onClick={() => onEdit(row.original)}>
|
||||
<Button type="button" size="sm" color="white" onClick={() => onEdit(row.original)}>
|
||||
수정
|
||||
</Button>
|
||||
<Button type="button" size="sm" color="red" onClick={() => onDelete(row.original)}>
|
||||
<Button type="button" size="sm" color="gray" onClick={() => onDelete(row.original)}>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -71,15 +71,15 @@ function CnfmListPage() {
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [taskDialogOpen, setTaskDialogOpen] = useState(false);
|
||||
const [selectedTaskSeComCd, setSelectedTaskSeComCd] = useState("");
|
||||
const [selectedTaskSeComCd, setSelectedTaskSeComCd] = useState<string[]>([]);
|
||||
|
||||
const { data: fnrsCodeList = [] } = useCodeList("INSTL");
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskDialogOpen) return;
|
||||
if (selectedTaskSeComCd) return;
|
||||
if (fnrsCodeList.length === 0) return;
|
||||
setSelectedTaskSeComCd(fnrsCodeList[0].lwpoCd);
|
||||
if (selectedTaskSeComCd.length) return;
|
||||
if (fnrsCodeList.length === 0) return;
|
||||
// setSelectedTaskSeComCd([fnrsCodeList[0].lwpoCd]);
|
||||
}, [taskDialogOpen, selectedTaskSeComCd, fnrsCodeList]);
|
||||
|
||||
const query = useQuery({
|
||||
@@ -108,20 +108,25 @@ function CnfmListPage() {
|
||||
};
|
||||
|
||||
const handleOpenTaskDialog = () => {
|
||||
setSelectedTaskSeComCd((prev) => prev || fnrsCodeList[0]?.lwpoCd || "");
|
||||
//setSelectedTaskSeComCd((prev) => (prev && prev.length ? prev : [fnrsCodeList[0]?.lwpoCd || ""]));
|
||||
setTaskDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmTaskDialog = () => {
|
||||
const selected = fnrsCodeList.find((item) => item.lwpoCd === selectedTaskSeComCd);
|
||||
if (!selected) return;
|
||||
if (!selectedTaskSeComCd || selectedTaskSeComCd.length === 0) {
|
||||
alert("하나 이상의 업무구분을 선택하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// prepare arrays of selected codes and names
|
||||
const selectedCodes = selectedTaskSeComCd;
|
||||
const selectedNames = selectedTaskSeComCd.map((code) => fnrsCodeList.find((item) => item.lwpoCd === code)?.lwpoCdNm || "");
|
||||
sessionStorage.setItem(
|
||||
INSTL_SNCN_REGISTER_SELECTION_KEY,
|
||||
JSON.stringify({
|
||||
taskSeComCd: selected.lwpoCd,
|
||||
taskSeComCdNm: selected.lwpoCdNm,
|
||||
}),
|
||||
taskSeComCdList: selectedCodes,
|
||||
taskSeComCdNmList: selectedNames
|
||||
})
|
||||
);
|
||||
setTaskDialogOpen(false);
|
||||
router.push("/instl-sncn/register");
|
||||
@@ -226,7 +231,7 @@ function CnfmListPage() {
|
||||
<th>담당자</th>
|
||||
<th>해당기관</th>
|
||||
<th>진행상황</th>
|
||||
<th>시행일</th>
|
||||
<th>신청일</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -248,12 +253,12 @@ function CnfmListPage() {
|
||||
className="cnfm-row-clickable"
|
||||
onClick={() => router.push(`/instl-sncn/${row.instlSncnDocNo}`)}
|
||||
>
|
||||
<td>{row.sidoNm || "-"}</td>
|
||||
<td>{row.sggNm || "-"}</td>
|
||||
<td>{row.sido || "-"}</td>
|
||||
<td>{row.gugun|| "-"}</td>
|
||||
<td className="text-left">{row.bizNm || "-"}</td>
|
||||
<td>{row.taskSeCdNm || row.taskSeComCd || "-"}</td>
|
||||
<td>{row.chrgrNm || row.aplcntNm || "-"}</td>
|
||||
<td>{row.instNm || row.aplcntOgdpNm || "-"}</td>
|
||||
<td>{row.aplcntNm || "-"}</td>
|
||||
<td>{row.envoSeCdNm || "-"}</td>
|
||||
<td>
|
||||
<div className="cnfm-step-wrap">
|
||||
<div className="cnfm-step-row">
|
||||
@@ -367,11 +372,15 @@ function CnfmListPage() {
|
||||
{fnrsCodeList.map((item) => (
|
||||
<label key={item.lwpoCd} className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
type="checkbox"
|
||||
name="taskSeComCdSelect"
|
||||
value={item.lwpoCd}
|
||||
checked={selectedTaskSeComCd === item.lwpoCd}
|
||||
onChange={() => setSelectedTaskSeComCd(item.lwpoCd)}
|
||||
checked={selectedTaskSeComCd.includes(item.lwpoCd)}
|
||||
onChange={() => {
|
||||
setSelectedTaskSeComCd((prev) =>
|
||||
prev.includes(item.lwpoCd) ? prev.filter((v) => v !== item.lwpoCd) : [...prev, item.lwpoCd],
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{item.lwpoCdNm}
|
||||
</label>
|
||||
@@ -387,11 +396,11 @@ function CnfmListPage() {
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-blue-500 px-5 py-2 text-[1.3rem] font-semibold text-white hover:bg-blue-600 disabled:cursor-not-allowed disabled:bg-gray-300"
|
||||
onClick={handleConfirmTaskDialog}
|
||||
disabled={!selectedTaskSeComCd}
|
||||
disabled={!selectedTaskSeComCd || selectedTaskSeComCd.length === 0}
|
||||
>
|
||||
확인
|
||||
</button>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,10 +7,15 @@ import { Button } from "@common/components";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { InstlSncnApi } from "@/entities/instl-sncn";
|
||||
// InstlSncnApi imported below with types
|
||||
import { PROXY_HOST } from "@/shared/config";
|
||||
import { formatFileSize } from "@/shared/lib/file-utils";
|
||||
import { dayjs, useUserStore } from "@/shared/lib";
|
||||
import axios from "axios";
|
||||
import { processElaprPrgrs } from "@/entities/elapr/api/elapr-prgrs-service";
|
||||
import { ElaprDmndApi } from "@/entities/elapr/api/elapr-dmnd-service";
|
||||
import { dayjs, useUserStore, useAlertStore } from "@/shared/lib";
|
||||
import { InstlSncnApi, type InstlSncnApplySaveRequestDto } from "@/entities/instl-sncn";
|
||||
import { client } from "@/shared/api";
|
||||
|
||||
import "./cnfm-page.css";
|
||||
|
||||
@@ -55,6 +60,13 @@ const getRoleStepCount = (dtlInstCd?: string): number => {
|
||||
return mapped ? REVIEW_STEP_ROLE_RANK[mapped] : 0;
|
||||
};
|
||||
|
||||
// 업무구분 코드 -> 표시명 매핑 (체크리스트 그룹 표시용)
|
||||
const TASK_CODE_LABELS: Record<string, string> = {
|
||||
RE: "재이용수",
|
||||
SP: "하수관로",
|
||||
SF: "하수처리시설",
|
||||
};
|
||||
|
||||
const normalizeChecklistResultMap = (
|
||||
raw: unknown,
|
||||
): Record<ReviewStepCode, any[]> => {
|
||||
@@ -85,6 +97,8 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
const router = useRouter();
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["instl-sncn-detail", id],
|
||||
queryFn: () => InstlSncnApi.getById(id),
|
||||
@@ -96,13 +110,151 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
const det = data.detDto ?? data;
|
||||
const bizSclList = data.bizSclList ?? [];
|
||||
const bizCstList = data.bizCstList ?? [];
|
||||
// prefer new API field `cklstResultMap`, fallback to legacy `cklstResultList`
|
||||
const cklstResultMap = useMemo(
|
||||
() => normalizeChecklistResultMap(data.cklstResultMap ?? data.cklstResultList),
|
||||
[data.cklstResultMap, data.cklstResultList],
|
||||
);
|
||||
// API now provides a flat checklist result list: `cklstResultList`
|
||||
const rawCklst = useMemo(() => data.cklstResultList ?? [], [data.cklstResultList]);
|
||||
|
||||
// ensure we have a flat array
|
||||
const cklstAll = useMemo(() => (Array.isArray(rawCklst) ? (rawCklst as any[]) : []), [rawCklst]);
|
||||
|
||||
// build a step-keyed map from the flat list so the rest of the view can use same access pattern
|
||||
const cklstResultMap = useMemo(() => {
|
||||
const base: Record<ReviewStepCode, any[]> = { LOCAL: [], ENV: [], KECO: [] };
|
||||
(cklstAll ?? []).forEach((row: any) => {
|
||||
const step = (row?.rvwStpComCd ?? "LOCAL") as ReviewStepCode;
|
||||
if (REVIEW_STEP_CODES.includes(step)) base[step].push(row);
|
||||
});
|
||||
return base;
|
||||
}, [cklstAll]);
|
||||
const esntlFileList = data.esntlFileList ?? [];
|
||||
const etcFileList = data.etcFileList ?? [];
|
||||
const elaprDocPrgrsList = (data.elaprDocPrgrsList ?? []) as any[];
|
||||
const renderApproverCell = (code: string) => {
|
||||
const row = elaprDocPrgrsList.find((r) => r.atrzSeComCd === code);
|
||||
if (!row) return <div className="text-center">-</div>;
|
||||
const status = row.atrzPrgrsSttsComCd ?? "";
|
||||
const statusMap: Record<string, { label: string; badgeClass: string; icon: string }> = {
|
||||
ATRZ_PRGRS_001: { label: "대기", badgeClass: "bg-gray-100 text-gray-700", icon: "⏳" },
|
||||
ATRZ_PRGRS_002: { label: "요청", badgeClass: "bg-yellow-100 text-yellow-800", icon: "📨" },
|
||||
ATRZ_PRGRS_003: { label: "승인", badgeClass: "bg-green-100 text-green-800", icon: "✔️" },
|
||||
ATRZ_PRGRS_004: { label: "반려", badgeClass: "bg-red-100 text-red-800", icon: "✖️" },
|
||||
};
|
||||
const st = statusMap[status] ?? { label: status || "", badgeClass: "bg-gray-100 text-gray-700", icon: "" };
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="font-semibold">{row.aprvrUuidNm ?? "-"}</div>
|
||||
<div className="text-[0.95rem] text-gray-500">
|
||||
{row.ognzNm ? `${row.ognzNm}` : ""}
|
||||
{row.jbpsSeNm || row.jbgdSeNm ? ` · ${row.jbpsSeNm ?? ""}${row.jbgdSeNm ? ` ${row.jbgdSeNm}` : ""}` : ""}
|
||||
</div>
|
||||
<div className="text-[0.9rem] mt-1 flex items-center justify-center gap-2">
|
||||
{row.atrzDt ? <span className="text-gray-400">{dayjs(row.atrzDt).format("YYYY-MM-DD HH:mm:ss")}</span> : null}
|
||||
{st.label ? (
|
||||
<span
|
||||
title={`${row.atrzPrgrsSttsComNm ?? st.label}${row.rjctRsn ? ` — 사유: ${row.rjctRsn}` : ""}${row.atrzDt ? ` · ${dayjs(row.atrzDt).format("YYYY-MM-DD HH:mm:ss")}` : ""}`}
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-md text-xs font-semibold ml-1 ${st.badgeClass}`}
|
||||
>
|
||||
<span className="mr-1">{st.icon}</span>
|
||||
<span>{st.label}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const orderedApprovers = useMemo(() => {
|
||||
return (elaprDocPrgrsList ?? []).slice().sort((a: any, b: any) => {
|
||||
const A = Number(a?.atrzSeqNo ?? 0);
|
||||
const B = Number(b?.atrzSeqNo ?? 0);
|
||||
return A - B;
|
||||
});
|
||||
}, [elaprDocPrgrsList]);
|
||||
|
||||
const handleApproveClick = async (elaprPrgrsId: any, atrzSeComCd: any) => {
|
||||
if (!elaprPrgrsId) {
|
||||
alert("결재 정보가 올바르지 않습니다.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await processElaprPrgrs(String(elaprPrgrsId), "ATRZ_PRGRS_003"); //진행상태를 승인으로 업데이트
|
||||
alert("결재가 완료되었습니다.");
|
||||
try {
|
||||
await query.refetch();
|
||||
} catch (e) {
|
||||
/* ignore refetch errors */
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("결재 처리 실패", err);
|
||||
const msg = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : "결재 처리 중 오류가 발생했습니다.";
|
||||
alert(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssignApproverClick = async (elaprTmpltId: string) => {
|
||||
try {
|
||||
const payload = {
|
||||
elaprDocNo: det.instlSncnDocNo,
|
||||
elaprTmpltId: elaprTmpltId, /* 팝업에서 선택된 전자결제 템플릿 */
|
||||
dmndTtl: det.bizNm, /* "구례 하수도 처리시설 2026", */
|
||||
dmndCn: det.bizCn /*"구례 발주 하수도 처리시설 사업 2026년도 진행분 결제 요청 입니다."*/
|
||||
};
|
||||
// ElaprDmndApi 서비스를 통해 백엔드 연동
|
||||
await ElaprDmndApi.create(payload);
|
||||
alert("결재자 지정(결재요청 등록)이 완료되었습니다.");
|
||||
try {
|
||||
await query.refetch();
|
||||
} catch (e) {
|
||||
/* ignore refetch errors */
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("결재자 지정 실패", err);
|
||||
const msg = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : "결재자 지정 중 오류가 발생했습니다.";
|
||||
alert(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const renderApproverRow = (row: any) => {
|
||||
if (!row) return <div className="text-center">-</div>;
|
||||
const status = row.atrzPrgrsSttsComCd ?? "";
|
||||
const statusMap: Record<string, { label: string; badgeClass: string; icon: string }> = {
|
||||
ATRZ_PRGRS_001: { label: "대기", badgeClass: "bg-gray-100 text-gray-700", icon: "⏳" },
|
||||
ATRZ_PRGRS_002: { label: "요청", badgeClass: "bg-yellow-100 text-yellow-800", icon: "📨" },
|
||||
ATRZ_PRGRS_003: { label: "승인", badgeClass: "bg-green-100 text-green-800", icon: "✔️" },
|
||||
ATRZ_PRGRS_004: { label: "반려", badgeClass: "bg-red-100 text-red-800", icon: "✖️" },
|
||||
};
|
||||
const st = statusMap[status] ?? { label: status || "", badgeClass: "bg-gray-100 text-gray-700", icon: "" };
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="font-semibold">{row.aprvrUuidNm ?? "-"}</div>
|
||||
<div className="text-[0.95rem] text-gray-500">
|
||||
{row.ognzNm ? `${row.ognzNm}` : ""}
|
||||
{row.jbpsSeNm || row.jbgdSeNm ? ` · ${row.jbpsSeNm ?? ""}${row.jbgdSeNm ? ` ${row.jbgdSeNm}` : ""}` : ""}
|
||||
</div>
|
||||
<div className="text-[0.9rem] mt-1 flex items-center justify-center gap-2">
|
||||
{row.atrzDt ? <span className="text-gray-400">{dayjs(row.atrzDt).format("YYYY-MM-DD HH:mm:ss")}</span> : null}
|
||||
{st.label ? (
|
||||
<>
|
||||
<span
|
||||
title={`${row.atrzPrgrsSttsComNm ?? st.label}${row.rjctRsn ? ` — 사유: ${row.rjctRsn}` : ""}${row.atrzDt ? ` · ${dayjs(row.atrzDt).format("YYYY-MM-DD HH:mm:ss")}` : ""}`}
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-md text-xs font-semibold ml-1 ${st.badgeClass}`}
|
||||
>
|
||||
<span className="mr-1">{st.icon}</span>
|
||||
<span>{st.label}</span>
|
||||
</span>
|
||||
{status === "ATRZ_PRGRS_002" && userInfo?.userUnqId === row.aprvrUuid ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleApproveClick(row.elaprPrgrsId, row.atrzSeComCd)}
|
||||
className="ml-2 px-2 py-1 bg-blue-500 text-white text-xs rounded"
|
||||
>
|
||||
결제
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const currentPrgrsSttsComCd = (det.prgrsSttsComCd as string | undefined) ?? "ST";
|
||||
const roleBasedMaxStepCount = getRoleStepCount(userInfo?.dtlInstCd);
|
||||
const progressBasedMaxStepCount = getProgressStepCount(currentPrgrsSttsComCd);
|
||||
@@ -112,6 +264,13 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
[visibleStepCount],
|
||||
);
|
||||
const [selectedReviewStep, setSelectedReviewStep] = useState<ReviewStepCode>("LOCAL");
|
||||
// selected task code for header filtering (null = none selected)
|
||||
const [selectedTaskCode, setSelectedTaskCode] = useState<string | null>(null);
|
||||
|
||||
// derive available task codes from checklist results
|
||||
const taskCodes = useMemo(() => {
|
||||
return Array.from(new Set((cklstAll ?? []).map((r: any) => (r as any).taskSeComCd).filter(Boolean)));
|
||||
}, [cklstAll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visibleReviewStepCodes.length === 0) return;
|
||||
@@ -120,10 +279,103 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
}
|
||||
}, [selectedReviewStep, visibleReviewStepCodes]);
|
||||
|
||||
// when checklist result task codes change, default selectedTaskCode to first available code
|
||||
useEffect(() => {
|
||||
if (!taskCodes || taskCodes.length === 0) {
|
||||
if (selectedTaskCode !== null) setSelectedTaskCode(null);
|
||||
return;
|
||||
}
|
||||
if (!selectedTaskCode || !taskCodes.includes(selectedTaskCode)) {
|
||||
setSelectedTaskCode(taskCodes[0]);
|
||||
}
|
||||
}, [taskCodes, selectedTaskCode]);
|
||||
|
||||
const selectedChecklistRows = useMemo(() => {
|
||||
if (visibleReviewStepCodes.length === 0) return [];
|
||||
return cklstResultMap[selectedReviewStep] ?? [];
|
||||
}, [cklstResultMap, selectedReviewStep, visibleReviewStepCodes.length]);
|
||||
const rows = (cklstResultMap[selectedReviewStep] ?? []).filter((r: any) =>
|
||||
selectedTaskCode ? (r.taskSeComCd ?? "") === selectedTaskCode : true,
|
||||
);
|
||||
return rows
|
||||
.slice()
|
||||
.sort((a: any, b: any) =>
|
||||
(a.cklstArtclSn ?? 0) - (b.cklstArtclSn ?? 0) || (a.sortSeq ?? 0) - (b.sortSeq ?? 0) || (a.cklstRvwSn ?? 0) - (b.cklstRvwSn ?? 0),
|
||||
);
|
||||
}, [cklstResultMap, selectedReviewStep, visibleReviewStepCodes.length, selectedTaskCode]);
|
||||
|
||||
const [viewCheckResults, setViewCheckResults] = useState<Record<string, string>>({});
|
||||
const [viewCheckComments, setViewCheckComments] = useState<Record<string, string>>({});
|
||||
|
||||
const cklstStats = useMemo(() => {
|
||||
if (!selectedReviewStep) return { total: 0, completed: 0, percent: 0 };
|
||||
const rows = (cklstResultMap[selectedReviewStep] ?? []).filter((r: any) => r.bchckUseYn === "Y" && (selectedTaskCode ? (r.taskSeComCd ?? "") === selectedTaskCode : true));
|
||||
const total = rows.length;
|
||||
const completed = rows.reduce((acc: number, row: any) => {
|
||||
const key = `${row.taskSeComCd ?? ""}_${row.cklstSn}_${row.cklstArtclSn ?? ""}_${row.cklstRvwSn}_${selectedReviewStep}`;
|
||||
const val = (viewCheckResults[key] ?? row.rvwRsltAnmlyYn) ?? "";
|
||||
return acc + (val ? 1 : 0);
|
||||
}, 0);
|
||||
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
return { total, completed, percent };
|
||||
}, [cklstResultMap, viewCheckResults, selectedReviewStep, selectedTaskCode]);
|
||||
|
||||
const fnValidation = (mode: "SAVE" | "SUBMIT") => {
|
||||
// 기본 검증: 필요하면 확장
|
||||
return true;
|
||||
};
|
||||
|
||||
const hadleTechReviewSubmit = async () => {
|
||||
if (!fnValidation("SAVE")) return;
|
||||
|
||||
const cklstResultList = (cklstAll ?? [])
|
||||
.filter((r: any) => r.bchckUseYn === "Y")
|
||||
.map((row: any) => {
|
||||
const key = `${row.taskSeComCd ?? ""}_${row.cklstSn}_${row.cklstArtclSn ?? ""}_${row.cklstRvwSn}_${selectedReviewStep}`;
|
||||
const rvwRsltAnmlyYn = (viewCheckResults[key] ?? row.rvwRsltAnmlyYn) ?? null;
|
||||
const picRvwCn = (viewCheckComments[key] ?? row.picRvwCn) ?? null;
|
||||
return {
|
||||
taskSeComCd: row.taskSeComCd ?? "",
|
||||
cklstSn: row.cklstSn,
|
||||
cklstArtclSn: row.cklstArtclSn,
|
||||
cklstRvwSn: row.cklstRvwSn,
|
||||
rvwStpComCd: selectedReviewStep,
|
||||
rvwRsltAnmlyYn,
|
||||
picRvwCn,
|
||||
};
|
||||
});
|
||||
|
||||
const payload: InstlSncnApplySaveRequestDto = {
|
||||
// only map core management DTO and checklist results; other lists empty per request
|
||||
instlSncnMng: { ...(det ?? {}), prgrsSttsComCd: "ER" } as any,
|
||||
bzScaleList: [],
|
||||
bizCstList: [],
|
||||
cklstResultList,
|
||||
esntlAtchFileList: [],
|
||||
} as unknown as InstlSncnApplySaveRequestDto;
|
||||
|
||||
try {
|
||||
const issuedDocNo = await InstlSncnApi.techReview(payload);
|
||||
alert(`기술검토 등록이 완료되었습니다. 문서번호: ${issuedDocNo}`);
|
||||
} catch (error) {
|
||||
console.error("기술검토 등록 API 호출 실패", error);
|
||||
const serverMessage =
|
||||
axios.isAxiosError(error) && error.response?.data?.message
|
||||
? (error.response.data as { message: string }).message
|
||||
: "저장 중 오류가 발생했습니다.";
|
||||
alert(serverMessage);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const results: Record<string, string> = {};
|
||||
const comments: Record<string, string> = {};
|
||||
(selectedChecklistRows ?? []).forEach((row: any) => {
|
||||
const key = `${row.taskSeComCd ?? ""}_${row.cklstSn}_${row.cklstArtclSn ?? ""}_${row.cklstRvwSn}_${row.rvwStpComCd ?? "LOCAL"}`;
|
||||
if (row.rvwRsltAnmlyYn === "Y" || row.rvwRsltAnmlyYn === "N") results[key] = row.rvwRsltAnmlyYn;
|
||||
if (row.picRvwCn) comments[key] = String(row.picRvwCn);
|
||||
});
|
||||
setViewCheckResults(results);
|
||||
setViewCheckComments(comments);
|
||||
}, [selectedChecklistRows]);
|
||||
|
||||
if (query.isLoading) {
|
||||
return <div className="mt-6 text-[1.4rem]">상세 정보를 불러오는 중입니다...</div>;
|
||||
@@ -146,6 +398,58 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
|
||||
return (
|
||||
<div className="mt-6 space-y-4">
|
||||
{/* orderedApprovers.length > 0 && ( */}
|
||||
<div className="cnfm-card p-4">
|
||||
<h3 className="text-[1.5rem] font-semibold">결재 정보</h3>
|
||||
<div className="cnfm-table-wrap mt-3">
|
||||
<table className="cnfm-table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
{orderedApprovers.length === 0 ? (
|
||||
<>
|
||||
<th className="text-center">접수자</th>
|
||||
<th className="text-center">검토자</th>
|
||||
<th className="text-center">승인자</th>
|
||||
<th className="text-center">최종승인자</th>
|
||||
</>
|
||||
) : (
|
||||
orderedApprovers.map((r: any) => (
|
||||
<th key={String(r.elaprPrgrsId ?? r.atrzSeComCd)} className="text-center">
|
||||
{r.atrzSeComNm ?? r.atrzSeComCd}
|
||||
</th>
|
||||
))
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
{orderedApprovers.length === 0 ? (
|
||||
<>
|
||||
<td>{renderApproverCell("ATRZ01")}</td>
|
||||
<td>{renderApproverCell("ATRZ02")}</td>
|
||||
<td>{renderApproverCell("ATRZ03")}</td>
|
||||
<td>{renderApproverCell("ATRZ99")}</td>
|
||||
</>
|
||||
) : (
|
||||
orderedApprovers.map((r: any) => (
|
||||
<td key={String(r.elaprPrgrsId ?? r.atrzSeComCd)}>{renderApproverRow(r)}</td>
|
||||
))
|
||||
)}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{/** 결제자 지정 버튼 */}
|
||||
<div className="mt-4 text-right">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => handleAssignApproverClick("TMPLT20260611000001")}
|
||||
>
|
||||
결재자 지정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* )} */}
|
||||
<div className="cnfm-card p-4">
|
||||
<h2 className="cnfm-section-title">설치인가 상세보기</h2>
|
||||
<p className="mt-2 text-[1.3rem] text-gray-600">문서번호: <strong>{det.instlSncnDocNo}</strong></p>
|
||||
@@ -158,30 +462,30 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
<tr>
|
||||
<th>사업명</th>
|
||||
<td>{det.bizNm || "-"}</td>
|
||||
<th>사업기간</th>
|
||||
<td>{(det.bizBgngYr || "-") + " ~ " + (det.bizEndYr || "-")}</td>
|
||||
<th>사업기간</th>
|
||||
<td>{(det.bizBgngYr || "-") + " ~ " + (det.bizEndYr || "-")}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>사업내용</th>
|
||||
<td colSpan={3}>{det.bizCn || "-"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>업무구분</th>
|
||||
<th>구분</th>
|
||||
<td>{det.taskSeCdNm || det.taskSeComCd || "-"}</td>
|
||||
<th>행정동ID</th>
|
||||
<td>{det.admdstCd || "-"}</td>
|
||||
<th>신청지자체</th>
|
||||
<td>{det.sido || "-"} {det.gugun || "-"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>사업구분(인가구분)</th>
|
||||
<td colSpan={3}>{det.sncnSeComCd || "-"}</td>
|
||||
<th>사업구분</th>
|
||||
<td colSpan={3}>{det.sncnSeCdNm || "-"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>진행상태</th>
|
||||
<td>{det.prgrsSttsCdNm || det.prgrsSttsComCd || "-"}</td>
|
||||
<td>{det.prgrsSttsCdNm || det.prgrsSttsComCd || "-"}</td>
|
||||
<th>예산사업코드</th>
|
||||
<td>{det.bgtBizCd || "-"}</td>
|
||||
</tr>
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -302,7 +606,7 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{bizCstList.map((node: any) => {
|
||||
|
||||
|
||||
const pl = indentPx(node.indAcntgSeCdNm);
|
||||
const bscPlanAmt = Number(node.bscPlanAmt) || 0;
|
||||
const budgetReflct = Number(node.bgtRfltAmt) || 0;
|
||||
@@ -317,8 +621,8 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
node.treeLevel === 1
|
||||
? "bg-blue-50 font-bold"
|
||||
: node.treeLevel === 2
|
||||
? "bg-gray-100 font-semibold"
|
||||
: ""
|
||||
? "bg-gray-100 font-semibold"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{node.treeLevel <= 2 ? (
|
||||
@@ -361,8 +665,30 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
|
||||
{/* 체크리스트 */}
|
||||
<div className="cnfm-card p-4">
|
||||
<h3 className="text-[1.5rem] font-bold text-gray-700">체크리스트</h3>
|
||||
<div className="mb-3 mt-2 flex flex-wrap gap-2">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-[1.5rem] font-bold text-gray-700">체크리스트</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex gap-2 items-center">
|
||||
{Array.from(new Set(taskCodes)).map((code) => {
|
||||
const name = TASK_CODE_LABELS[code] ?? code;
|
||||
const active = selectedTaskCode === code;
|
||||
return (
|
||||
<button
|
||||
key={code}
|
||||
type="button"
|
||||
className={`rounded border px-3 py-1 text-[1.2rem] font-semibold ${active ? "border-blue-500 bg-blue-50 text-blue-700" : "border-gray-300 bg-white text-gray-600"
|
||||
}`}
|
||||
onClick={() => setSelectedTaskCode(code)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="text-[1.1rem] text-gray-500">{`(${cklstStats.completed}/${cklstStats.total})`}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
{visibleReviewStepCodes.length === 0 ? (
|
||||
<span className="text-[1.2rem] text-gray-500">
|
||||
현재 사용자 권한/진행상태 조합에서 표시 가능한 체크리스트 단계가 없습니다.
|
||||
@@ -372,11 +698,10 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
<button
|
||||
key={stepCode}
|
||||
type="button"
|
||||
className={`rounded border px-3 py-1 text-[1.2rem] font-semibold ${
|
||||
selectedReviewStep === stepCode
|
||||
? "border-blue-500 bg-blue-50 text-blue-700"
|
||||
: "border-gray-300 bg-white text-gray-600"
|
||||
}`}
|
||||
className={`rounded border px-3 py-1 text-[1.2rem] font-semibold ${selectedReviewStep === stepCode
|
||||
? "border-blue-500 bg-blue-50 text-blue-700"
|
||||
: "border-gray-300 bg-white text-gray-600"
|
||||
}`}
|
||||
onClick={() => setSelectedReviewStep(stepCode)}
|
||||
>
|
||||
{stepCode}
|
||||
@@ -384,17 +709,21 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-2 text-[1.25rem] text-gray-500">
|
||||
체크리스트 진행상황 [{selectedReviewStep}] — 총합: <strong>{cklstStats.total}</strong> · 완료: <strong>{cklstStats.completed}</strong> · 진행률: <strong>{cklstStats.percent}%</strong>
|
||||
</div>
|
||||
<div className="cnfm-table-wrap mt-3">
|
||||
<table className="cnfm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-[20rem]">검토항목</th>
|
||||
<th>주요 검토내용</th>
|
||||
<th colSpan={2}>검토 결과(-)</th>
|
||||
<th className="w-[20rem]" rowSpan={2}>검토항목</th>
|
||||
<th rowSpan={2}>주요 검토내용</th>
|
||||
<th colSpan={2}>검토 결과(✓)</th>
|
||||
{selectedReviewStep !== "LOCAL" ? (
|
||||
<th rowSpan={2} className="w-[20rem]">기술검토 의견</th>
|
||||
) : null}
|
||||
</tr>
|
||||
<tr>
|
||||
<th />
|
||||
<th />
|
||||
<th className="w-[9rem]">
|
||||
<button
|
||||
type="button"
|
||||
@@ -403,7 +732,7 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
</button>
|
||||
</th>
|
||||
<th className="w-[9rem]">
|
||||
<button
|
||||
<button
|
||||
type="button"
|
||||
>
|
||||
아니오
|
||||
@@ -414,13 +743,13 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
<tbody>
|
||||
{selectedChecklistRows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={3} className="cnfm-td-empty">조회된 체크리스트 항목이 없습니다.</td>
|
||||
<td colSpan={5} className="cnfm-td-empty">조회된 체크리스트 항목이 없습니다.</td>
|
||||
</tr>
|
||||
) : (
|
||||
selectedChecklistRows.map((row: any) => {
|
||||
const key = `${row.cklstSn}_${row.cklstRvwSn}_${row.rvwStpComCd ?? "STEP01"}`;
|
||||
const key = `${row.taskSeComCd ?? ""}_${row.cklstSn}_${row.cklstArtclSn ?? ""}_${row.cklstRvwSn}_${row.rvwStpComCd ?? "LOCAL"}`;
|
||||
const hasResult = row.rvwRsltAnmlyYn === "Y" || row.rvwRsltAnmlyYn === "N";
|
||||
const showRadios = row.bchckUseYn === "Y" || hasResult;
|
||||
const showRadios = row.bchckUseYn === "Y";
|
||||
|
||||
return (
|
||||
<tr key={key}>
|
||||
@@ -434,13 +763,36 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
) : (
|
||||
<>
|
||||
<td className="text-center">
|
||||
<input type="radio" name={`chk_${key}`} value="Y" checked={row.rvwRsltAnmlyYn === "Y"} disabled />
|
||||
<input
|
||||
type="radio"
|
||||
name={`chk_${key}`}
|
||||
value="Y"
|
||||
checked={(viewCheckResults[key] ?? "") === "Y"}
|
||||
disabled={selectedReviewStep === "LOCAL"}
|
||||
onChange={() => setViewCheckResults((prev) => ({ ...prev, [key]: "Y" }))}
|
||||
/>
|
||||
</td>
|
||||
<td className="text-center">
|
||||
<input type="radio" name={`chk_${key}`} value="N" checked={row.rvwRsltAnmlyYn === "N"} disabled />
|
||||
<input
|
||||
type="radio"
|
||||
name={`chk_${key}`}
|
||||
value="N"
|
||||
checked={(viewCheckResults[key] ?? "") === "N"}
|
||||
disabled={selectedReviewStep === "LOCAL"}
|
||||
onChange={() => setViewCheckResults((prev) => ({ ...prev, [key]: "N" }))}
|
||||
/>
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
{showRadios && selectedReviewStep !== "LOCAL" ? (
|
||||
<td className="text-left">
|
||||
<textarea
|
||||
className="cnfm-input w-full"
|
||||
value={viewCheckComments[key] ?? ""}
|
||||
onChange={(e) => setViewCheckComments((prev) => ({ ...prev, [key]: e.target.value }))}
|
||||
/>
|
||||
</td>
|
||||
) : null}
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
@@ -457,7 +809,7 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
<table className="cnfm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>필수 자료명</th>
|
||||
<th>필수 자료명</th>
|
||||
<th>파일명</th>
|
||||
<th className="w-[8rem] text-right">크기</th>
|
||||
</tr>
|
||||
@@ -530,6 +882,7 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
<div className="cnfm-action-row">
|
||||
<Button color="white" onClick={() => router.push("/instl-sncn")}>목록</Button>
|
||||
<Button onClick={() => router.push(`/instl-sncn/${id}/modify`)}>수정</Button>
|
||||
<Button onClick={() => void hadleTechReviewSubmit()}>기술검토등록</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { RvwMnpwMngPage } from "./ui/rvw-mnpw-mng-page";
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { RvwMnpwMngResponseDto } from "@/entities/rvw-mnpw";
|
||||
|
||||
import type { RvwMnpwBlockConfig } from "../model/rvw-mnpw-block-config";
|
||||
|
||||
export function groupRvwMnpwByAtrzSe(
|
||||
items: RvwMnpwMngResponseDto[],
|
||||
blocks: RvwMnpwBlockConfig[],
|
||||
): Record<string, RvwMnpwMngResponseDto[]> {
|
||||
const grouped = Object.fromEntries(
|
||||
blocks.map((block) => [block.atrzSeComCd, [] as RvwMnpwMngResponseDto[]]),
|
||||
);
|
||||
|
||||
for (const item of items) {
|
||||
grouped[item.atrzSeComCd]?.push(item);
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface RvwMnpwBlockConfig {
|
||||
id: string;
|
||||
title: string;
|
||||
atrzSeComCd: string;
|
||||
}
|
||||
|
||||
/** 결재선 관리 validators.ts ATRZ 코드와 동일 */
|
||||
export const RVW_MNPW_BLOCKS: RvwMnpwBlockConfig[] = [
|
||||
{ id: "receiver", title: "접수", atrzSeComCd: "ATRZ01" },
|
||||
{ id: "reviewer", title: "검토", atrzSeComCd: "ATRZ02" },
|
||||
{ id: "approver", title: "승인", atrzSeComCd: "ATRZ03" },
|
||||
{ id: "final", title: "최종승인", atrzSeComCd: "ATRZ99" },
|
||||
];
|
||||
|
||||
export const RVW_MNPW_PAGE_SIZE = 500;
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { useOgnzUserTree } from "@/entities/user-organization";
|
||||
import {
|
||||
collectExpandedOgnzSnIds,
|
||||
filterOgnzUserTree,
|
||||
OgnzUserTree,
|
||||
type SelectedOgnzUser,
|
||||
} from "@/widgets/ognz-user-tree";
|
||||
import { Button, Input } from "@common/components";
|
||||
|
||||
interface OgnzPanelProps {
|
||||
selectedPerson: SelectedOgnzUser | null;
|
||||
onSelectPerson: (person: SelectedOgnzUser) => void;
|
||||
}
|
||||
|
||||
function OgnzPanel({ selectedPerson, onSelectPerson }: OgnzPanelProps) {
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [displayTree, setDisplayTree] = useState<ReturnType<typeof filterOgnzUserTree>>([]);
|
||||
const [expandedOgnzSnIds, setExpandedOgnzSnIds] = useState<Set<number>>(new Set());
|
||||
const [isSearchActive, setIsSearchActive] = useState(false);
|
||||
|
||||
const { data, isPending, isError } = useOgnzUserTree();
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayTree(data ?? []);
|
||||
}, [data]);
|
||||
|
||||
const handleToggle = useCallback((ognzSn: number, isExpanded: boolean) => {
|
||||
setExpandedOgnzSnIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (isExpanded) next.add(ognzSn);
|
||||
else next.delete(ognzSn);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const filtered = filterOgnzUserTree(data ?? [], keyword);
|
||||
setDisplayTree(filtered);
|
||||
setExpandedOgnzSnIds(collectExpandedOgnzSnIds(filtered));
|
||||
setIsSearchActive(keyword.trim().length > 0);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setKeyword("");
|
||||
setDisplayTree(data ?? []);
|
||||
setExpandedOgnzSnIds(new Set());
|
||||
setIsSearchActive(false);
|
||||
};
|
||||
|
||||
const handleSelect = (person: SelectedOgnzUser) => {
|
||||
if (selectedPerson?.userUuid === person.userUuid) return;
|
||||
onSelectPerson(person);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="card card-border mx-0 flex h-full flex-col pt-0">
|
||||
<div className="card-header">
|
||||
<h3 className="text-2xl font-semibold">조직도</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<form onSubmit={handleSubmit} className="flex items-center gap-2">
|
||||
<Input
|
||||
className="!text-[1.3rem]"
|
||||
placeholder="성명·부서 검색"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
/>
|
||||
<Button type="submit">조회</Button>
|
||||
<Button type="button" color="white" onClick={handleReset}>
|
||||
초기화
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="min-h-[32rem] flex-1 overflow-auto">
|
||||
<OgnzUserTree
|
||||
nodes={displayTree}
|
||||
isPending={isPending}
|
||||
isError={isError}
|
||||
isSearchActive={isSearchActive}
|
||||
expandedOgnzSnIds={expandedOgnzSnIds}
|
||||
selectedUserId={selectedPerson?.userUuid}
|
||||
onToggle={handleToggle}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export { OgnzPanel };
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useRvwMnpwMngPage } from "@/entities/rvw-mnpw";
|
||||
import type { SelectedOgnzUser } from "@/widgets/ognz-user-tree";
|
||||
import { Skeleton } from "@common/components";
|
||||
|
||||
import { groupRvwMnpwByAtrzSe } from "../lib/group-rvw-mnpw-by-atrz-se";
|
||||
import { RVW_MNPW_BLOCKS, RVW_MNPW_PAGE_SIZE } from "../model/rvw-mnpw-block-config";
|
||||
|
||||
import { RvwMnpwBlock } from "./rvw-mnpw-block";
|
||||
|
||||
interface RvwMnpwAsmtPanelProps {
|
||||
selectedPerson: SelectedOgnzUser | null;
|
||||
}
|
||||
|
||||
function RvwMnpwAsmtPanel({ selectedPerson }: RvwMnpwAsmtPanelProps) {
|
||||
const pageQuery = useRvwMnpwMngPage({
|
||||
page: 0,
|
||||
size: RVW_MNPW_PAGE_SIZE,
|
||||
sort: "atrzSeComCd,asc",
|
||||
});
|
||||
|
||||
const groupedItems = useMemo(
|
||||
() => groupRvwMnpwByAtrzSe(pageQuery.data?.content ?? [], RVW_MNPW_BLOCKS),
|
||||
[pageQuery.data?.content],
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="card card-border mx-0 flex h-full flex-col pt-0">
|
||||
<div className="card-header">
|
||||
<h3 className="text-2xl font-semibold">인원 배치</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
{pageQuery.isPending ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-[8rem]" />
|
||||
<Skeleton className="h-[8rem]" />
|
||||
<Skeleton className="h-[8rem]" />
|
||||
<Skeleton className="h-[8rem]" />
|
||||
</div>
|
||||
) : pageQuery.isError ? (
|
||||
<p className="py-8 text-center text-lg text-muted-foreground">
|
||||
인원 배치 목록을 불러오지 못했습니다.
|
||||
</p>
|
||||
) : (
|
||||
RVW_MNPW_BLOCKS.map((block) => (
|
||||
<RvwMnpwBlock
|
||||
key={block.id}
|
||||
block={block}
|
||||
items={groupedItems[block.atrzSeComCd] ?? []}
|
||||
selectedPerson={selectedPerson}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export { RvwMnpwAsmtPanel };
|
||||
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import type { RvwMnpwMngResponseDto } from "@/entities/rvw-mnpw";
|
||||
import { useDeleteRvwMnpwMng, useSaveRvwMnpwMng } from "@/entities/rvw-mnpw";
|
||||
import type { SelectedOgnzUser } from "@/widgets/ognz-user-tree";
|
||||
import { Badge, Button } from "@common/components";
|
||||
|
||||
import type { RvwMnpwBlockConfig } from "../model/rvw-mnpw-block-config";
|
||||
|
||||
interface RvwMnpwBlockProps {
|
||||
block: RvwMnpwBlockConfig;
|
||||
items: RvwMnpwMngResponseDto[];
|
||||
selectedPerson: SelectedOgnzUser | null;
|
||||
}
|
||||
|
||||
function RvwMnpwBlock({ block, items, selectedPerson }: RvwMnpwBlockProps) {
|
||||
const { confirm } = useConfirm();
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const saveMutation = useSaveRvwMnpwMng();
|
||||
const deleteMutation = useDeleteRvwMnpwMng();
|
||||
|
||||
const handleAssign = async () => {
|
||||
if (!selectedPerson) return;
|
||||
|
||||
try {
|
||||
await saveMutation.mutateAsync({
|
||||
atrzSeComCd: block.atrzSeComCd,
|
||||
ognzSn: selectedPerson.ognzSn,
|
||||
userUuid: selectedPerson.userUuid,
|
||||
});
|
||||
alert("배치되었습니다.");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "배치에 실패했습니다.";
|
||||
alert(message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (item: RvwMnpwMngResponseDto) => {
|
||||
if (!(await confirm("해당 인원을 배치에서 제거하시겠습니까?"))) return;
|
||||
|
||||
try {
|
||||
await deleteMutation.mutateAsync({
|
||||
atrzSeComCd: item.atrzSeComCd,
|
||||
ognzSn: item.ognzSn,
|
||||
userUuid: item.userUuid,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "제거에 실패했습니다.";
|
||||
alert(message);
|
||||
}
|
||||
};
|
||||
|
||||
const isAssigning = saveMutation.isPending;
|
||||
const isRemoving = deleteMutation.isPending;
|
||||
|
||||
return (
|
||||
<section className="rounded-md border border-border bg-background p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-xl font-semibold">{block.title}</h4>
|
||||
<Badge color="gray">{items.length}</Badge>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="green"
|
||||
disabled={!selectedPerson || isAssigning}
|
||||
onClick={handleAssign}
|
||||
>
|
||||
선택 인원 배치
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p className="py-4 text-center text-lg text-muted-foreground">배치된 인원이 없습니다.</p>
|
||||
) : (
|
||||
<ul className="grid grid-cols-4 gap-2">
|
||||
{items.map((item) => (
|
||||
<li
|
||||
key={`${item.atrzSeComCd}-${item.ognzSn}-${item.userUuid}`}
|
||||
className="flex items-center justify-between gap-3 rounded-sm border border-border px-3 py-2"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<p className="text-[1.4rem] font-medium text-label">
|
||||
{item.userUuidNm || item.userUuid}
|
||||
</p>
|
||||
{item.ognzNm && item.jbpsSeNm && (
|
||||
<span className="text-lg text-muted-foreground">
|
||||
({item.ognzNm} · {item.jbpsSeNm})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="white"
|
||||
disabled={isRemoving}
|
||||
onClick={() => handleRemove(item)}
|
||||
>
|
||||
제거
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export { RvwMnpwBlock };
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import type { SelectedOgnzUser } from "@/widgets/ognz-user-tree";
|
||||
|
||||
import { OgnzPanel } from "./ognz-panel";
|
||||
import { RvwMnpwAsmtPanel } from "./rvw-mnpw-asmt-panel";
|
||||
|
||||
function RvwMnpwMngPage() {
|
||||
const [selectedPerson, setSelectedPerson] = useState<SelectedOgnzUser | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-[1.8rem] pb-6">
|
||||
{/* TBD: 역할별 CRUD 권한 — 메뉴·API 권한 연동 후 버튼 비활성 처리 */}
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(280px,1fr)_minmax(0,2.5fr)]">
|
||||
<OgnzPanel selectedPerson={selectedPerson} onSelectPerson={setSelectedPerson} />
|
||||
<RvwMnpwAsmtPanel selectedPerson={selectedPerson} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { RvwMnpwMngPage };
|
||||
@@ -7,6 +7,7 @@ export const ENDPOINT = {
|
||||
ACNTG_SE: "/as-service/api/v1/acntg-se",
|
||||
CNSTP_SE: "/as-service/api/v1/cnstp-se",
|
||||
ESNTL_ATCH_FILE: "/as-service/api/v1/esntl-atch-file",
|
||||
RVW_MNPW: "/as-service/api/v1/rvw-mnpw",
|
||||
},
|
||||
AS_INSTL_SERVICE: {
|
||||
INSTL_SNCN: "/as-service/api/v1/instl-sncn",
|
||||
@@ -19,6 +20,7 @@ export const ENDPOINT = {
|
||||
AS_ELAPR_SERVICE: {
|
||||
ELAPR_TMPLT: "/as-elapr-service/api/v1/elapr-tmplt",
|
||||
ELAPR_APLN: "/as-elapr-service/api/v1/elapr-apln",
|
||||
ELAPR_DMND: "/as-elapr-service/api/v1/elapr-dmnd",
|
||||
},
|
||||
AS_CHECKLIST_SERVICE: {
|
||||
CKLST: "/as-service/api/v1/cklst",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export { collectExpandedOgnzSnIds, filterOgnzUserTree } from "./lib/filter-ognz-user-tree";
|
||||
export { type SelectedOgnzUser, toSelectedOgnzUser } from "./model/selected-ognz-user";
|
||||
export { OgnzUserTree } from "./ui/ognz-user-tree";
|
||||
export { OgnzUserTreeNode } from "./ui/ognz-user-tree-node";
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { OgnzUserTreeResponseDto, OgnzUserTreeUserDto } from "@/entities/user-organization";
|
||||
|
||||
export interface SelectedOgnzUser {
|
||||
userUuid: string;
|
||||
userNm: string;
|
||||
ognzSn: number;
|
||||
ognzNm: string;
|
||||
jbgdSeCd?: string;
|
||||
jbpsSeCd?: string;
|
||||
}
|
||||
|
||||
export function toSelectedOgnzUser(
|
||||
user: OgnzUserTreeUserDto,
|
||||
node: Pick<OgnzUserTreeResponseDto, "ognzSn" | "ognzNm">,
|
||||
): SelectedOgnzUser {
|
||||
return {
|
||||
userUuid: user.userUnqId,
|
||||
userNm: user.userNm,
|
||||
ognzSn: node.ognzSn,
|
||||
ognzNm: node.ognzNm,
|
||||
jbgdSeCd: user.jbgdSeCd || undefined,
|
||||
jbpsSeCd: user.jbpsSeCd || undefined,
|
||||
};
|
||||
}
|
||||
+22
-16
@@ -11,16 +11,16 @@ import {
|
||||
CollapsibleTrigger,
|
||||
} from "@common/components";
|
||||
|
||||
import type { ReviewerOption } from "../model/reviewer-option";
|
||||
import { toReviewerOption } from "../model/reviewer-option";
|
||||
import { type SelectedOgnzUser, toSelectedOgnzUser } from "../model/selected-ognz-user";
|
||||
|
||||
interface OgnzUserTreeNodeProps {
|
||||
node: OgnzUserTreeResponseDto;
|
||||
depth?: number;
|
||||
expandedOgnzSnIds: Set<number>;
|
||||
selectedUserId?: string;
|
||||
showSelectButton?: boolean;
|
||||
onToggle: (ognzSn: number, open: boolean) => void;
|
||||
onSelect: (reviewer: ReviewerOption) => void;
|
||||
onSelect: (user: SelectedOgnzUser) => void;
|
||||
}
|
||||
|
||||
function OgnzUserTreeNode({
|
||||
@@ -28,6 +28,7 @@ function OgnzUserTreeNode({
|
||||
depth = 0,
|
||||
expandedOgnzSnIds,
|
||||
selectedUserId,
|
||||
showSelectButton = false,
|
||||
onToggle,
|
||||
onSelect,
|
||||
}: OgnzUserTreeNodeProps) {
|
||||
@@ -71,28 +72,32 @@ function OgnzUserTreeNode({
|
||||
<CollapsibleContent>
|
||||
{node.users.map((user) => {
|
||||
const isSelected = selectedUserId === user.userUnqId;
|
||||
const selectedUser = toSelectedOgnzUser(user, node);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={user.userUnqId}
|
||||
style={{ paddingInlineStart: indent + 24 }}
|
||||
className={cn(
|
||||
"group flex cursor-pointer items-center justify-between gap-2 rounded-sm p-2 hover:bg-accent",
|
||||
isSelected && "bg-accent",
|
||||
"group flex cursor-pointer items-center gap-2 rounded-sm p-2 hover:bg-accent",
|
||||
isSelected && "!border !border-primary !bg-primary-transparent",
|
||||
)}
|
||||
onClick={() => onSelect(toReviewerOption(user, node.ognzNm))}
|
||||
onClick={() => onSelect(selectedUser)}
|
||||
>
|
||||
<span className="text-[1.4rem] text-label">{user.userNm}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(toReviewerOption(user, node.ognzNm));
|
||||
}}
|
||||
>
|
||||
선택
|
||||
</Button>
|
||||
<span className="text-xl text-muted-foreground">({user.jbpsSeNm})</span>
|
||||
{showSelectButton ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(selectedUser);
|
||||
}}
|
||||
>
|
||||
선택
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -104,6 +109,7 @@ function OgnzUserTreeNode({
|
||||
depth={depth + 1}
|
||||
expandedOgnzSnIds={expandedOgnzSnIds}
|
||||
selectedUserId={selectedUserId}
|
||||
showSelectButton={showSelectButton}
|
||||
onToggle={onToggle}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
+8
-3
@@ -3,7 +3,7 @@
|
||||
import type { OgnzUserTreeResponseDto } from "@/entities/user-organization";
|
||||
import { Skeleton } from "@common/components";
|
||||
|
||||
import type { ReviewerOption } from "../model/reviewer-option";
|
||||
import type { SelectedOgnzUser } from "../model/selected-ognz-user";
|
||||
|
||||
import { OgnzUserTreeNode } from "./ognz-user-tree-node";
|
||||
|
||||
@@ -11,18 +11,22 @@ interface OgnzUserTreeProps {
|
||||
nodes: OgnzUserTreeResponseDto[];
|
||||
isPending?: boolean;
|
||||
isError?: boolean;
|
||||
isSearchActive?: boolean;
|
||||
expandedOgnzSnIds: Set<number>;
|
||||
selectedUserId?: string;
|
||||
showSelectButton?: boolean;
|
||||
onToggle: (ognzSn: number, open: boolean) => void;
|
||||
onSelect: (reviewer: ReviewerOption) => void;
|
||||
onSelect: (user: SelectedOgnzUser) => void;
|
||||
}
|
||||
|
||||
function OgnzUserTree({
|
||||
nodes,
|
||||
isPending,
|
||||
isError,
|
||||
isSearchActive = false,
|
||||
expandedOgnzSnIds,
|
||||
selectedUserId,
|
||||
showSelectButton = false,
|
||||
onToggle,
|
||||
onSelect,
|
||||
}: OgnzUserTreeProps) {
|
||||
@@ -47,7 +51,7 @@ function OgnzUserTree({
|
||||
if (nodes.length === 0) {
|
||||
return (
|
||||
<p className="py-8 text-center text-lg text-muted-foreground">
|
||||
등록된 조직/사용자가 없습니다.
|
||||
{isSearchActive ? "조회된 인원이 없습니다." : "등록된 조직/사용자가 없습니다."}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -60,6 +64,7 @@ function OgnzUserTree({
|
||||
node={node}
|
||||
expandedOgnzSnIds={expandedOgnzSnIds}
|
||||
selectedUserId={selectedUserId}
|
||||
showSelectButton={showSelectButton}
|
||||
onToggle={onToggle}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
@@ -37,7 +37,7 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.474.0",
|
||||
"next": "15.3.8",
|
||||
"next": "15.5.18",
|
||||
"next-qrcode": "^2.5.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
@@ -63,4 +63,4 @@
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"chart.js": "^4.4.8",
|
||||
"dayjs": "^1.11.13",
|
||||
"js-cookie": "^3.0.5",
|
||||
"next": "15.3.8",
|
||||
"next": "15.5.18",
|
||||
"next-themes": "^0.4.6",
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
"react-dnd": "^16.0.1",
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.474.0",
|
||||
"next": "15.3.8",
|
||||
"next": "15.5.18",
|
||||
"next-qrcode": "^2.5.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { PageableList, PageableParams } from "@/shared/model";
|
||||
|
||||
export const USER_STATE_CODE = {
|
||||
@@ -153,6 +154,7 @@ export const userUpdateSchema = z.object({
|
||||
autzrId: z.string().uuid().optional(),
|
||||
expryYmd: z.string().max(8),
|
||||
instSeCd: z.string().optional(),
|
||||
instSeNm: z.string().optional(),
|
||||
dtlInstCd: z.string().optional(),
|
||||
aprvYmd: z.string().max(8).optional(),
|
||||
enoCd: z.string().optional(),
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
getCoreRowModel,
|
||||
type PaginationState,
|
||||
type Row,
|
||||
type RowSelectionState,
|
||||
type Table,
|
||||
type Row,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useAlertStore, useDataTable } from "@/shared/lib";
|
||||
import { GroupApi } from "@/entities/group";
|
||||
import { GroupUsersApi } from "@/entities/group-user";
|
||||
import {
|
||||
type AdminUserListResponse,
|
||||
UserApi,
|
||||
type UserSearchParams,
|
||||
} from "@/entities/user";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
@@ -19,18 +31,10 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Checkbox,
|
||||
} from "@common/components";
|
||||
import { GroupApi } from "@/entities/group";
|
||||
import {
|
||||
UserApi,
|
||||
type UserSearchParams,
|
||||
type AdminUserListResponse,
|
||||
} from "@/entities/user";
|
||||
import { GroupUsersApi } from "@/entities/group-user";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useDataTable, useAlertStore } from "@/shared/lib";
|
||||
|
||||
import { columns } from "../model/table-columns";
|
||||
|
||||
import { AddAdminDialog } from "./add-admin-dialog";
|
||||
|
||||
function AdminPage() {
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
getCoreRowModel,
|
||||
type OnChangeFn,
|
||||
type PaginationState,
|
||||
type RowSelectionState,
|
||||
} from "@tanstack/react-table";
|
||||
import { Button, Input, Label } from "@common/components";
|
||||
import { POSTING_TYPE, PostingDialog } from "@/entities/posting";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useDataTable } from "@/shared/lib";
|
||||
import { useDataTable, useSearchParamsState } from "@/shared/lib";
|
||||
import { POSTING_TYPE, PostingDialog } from "@/entities/posting";
|
||||
import { Button, Input, Label } from "@common/components";
|
||||
|
||||
import { BannerApi } from "../api/banner-service";
|
||||
import { type Banner } from "../model/banner-interface";
|
||||
import { createColumns } from "../model/banner-table-columns";
|
||||
import { type Banner, type BannerParams } from "../model/banner-interface";
|
||||
|
||||
import { PreviewBannerDialog } from "./preview-banner-dialog";
|
||||
|
||||
type PostingTarget = {
|
||||
@@ -22,50 +27,52 @@ type PostingTarget = {
|
||||
pstgTrgtNo: number;
|
||||
};
|
||||
|
||||
const DEFAULT_PARAMS = {
|
||||
bnrTtl: "",
|
||||
sort: "regDt,desc",
|
||||
page: 0,
|
||||
size: 10,
|
||||
};
|
||||
|
||||
function BannerPage() {
|
||||
const router = useRouter();
|
||||
const imageRefs = useRef<Map<number, string>>(new Map());
|
||||
|
||||
const { params, setParams, resetParams } =
|
||||
useSearchParamsState(DEFAULT_PARAMS);
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [postingTargets, setPostingTargets] = useState<PostingTarget[]>([]);
|
||||
const [bnrTtl, setBnrTtl] = useState("");
|
||||
const [searchParams, setSearchParams] = useState<BannerParams>({
|
||||
bnrTtl: "",
|
||||
sort: "regDt,desc",
|
||||
});
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
const [bnrTtl, setBnrTtl] = useState(params.bnrTtl);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.BANNERS, searchParams, pagination],
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.BANNERS, params],
|
||||
queryFn: () =>
|
||||
BannerApi.getBannerList({
|
||||
...searchParams,
|
||||
page: pagination.pageIndex,
|
||||
size: pagination.pageSize,
|
||||
bnrTtl: params.bnrTtl,
|
||||
sort: params.sort,
|
||||
page: params.page,
|
||||
size: params.size,
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||
const pagination: PaginationState = {
|
||||
pageIndex: params.page,
|
||||
pageSize: params.size,
|
||||
};
|
||||
|
||||
const handlePaginationChange: OnChangeFn<PaginationState> = (updater) => {
|
||||
const next = typeof updater === "function" ? updater(pagination) : updater;
|
||||
setParams({ page: next.pageIndex, size: next.pageSize });
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setPagination({
|
||||
...pagination,
|
||||
pageIndex: 0,
|
||||
});
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
bnrTtl,
|
||||
});
|
||||
setParams({ bnrTtl, page: 0 });
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setBnrTtl("");
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
bnrTtl: "",
|
||||
});
|
||||
resetParams();
|
||||
};
|
||||
|
||||
const handleRowClick = (row: Banner) => {
|
||||
@@ -92,7 +99,7 @@ function BannerPage() {
|
||||
manualPagination: true,
|
||||
enableMultiRowSelection: true,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
onPaginationChange: setPagination,
|
||||
onPaginationChange: handlePaginationChange,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
},
|
||||
dataTableOptions: {
|
||||
|
||||
@@ -2,12 +2,19 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
getCoreRowModel,
|
||||
type OnChangeFn,
|
||||
type PaginationState,
|
||||
type RowSelectionState,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useDataTable, useSearchParamsState } from "@/shared/lib";
|
||||
import { type Board, BoardApi } from "@/entities/board";
|
||||
import { CodeSelect } from "@/entities/code";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
@@ -18,60 +25,57 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@common/components";
|
||||
import { CodeSelect } from "@/entities/code";
|
||||
import { BoardApi, type BoardParams } from "@/entities/board";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useDataTable } from "@/shared/lib";
|
||||
import { type Board } from "@/entities/board";
|
||||
|
||||
import { columns } from "../model/table-columns";
|
||||
|
||||
const DEFAULT_PARAMS = {
|
||||
bbsTypeCd: "",
|
||||
bbsNm: "",
|
||||
sort: "regDt,desc",
|
||||
page: 0,
|
||||
size: 10,
|
||||
};
|
||||
|
||||
function BbsPage() {
|
||||
const router = useRouter();
|
||||
|
||||
const { params, setParams, resetParams } =
|
||||
useSearchParamsState(DEFAULT_PARAMS);
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [bbsTypeCd, setBbsTypeCd] = useState("");
|
||||
const [bbsNm, setBbsNm] = useState("");
|
||||
const [searchParams, setSearchParams] = useState<BoardParams>({
|
||||
bbsTypeCd: "",
|
||||
bbsNm: "",
|
||||
sort: "regDt,desc",
|
||||
});
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
const [bbsTypeCd, setBbsTypeCd] = useState(params.bbsTypeCd);
|
||||
const [bbsNm, setBbsNm] = useState(params.bbsNm);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [ENDPOINT.BOARD_SERVICE.BBS, searchParams, pagination],
|
||||
queryKey: [ENDPOINT.BOARD_SERVICE.BBS, params],
|
||||
queryFn: () =>
|
||||
BoardApi.getBoardList({
|
||||
...searchParams,
|
||||
page: pagination.pageIndex,
|
||||
size: pagination.pageSize,
|
||||
bbsTypeCd: params.bbsTypeCd,
|
||||
bbsNm: params.bbsNm,
|
||||
sort: params.sort,
|
||||
page: params.page,
|
||||
size: params.size,
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||
const pagination: PaginationState = {
|
||||
pageIndex: params.page,
|
||||
pageSize: params.size,
|
||||
};
|
||||
|
||||
const handlePaginationChange: OnChangeFn<PaginationState> = (updater) => {
|
||||
const next = typeof updater === "function" ? updater(pagination) : updater;
|
||||
setParams({ page: next.pageIndex, size: next.pageSize });
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setPagination({
|
||||
...pagination,
|
||||
pageIndex: 0,
|
||||
});
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
bbsTypeCd,
|
||||
bbsNm,
|
||||
});
|
||||
setParams({ bbsTypeCd, bbsNm, page: 0 });
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setBbsTypeCd("");
|
||||
setBbsNm("");
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
bbsTypeCd: "",
|
||||
bbsNm: "",
|
||||
});
|
||||
resetParams();
|
||||
};
|
||||
|
||||
const handleRowClick = (row: Board) => {
|
||||
@@ -89,7 +93,7 @@ function BbsPage() {
|
||||
manualPagination: true,
|
||||
enableMultiRowSelection: true,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
onPaginationChange: setPagination,
|
||||
onPaginationChange: handlePaginationChange,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
},
|
||||
dataTableOptions: {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import dayjs from "dayjs";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { Button } from "@common/components";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import type { UpCode } from "@/entities/code";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import { UpCodeManagementDialog } from "../ui/upcode-dialog";
|
||||
|
||||
export const columns: ColumnDef<UpCode>[] = [
|
||||
@@ -24,7 +27,7 @@ export const columns: ColumnDef<UpCode>[] = [
|
||||
header: "상위코드명",
|
||||
},
|
||||
{
|
||||
accessorKey: "cdSe",
|
||||
accessorKey: "cdSeNm",
|
||||
header: "코드구분",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,60 +2,64 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
|
||||
import { Button, Input, Label } from "@common/components";
|
||||
import {
|
||||
ContentsApi,
|
||||
type Contents,
|
||||
type ContentsParams,
|
||||
} from "@/entities/content";
|
||||
getCoreRowModel,
|
||||
type OnChangeFn,
|
||||
type PaginationState,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useDataTable } from "@/shared/lib";
|
||||
import { useDataTable, useSearchParamsState } from "@/shared/lib";
|
||||
import { type Contents, ContentsApi } from "@/entities/content";
|
||||
import { Button, Input, Label } from "@common/components";
|
||||
|
||||
import { columns } from "../model/table-columns";
|
||||
|
||||
const DEFAULT_PARAMS = {
|
||||
contsTtl: "",
|
||||
sort: "regDt,desc",
|
||||
page: 0,
|
||||
size: 10,
|
||||
};
|
||||
|
||||
function ContentsPage() {
|
||||
const router = useRouter();
|
||||
|
||||
const [contsTtl, setContsTtl] = useState("");
|
||||
const [searchParams, setSearchParams] = useState<ContentsParams>({
|
||||
contsTtl: "",
|
||||
sort: "regDt,desc",
|
||||
});
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
const { params, setParams, resetParams } =
|
||||
useSearchParamsState(DEFAULT_PARAMS);
|
||||
const [contsTtl, setContsTtl] = useState(params.contsTtl);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.CONTENTS, searchParams, pagination],
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.CONTENTS, params],
|
||||
queryFn: () =>
|
||||
ContentsApi.getContentsList({
|
||||
...searchParams,
|
||||
page: pagination.pageIndex,
|
||||
size: pagination.pageSize,
|
||||
contsTtl: params.contsTtl,
|
||||
sort: params.sort,
|
||||
page: params.page,
|
||||
size: params.size,
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||
const pagination: PaginationState = {
|
||||
pageIndex: params.page,
|
||||
pageSize: params.size,
|
||||
};
|
||||
|
||||
const handlePaginationChange: OnChangeFn<PaginationState> = (updater) => {
|
||||
const next = typeof updater === "function" ? updater(pagination) : updater;
|
||||
setParams({ page: next.pageIndex, size: next.pageSize });
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setPagination({
|
||||
...pagination,
|
||||
pageIndex: 0,
|
||||
});
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
contsTtl,
|
||||
});
|
||||
setParams({ contsTtl, page: 0 });
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setContsTtl("");
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
contsTtl: "",
|
||||
});
|
||||
resetParams();
|
||||
};
|
||||
|
||||
const handleRowClick = (row: Contents) => {
|
||||
@@ -69,7 +73,7 @@ function ContentsPage() {
|
||||
state: {
|
||||
pagination,
|
||||
},
|
||||
onPaginationChange: setPagination,
|
||||
onPaginationChange: handlePaginationChange,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
},
|
||||
|
||||
@@ -354,7 +354,7 @@ function DashboardPage() {
|
||||
<div className="card-header text-foreground">
|
||||
<h3 className="text-2xl font-medium">실시간 알림</h3>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="p-4" style={{ height: "120px", overflowY: "auto" }}>
|
||||
<div className="space-y-4 text-foreground">
|
||||
{noticeUserList?.map((item) => (
|
||||
<div
|
||||
@@ -373,7 +373,7 @@ function DashboardPage() {
|
||||
<div className="card-header text-foreground">
|
||||
<h3 className="text-2xl font-medium">일정 알림</h3>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="p-4" style={{ height: "120px", overflowY: "auto" }}>
|
||||
<div className="space-y-4 text-foreground">
|
||||
{scheduleList && scheduleList.length > 0 ? (
|
||||
scheduleList.map((schedule, index) => {
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import { Button, Input, Label } from "@common/components";
|
||||
import { AttachmentApi, type AttachmentParams } from "@/entities/attachment";
|
||||
import { useAlertStore, useDataTable, useMutationWrapper } from "@/shared/lib";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useAlertStore, useDataTable, useMutationWrapper } from "@/shared/lib";
|
||||
import { AttachmentApi, type AttachmentParams } from "@/entities/attachment";
|
||||
import { Button, Input, Label } from "@common/components";
|
||||
|
||||
import { createColumns } from "../model/table-columns";
|
||||
|
||||
function FilePage() {
|
||||
|
||||
@@ -2,68 +2,73 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
getCoreRowModel,
|
||||
type OnChangeFn,
|
||||
type PaginationState,
|
||||
type RowSelectionState,
|
||||
} from "@tanstack/react-table";
|
||||
import { Button, Input, Label } from "@common/components";
|
||||
import { POSTING_TYPE, PostingDialog } from "@/entities/posting";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useDataTable } from "@/shared/lib";
|
||||
import { useDataTable, useSearchParamsState } from "@/shared/lib";
|
||||
import { POSTING_TYPE, PostingDialog } from "@/entities/posting";
|
||||
import { Button, Input, Label } from "@common/components";
|
||||
|
||||
import { ImageApi } from "../api/image-service";
|
||||
import { type Image } from "../model/image-interface";
|
||||
import { columns } from "../model/image-table-columns";
|
||||
import { type Image, type ImageParams } from "../model/image-interface";
|
||||
|
||||
type PostingTarget = {
|
||||
pstgTrgtNm: string;
|
||||
pstgTrgtNo: number;
|
||||
};
|
||||
|
||||
const DEFAULT_PARAMS = {
|
||||
keyword: "",
|
||||
sort: "regDt,desc",
|
||||
page: 0,
|
||||
size: 10,
|
||||
};
|
||||
|
||||
function ImagePage() {
|
||||
const router = useRouter();
|
||||
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [searchParams, setSearchParams] = useState<ImageParams>({
|
||||
keyword: "",
|
||||
sort: "regDt,desc",
|
||||
});
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
const { params, setParams, resetParams } =
|
||||
useSearchParamsState(DEFAULT_PARAMS);
|
||||
const [keyword, setKeyword] = useState(params.keyword);
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [postingTargets, setPostingTargets] = useState<PostingTarget[]>([]);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.IMAGES, searchParams, pagination],
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.IMAGES, params],
|
||||
queryFn: () =>
|
||||
ImageApi.getImageList({
|
||||
...searchParams,
|
||||
page: pagination.pageIndex,
|
||||
size: pagination.pageSize,
|
||||
keyword: params.keyword,
|
||||
sort: params.sort,
|
||||
page: params.page,
|
||||
size: params.size,
|
||||
}),
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||
const pagination: PaginationState = {
|
||||
pageIndex: params.page,
|
||||
pageSize: params.size,
|
||||
};
|
||||
|
||||
const handlePaginationChange: OnChangeFn<PaginationState> = (updater) => {
|
||||
const next = typeof updater === "function" ? updater(pagination) : updater;
|
||||
setParams({ page: next.pageIndex, size: next.pageSize });
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setPagination({
|
||||
...pagination,
|
||||
pageIndex: 0,
|
||||
});
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
keyword,
|
||||
});
|
||||
setParams({ keyword, page: 0 });
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setKeyword("");
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
keyword: "",
|
||||
});
|
||||
resetParams();
|
||||
};
|
||||
|
||||
const handleRowClick = (row: Image) => {
|
||||
@@ -81,7 +86,7 @@ function ImagePage() {
|
||||
manualPagination: true,
|
||||
enableMultiRowSelection: true,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
onPaginationChange: setPagination,
|
||||
onPaginationChange: handlePaginationChange,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
},
|
||||
dataTableOptions: {
|
||||
|
||||
@@ -2,45 +2,76 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
|
||||
import { Button, Input, Label } from "@common/components";
|
||||
import { CodeSelect } from "@/entities/code";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useDataTable } from "@/shared/lib";
|
||||
import {
|
||||
AuthorizationsApi,
|
||||
getCoreRowModel,
|
||||
type OnChangeFn,
|
||||
type PaginationState,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useDataTable, useSearchParamsState } from "@/shared/lib";
|
||||
import {
|
||||
type Authorizations,
|
||||
type AuthorizationsParams,
|
||||
AuthorizationsApi,
|
||||
} from "@/entities/authorization";
|
||||
import { CodeSelect } from "@/entities/code";
|
||||
import { Button, Input, Label } from "@common/components";
|
||||
|
||||
import { columns } from "../model/authorizations-table-columns";
|
||||
|
||||
const DEFAULT_PARAMS = {
|
||||
authorizationName: "",
|
||||
httpMethodCode: "",
|
||||
sort: "regDt,desc",
|
||||
page: 0,
|
||||
size: 10,
|
||||
};
|
||||
|
||||
function ModulePage() {
|
||||
const router = useRouter();
|
||||
|
||||
const [authorizationName, setAuthorizationName] = useState("");
|
||||
const [httpMethodCode, setHttpMethodCode] = useState("");
|
||||
const [searchParams, setSearchParams] = useState<AuthorizationsParams>({
|
||||
authorizationName: "",
|
||||
httpMethodCode: "",
|
||||
sort: "regDt,desc",
|
||||
});
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
const { params, setParams, resetParams } =
|
||||
useSearchParamsState(DEFAULT_PARAMS);
|
||||
const [authorizationName, setAuthorizationName] = useState(
|
||||
params.authorizationName,
|
||||
);
|
||||
const [httpMethodCode, setHttpMethodCode] = useState(params.httpMethodCode);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [ENDPOINT.USER_SERVICE.AUTHORIZATIONS, searchParams, pagination],
|
||||
queryKey: [ENDPOINT.USER_SERVICE.AUTHORIZATIONS, params],
|
||||
queryFn: () =>
|
||||
AuthorizationsApi.getAuthorizationsList({
|
||||
...searchParams,
|
||||
page: pagination.pageIndex,
|
||||
size: pagination.pageSize,
|
||||
authorizationName: params.authorizationName,
|
||||
httpMethodCode: params.httpMethodCode,
|
||||
sort: params.sort,
|
||||
page: params.page,
|
||||
size: params.size,
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const pagination: PaginationState = {
|
||||
pageIndex: params.page,
|
||||
pageSize: params.size,
|
||||
};
|
||||
|
||||
const handlePaginationChange: OnChangeFn<PaginationState> = (updater) => {
|
||||
const next = typeof updater === "function" ? updater(pagination) : updater;
|
||||
setParams({ page: next.pageIndex, size: next.pageSize });
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setParams({ authorizationName, httpMethodCode, page: 0 });
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setAuthorizationName("");
|
||||
setHttpMethodCode("");
|
||||
resetParams();
|
||||
};
|
||||
|
||||
const handleRowClick = (row: Authorizations) => {
|
||||
router.push(`/module/register/${row.authorizationNo}`);
|
||||
};
|
||||
@@ -52,7 +83,7 @@ function ModulePage() {
|
||||
state: {
|
||||
pagination,
|
||||
},
|
||||
onPaginationChange: setPagination,
|
||||
onPaginationChange: handlePaginationChange,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
},
|
||||
@@ -62,29 +93,6 @@ function ModulePage() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setPagination({
|
||||
...pagination,
|
||||
pageIndex: 0,
|
||||
});
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
authorizationName,
|
||||
httpMethodCode,
|
||||
});
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setAuthorizationName("");
|
||||
setHttpMethodCode("");
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
authorizationName: "",
|
||||
httpMethodCode: "",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card card-border !mt-6">
|
||||
<div className="card card-border">
|
||||
|
||||
@@ -2,70 +2,75 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
getCoreRowModel,
|
||||
type OnChangeFn,
|
||||
type PaginationState,
|
||||
type RowSelectionState,
|
||||
} from "@tanstack/react-table";
|
||||
import { Button, Input, Label } from "@common/components";
|
||||
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useDataTable, useSearchParamsState } from "@/shared/lib";
|
||||
import { AuthorityButton } from "@/entities/menu-authority";
|
||||
import { POSTING_TYPE, PostingDialog } from "@/entities/posting";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useDataTable } from "@/shared/lib";
|
||||
import { Button, Input, Label } from "@common/components";
|
||||
|
||||
import { PopupApi } from "../api/popup-service";
|
||||
import type { Popup } from "../model/popup-interface";
|
||||
import { columns } from "../model/table-columns";
|
||||
import type { Popup, PopupParams } from "../model/popup-interface";
|
||||
|
||||
type PostingTarget = {
|
||||
pstgTrgtNm: string;
|
||||
pstgTrgtNo: number;
|
||||
};
|
||||
|
||||
const DEFAULT_PARAMS = {
|
||||
keyword: "",
|
||||
sort: "regDt,desc",
|
||||
page: 0,
|
||||
size: 10,
|
||||
};
|
||||
|
||||
function PopupPage() {
|
||||
const router = useRouter();
|
||||
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [searchParams, setSearchParams] = useState<PopupParams>({
|
||||
keyword: "",
|
||||
sort: "regDt,desc",
|
||||
});
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
const { params, setParams, resetParams } =
|
||||
useSearchParamsState(DEFAULT_PARAMS);
|
||||
const [keyword, setKeyword] = useState(params.keyword);
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [postingTargets, setPostingTargets] = useState<PostingTarget[]>([]);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.POPUPS, searchParams, pagination],
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.POPUPS, params],
|
||||
queryFn: () =>
|
||||
PopupApi.getPopupList({
|
||||
...searchParams,
|
||||
page: pagination.pageIndex,
|
||||
size: pagination.pageSize,
|
||||
keyword: params.keyword,
|
||||
sort: params.sort,
|
||||
page: params.page,
|
||||
size: params.size,
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||
const pagination: PaginationState = {
|
||||
pageIndex: params.page,
|
||||
pageSize: params.size,
|
||||
};
|
||||
|
||||
const handlePaginationChange: OnChangeFn<PaginationState> = (updater) => {
|
||||
const next = typeof updater === "function" ? updater(pagination) : updater;
|
||||
setParams({ page: next.pageIndex, size: next.pageSize });
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setPagination({
|
||||
...pagination,
|
||||
pageIndex: 0,
|
||||
});
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
keyword,
|
||||
});
|
||||
setParams({ keyword, page: 0 });
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setKeyword("");
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
keyword: "",
|
||||
});
|
||||
resetParams();
|
||||
};
|
||||
|
||||
const handleRowClick = (row: Popup) => {
|
||||
@@ -82,7 +87,7 @@ function PopupPage() {
|
||||
},
|
||||
manualPagination: true,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
onPaginationChange: setPagination,
|
||||
onPaginationChange: handlePaginationChange,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
},
|
||||
dataTableOptions: {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user