Merge remote-tracking branch 'origin/main' into iipbs/sn/dev_kic

This commit is contained in:
kiminchan
2026-06-24 14:53:40 +09:00
397 changed files with 31460 additions and 12547 deletions
+2 -2
View File
@@ -19,7 +19,7 @@
"@tanstack/react-table": "^8.21.2",
"axios": "^1.8.1",
"dayjs": "^1.11.13",
"next": "15.3.8",
"next": "15.5.18",
"react-hook-form": "^7.54.2",
"zod": "^3.24.2"
},
@@ -33,4 +33,4 @@
"tailwindcss": "^3.4.14",
"typescript": "^5.7.2"
}
}
}
@@ -0,0 +1 @@
export { AcntgCodeManagementPage as default } from "@/pages/acntg-code-management";
@@ -0,0 +1 @@
export { CnstpCodeManagementPage as default } from "@/pages/cnstp-code-management";
@@ -1 +0,0 @@
export { CodeManagementPage as default } from "@/pages/code-management";
@@ -0,0 +1,6 @@
import { CnfmModifyPage } from "@/pages/instl-sncn-old";
export default async function InstlSncnModifyPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return <CnfmModifyPage id={id} />;
}
@@ -0,0 +1,6 @@
import { CnfmViewPage } from "@/pages/instl-sncn-old";
export default async function InstlSncnViewPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return <CnfmViewPage id={id} />;
}
@@ -0,0 +1,3 @@
import { CnfmListPage } from "@/pages/instl-sncn-old";
export default CnfmListPage;
@@ -0,0 +1,5 @@
import { CnfmRegistPage } from "@/pages/instl-sncn-old";
export default function Page(props: any) {
return <CnfmRegistPage {...props} />;
}
@@ -0,0 +1,10 @@
import { InstlEditPage } from "@/pages/instl-sncn";
interface InstlSncnEditRouteProps {
params: Promise<{ id: string }>;
}
export default async function Page({ params }: InstlSncnEditRouteProps) {
const { id } = await params;
return <InstlEditPage id={id} />;
}
@@ -1,10 +0,0 @@
import { CnfmModifyPage } from "@/pages/instl-sncn";
export default async function InstlSncnModifyPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return <CnfmModifyPage id={id} />;
}
@@ -1,10 +1,6 @@
import { CnfmViewPage } from "@/pages/instl-sncn";
export default async function InstlSncnViewPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return <CnfmViewPage id={id} />;
}
import { InstlViewPage } from "@/pages/instl-sncn";
export default async function InstlSncnDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return <InstlViewPage id={id} />;
}
@@ -1,3 +1,3 @@
import { CnfmListPage } from "@/pages/instl-sncn";
export default CnfmListPage;
import { InstlListPage } from "@/pages/instl-sncn";
export default InstlListPage;
@@ -0,0 +1,3 @@
import { InstlRegistPage } from "@/pages/instl-sncn";
export default InstlRegistPage;
@@ -1,5 +0,0 @@
import { CnfmRegistPage } from "@/pages/instl-sncn";
export default function Page(props: any) {
return <CnfmRegistPage {...props} />;
}
@@ -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)
-6
View File
@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+2 -1
View File
@@ -24,10 +24,11 @@
"@tanstack/react-query-devtools": "^5.75.5",
"@tanstack/react-table": "^8.21.2",
"axios": "^1.8.1",
"class-variance-authority": "^0.7.1",
"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"
+18 -12
View File
@@ -2,15 +2,17 @@ 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;
const BASE = ENDPOINT.AS_SERVICE.ACNTG_SE;
async function findAll(params: AcntgSeMngSearchDto): Promise<PageResponse<AcntgSeMngResponseDto>> {
const response = await client.get<PageResponse<AcntgSeMngResponseDto>>(BASE, { params });
@@ -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;
+9 -5
View File
@@ -2,11 +2,15 @@ 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";
const acntgSeMngBaseKey = [ENDPOINT.AS_COMMON_SERVICE.ACNTG_SE] as const;
const acntgSeMngBaseKey = [ENDPOINT.AS_SERVICE.ACNTG_SE] as const;
export function useAcntgSeMngPage(params: AcntgSeMngSearchDto) {
return useQuery({
@@ -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 });
+3 -1
View File
@@ -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>;
}
@@ -0,0 +1,26 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
CmCdAdstDstrComboResponseDto,
CmCdAdstDstrService,
} from "../model/adstdstr-interface";
const BASE = ENDPOINT.AS_SERVICE.ADSTDSTR;
async function findSidoCombo(): Promise<CmCdAdstDstrComboResponseDto[]> {
const response = await client.get<CmCdAdstDstrComboResponseDto[]>(`${BASE}/sido`);
return response.data;
}
async function findGugunCombo(sido: string): Promise<CmCdAdstDstrComboResponseDto[]> {
const response = await client.get<CmCdAdstDstrComboResponseDto[]>(`${BASE}/gugun`, {
params: { sido },
});
return response.data;
}
export const cmCdAdstDstrService = {
findSidoCombo,
findGugunCombo,
} satisfies CmCdAdstDstrService;
@@ -0,0 +1,22 @@
import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import { cmCdAdstDstrService } from "./adstdstr-service";
const cmCdAdstDstrBaseKey = [ENDPOINT.AS_SERVICE.ADSTDSTR] as const;
export function useFindSidoCombo() {
return useQuery({
queryKey: [...cmCdAdstDstrBaseKey, "findSidoCombo"],
queryFn: () => cmCdAdstDstrService.findSidoCombo(),
});
}
export function useFindGugunCombo(sido: string) {
return useQuery({
queryKey: [...cmCdAdstDstrBaseKey, "findGugunCombo", sido],
queryFn: () => cmCdAdstDstrService.findGugunCombo(sido),
enabled: Boolean(sido),
});
}
+6
View File
@@ -0,0 +1,6 @@
export { cmCdAdstDstrService } from "./api/adstdstr-service";
export { useFindGugunCombo, useFindSidoCombo } from "./api/queries";
export type {
CmCdAdstDstrComboResponseDto,
CmCdAdstDstrService,
} from "./model/adstdstr-interface";
@@ -0,0 +1,11 @@
/** 백엔드 CmCdAdstDstrComboResponseDto */
export interface CmCdAdstDstrComboResponseDto {
adstDstrCd: string;
adstDstrNm: string;
}
/** Controller public 메서드명·시그니처와 동일 */
export interface CmCdAdstDstrService {
findSidoCombo(): Promise<CmCdAdstDstrComboResponseDto[]>;
findGugunCombo(sido: string): Promise<CmCdAdstDstrComboResponseDto[]>;
}
@@ -0,0 +1,47 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import { buildBnsSearchDto } from "../lib/build-bns-search-dto";
import type {
BnsDetailResponseWrapperDto,
BnsIfService,
BnsListResponseWrapperDto,
BnsSearchDto,
EmCodeListResponseDto,
} from "../model/bns-if-interface";
const BASE = ENDPOINT.AS_SERVICE.BNS_IF;
async function getBnsList(params: BnsSearchDto): Promise<BnsListResponseWrapperDto> {
const response = await client.get<BnsListResponseWrapperDto>(`${BASE}/bns`, {
params: buildBnsSearchDto(params),
});
return response.data;
}
async function getBnsDetail(
gbn: string,
yr: string,
seqNo: string,
): Promise<BnsDetailResponseWrapperDto> {
const response = await client.get<BnsDetailResponseWrapperDto>(`${BASE}/bns/detail`, {
params: { gbn, yr, seqNo },
});
return response.data;
}
async function getEmCodeList(
dpmCd?: string,
lclgvrnmntCd?: string,
): Promise<EmCodeListResponseDto> {
const response = await client.get<EmCodeListResponseDto>(`${BASE}/emCodeList`, {
params: { dpmCd, lclgvrnmntCd },
});
return response.data;
}
export const bnsIfService = {
getBnsList,
getBnsDetail,
getEmCodeList,
} satisfies BnsIfService;
@@ -0,0 +1,33 @@
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import type { BnsSearchDto } from "../model/bns-if-interface";
import { bnsIfService } from "./bns-if-service";
const bnsIfBaseKey = [ENDPOINT.AS_SERVICE.BNS_IF] as const;
export function useBnsList(params: BnsSearchDto, options?: { enabled?: boolean }) {
return useQuery({
queryKey: [...bnsIfBaseKey, "getBnsList", params],
queryFn: () => bnsIfService.getBnsList(params),
enabled: options?.enabled,
placeholderData: keepPreviousData,
});
}
export function useBnsDetail(gbn: string, yr: string, seqNo: string) {
return useQuery({
queryKey: [...bnsIfBaseKey, "getBnsDetail", gbn, yr, seqNo],
queryFn: () => bnsIfService.getBnsDetail(gbn, yr, seqNo),
enabled: Boolean(gbn && yr && seqNo),
});
}
export function useEmCodeList(dpmCd?: string, lclgvrnmntCd?: string) {
return useQuery({
queryKey: [...bnsIfBaseKey, "getEmCodeList", dpmCd, lclgvrnmntCd],
queryFn: () => bnsIfService.getEmCodeList(dpmCd, lclgvrnmntCd),
});
}
+13
View File
@@ -0,0 +1,13 @@
export { bnsIfService } from "./api/bns-if-service";
export { useBnsDetail, useBnsList, useEmCodeList } from "./api/queries";
export { buildBnsSearchDto } from "./lib/build-bns-search-dto";
export type {
BnsDetailResponseDto,
BnsDetailResponseWrapperDto,
BnsIfService,
BnsListResponseDto,
BnsListResponseWrapperDto,
BnsSearchDto,
EmCodeItemDto,
EmCodeListResponseDto,
} from "./model/bns-if-interface";
@@ -0,0 +1,20 @@
import type { BnsSearchDto } from "../model/bns-if-interface";
export function buildBnsSearchDto(input: {
cPage: number;
searchStartYear?: string;
searchSido?: string;
searchGugun?: string;
}): BnsSearchDto {
const dto: BnsSearchDto = { cPage: input.cPage };
const year = input.searchStartYear?.trim();
const sido = input.searchSido?.trim();
const gugun = input.searchGugun?.trim();
if (year) dto.searchStartYear = year;
if (sido) dto.searchSido = sido;
if (gugun) dto.searchGugun = gugun;
return dto;
}
@@ -0,0 +1,125 @@
/** 백엔드 BnsSearchDto */
export interface BnsSearchDto {
searchStartYear?: string;
cPage: number;
searchSido?: string;
searchGugun?: string;
}
/** 백엔드 BnsListResponseWrapperDto */
export interface BnsListResponseWrapperDto {
list: BnsListResponseDto[];
cnt: number;
}
/** 백엔드 BnsListResponseDto */
export interface BnsListResponseDto {
cPage: number;
gbn: number;
chk: number;
seqNo: string;
rgnNm: string;
sido: string;
gugun: string;
bzCd: string;
bzNm: string;
yr: string;
strtYr: string;
edYr: string;
totBzamt: string;
}
/** 백엔드 BnsDetailResponseWrapperDto */
export interface BnsDetailResponseWrapperDto {
saup: unknown;
detail: BnsDetailResponseDto;
}
/** 백엔드 BnsDetailResponseDto */
export interface BnsDetailResponseDto {
cPage: number;
seqNo: string;
confBzCd: string;
chngDgr: string;
yr: string;
sido: string;
gugun: string;
rvbsnNm: string;
lgbsnNm: string;
mdmbsnNm: string;
lclgvrnmntNm: string;
accKdNm: string;
bzKdNm: string;
untBzNm: string;
dtlBzNm: string;
mevBzNm: string;
ddtlBzNm: string;
strtYr: string;
edYr: string;
aidRt: string;
bzStatus: string;
bzCd: string;
bzNm: string;
bzLoc: string;
efflWqltStndrdNm: string;
goalYear: string;
bssProm: string;
uppPlan: string;
techInsp: string;
bssCont: string;
rmk: string;
inflBscFctNm: string;
inflBscFctCd: string;
trtMtd: string;
swrExtns: string;
swrChng: string;
swrRnvt: string;
totFamNum: string;
pumpCnt: string;
rvWbsn: string;
rvText: string;
imprvExpctFlw: string;
imprvExpctWqlt: string;
trtplntPlEfflWqlt: string;
wqltAvg: string;
wqltHgh: string;
wqltLow: string;
stdCost: string;
bzEtc: string;
bscFctCd: string;
bscFctNm: string;
fctCpc: string;
fctCpcAfr: string;
trtDstrArea: string;
trtDstrAreaAfr: string;
swrTrtPopl: string;
swrTrtPoplAfr: string;
adstPopl: string;
}
/** 백엔드 EmCodeItemDto */
export interface EmCodeItemDto {
code: string;
data: string;
upCode: string;
}
/** 백엔드 EmCodeListResponseDto */
export interface EmCodeListResponseDto {
rvbsnList: EmCodeItemDto[];
lgbsnList: EmCodeItemDto[];
mdmbsnList: EmCodeItemDto[];
accList: EmCodeItemDto[];
bzList: EmCodeItemDto[];
untList: EmCodeItemDto[];
dtlList: EmCodeItemDto[];
mevList: EmCodeItemDto[];
ddtlList: EmCodeItemDto[];
}
/** Controller public 메서드명·시그니처와 동일 */
export interface BnsIfService {
getBnsList(params: BnsSearchDto): Promise<BnsListResponseWrapperDto>;
getBnsDetail(gbn: string, yr: string, seqNo: string): Promise<BnsDetailResponseWrapperDto>;
getEmCodeList(dpmCd?: string, lclgvrnmntCd?: string): Promise<EmCodeListResponseDto>;
}
@@ -11,33 +11,25 @@ import type {
CklstArtclUpdateRequest,
} from "../model/cklst-artcl-interface";
const BASE = ENDPOINT.AS_SERVICE.CKLST_ARTCL;
async function findPage(params: CklstArtclSearchParams): Promise<PageResponse<CklstArtcl>> {
const response = await client.get<PageResponse<CklstArtcl>>(
ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL,
{ params },
);
const response = await client.get<PageResponse<CklstArtcl>>(BASE, { params });
return response.data;
}
async function findByCklstSn(cklstSn: number): Promise<CklstArtcl[]> {
const response = await client.get<CklstArtcl[]>(
`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL}/by-cklst/${cklstSn}`,
);
const response = await client.get<CklstArtcl[]>(`${BASE}/by-cklst/${cklstSn}`);
return response.data;
}
async function findById(cklstArtclSn: number, cklstSn: number): Promise<CklstArtcl> {
const response = await client.get<CklstArtcl>(
`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL}/${cklstArtclSn}/${cklstSn}`,
);
const response = await client.get<CklstArtcl>(`${BASE}/${cklstArtclSn}/${cklstSn}`);
return response.data;
}
async function create(body: CklstArtclSaveRequest): Promise<CklstArtclId> {
const response = await client.post<CklstArtclId>(
ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL,
body,
);
const response = await client.post<CklstArtclId>(BASE, body);
return response.data;
}
@@ -46,17 +38,12 @@ async function update(
cklstSn: number,
body: CklstArtclUpdateRequest,
): Promise<CklstArtclId> {
const response = await client.put<CklstArtclId>(
`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL}/${cklstArtclSn}/${cklstSn}`,
body,
);
const response = await client.put<CklstArtclId>(`${BASE}/${cklstArtclSn}/${cklstSn}`, body);
return response.data;
}
async function remove(cklstArtclSn: number, cklstSn: number): Promise<CklstArtclId> {
const response = await client.delete<CklstArtclId>(
`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL}/${cklstArtclSn}/${cklstSn}`,
);
const response = await client.delete<CklstArtclId>(`${BASE}/${cklstArtclSn}/${cklstSn}`);
return response.data;
}
@@ -11,40 +11,39 @@ import type {
CklstTreeSearchParams,
} from "../model/cklst-mng-interface";
const BASE = ENDPOINT.AS_SERVICE.CKLST;
async function findPage(params: CklstMngSearchParams): Promise<PageResponse<CklstMng>> {
const response = await client.get<PageResponse<CklstMng>>(ENDPOINT.AS_CHECKLIST_SERVICE.CKLST, {
const response = await client.get<PageResponse<CklstMng>>(BASE, {
params,
});
return response.data;
}
async function findById(cklstSn: number): Promise<CklstMng> {
const response = await client.get<CklstMng>(`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST}/${cklstSn}`);
const response = await client.get<CklstMng>(`${BASE}/${cklstSn}`);
return response.data;
}
async function findTree(params: CklstTreeSearchParams): Promise<CklstTreeNode[]> {
const response = await client.get<CklstTreeNode[]>(`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST}/list/tree`, {
const response = await client.get<CklstTreeNode[]>(`${BASE}/list/tree`, {
params,
});
return response.data;
}
async function create(body: CklstMngSaveRequest): Promise<number> {
const response = await client.post<number>(ENDPOINT.AS_CHECKLIST_SERVICE.CKLST, body);
const response = await client.post<number>(BASE, body);
return response.data;
}
async function update(cklstSn: number, body: CklstMngSaveRequest): Promise<number> {
const response = await client.put<number>(
`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST}/${cklstSn}`,
body,
);
const response = await client.put<number>(`${BASE}/${cklstSn}`, body);
return response.data;
}
async function remove(cklstSn: number): Promise<number> {
const response = await client.delete<number>(`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST}/${cklstSn}`);
const response = await client.delete<number>(`${BASE}/${cklstSn}`);
return response.data;
}
@@ -11,18 +11,15 @@ import type {
CklstRvwUpdateRequest,
} from "../model/cklst-rvw-interface";
const BASE = ENDPOINT.AS_SERVICE.CKLST_RVW;
async function findPage(params: CklstRvwSearchParams): Promise<PageResponse<CklstRvw>> {
const response = await client.get<PageResponse<CklstRvw>>(
ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW,
{ params },
);
const response = await client.get<PageResponse<CklstRvw>>(BASE, { params });
return response.data;
}
async function findByArtcl(cklstSn: number, cklstArtclSn: number): Promise<CklstRvw[]> {
const response = await client.get<CklstRvw[]>(
`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW}/by-artcl/${cklstSn}/${cklstArtclSn}`,
);
const response = await client.get<CklstRvw[]>(`${BASE}/by-artcl/${cklstSn}/${cklstArtclSn}`);
return response.data;
}
@@ -31,17 +28,12 @@ async function findById(
cklstArtclSn: number,
cklstSn: number,
): Promise<CklstRvw> {
const response = await client.get<CklstRvw>(
`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW}/${cklstRvwSn}/${cklstArtclSn}/${cklstSn}`,
);
const response = await client.get<CklstRvw>(`${BASE}/${cklstRvwSn}/${cklstArtclSn}/${cklstSn}`);
return response.data;
}
async function create(body: CklstRvwSaveRequest): Promise<CklstRvwId> {
const response = await client.post<CklstRvwId>(
ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW,
body,
);
const response = await client.post<CklstRvwId>(BASE, body);
return response.data;
}
@@ -52,7 +44,7 @@ async function update(
body: CklstRvwUpdateRequest,
): Promise<CklstRvwId> {
const response = await client.put<CklstRvwId>(
`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW}/${cklstRvwSn}/${cklstArtclSn}/${cklstSn}`,
`${BASE}/${cklstRvwSn}/${cklstArtclSn}/${cklstSn}`,
body,
);
return response.data;
@@ -64,7 +56,7 @@ async function remove(
cklstSn: number,
): Promise<CklstRvwId> {
const response = await client.delete<CklstRvwId>(
`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW}/${cklstRvwSn}/${cklstArtclSn}/${cklstSn}`,
`${BASE}/${cklstRvwSn}/${cklstArtclSn}/${cklstSn}`,
);
return response.data;
}
+3 -3
View File
@@ -18,9 +18,9 @@ import { cklstArtclService } from "./cklst-artcl-service";
import { cklstMngService } from "./cklst-mng-service";
import { cklstRvwService } from "./cklst-rvw-service";
const cklstMngBaseKey = [ENDPOINT.AS_CHECKLIST_SERVICE.CKLST] as const;
const cklstArtclBaseKey = [ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_ARTCL] as const;
const cklstRvwBaseKey = [ENDPOINT.AS_CHECKLIST_SERVICE.CKLST_RVW] as const;
const cklstMngBaseKey = [ENDPOINT.AS_SERVICE.CKLST] as const;
const cklstArtclBaseKey = [ENDPOINT.AS_SERVICE.CKLST_ARTCL] as const;
const cklstRvwBaseKey = [ENDPOINT.AS_SERVICE.CKLST_RVW] as const;
export function useCklstMngPage(params: CklstMngSearchParams) {
return useQuery({
@@ -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 {
+18 -12
View File
@@ -2,15 +2,17 @@ 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;
const BASE = ENDPOINT.AS_SERVICE.CNSTP_SE;
async function findAll(params: CnstpSeMngSearchDto): Promise<PageResponse<CnstpSeMngResponseDto>> {
const response = await client.get<PageResponse<CnstpSeMngResponseDto>>(BASE, { params });
@@ -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;
+9 -5
View File
@@ -2,11 +2,15 @@ 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";
const cnstpSeMngBaseKey = [ENDPOINT.AS_COMMON_SERVICE.CNSTP_SE] as const;
const cnstpSeMngBaseKey = [ENDPOINT.AS_SERVICE.CNSTP_SE] as const;
export function useCnstpSeMngPage(params: CnstpSeMngSearchDto) {
return useQuery({
@@ -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 });
+3 -1
View File
@@ -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,
});
}
+2
View File
@@ -1,4 +1,5 @@
export { elaprAplnService } from "./api/elapr-apln-service";
export { ElaprDmndApi } from "./api/elapr-dmnd-service";
export { elaprTmpltService } from "./api/elapr-tmplt-service";
export {
useCreateElaprTmplt,
@@ -24,6 +25,7 @@ export {
type ElaprAplnService,
type ElaprAplnUpdateRequest,
} from "./model/elapr-apln-interface";
export type { ElaprDmndSaveRequest } from "./model/elapr-dmnd-interface";
export type {
ElaprTmplt,
ElaprTmpltSaveRequest,
@@ -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>;
}
+10 -19
View File
@@ -11,50 +11,41 @@ import type {
EsntlAtchFileMngTreeSearchParams,
} from "../model/esntl-interface";
const BASE = ENDPOINT.AS_SERVICE.ESNTL_ATCH_FILE;
async function findTree(
params: EsntlAtchFileMngTreeSearchParams,
): Promise<EsntlAtchFileMngTreeResponseDto[]> {
const response = await client.get<EsntlAtchFileMngTreeResponseDto[]>(
`${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/list/tree`,
{ params },
);
const response = await client.get<EsntlAtchFileMngTreeResponseDto[]>(`${BASE}/list/tree`, {
params,
});
return response.data;
}
async function findPage(
params: EsntlAtchFileMngSearchParams,
): Promise<PageResponse<EsntlAtchFileMng>> {
const response = await client.get<PageResponse<EsntlAtchFileMng>>(
ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE,
{ params },
);
const response = await client.get<PageResponse<EsntlAtchFileMng>>(BASE, { params });
return response.data;
}
async function findById(esntlAtchFileId: string): Promise<EsntlAtchFileMng> {
const response = await client.get<EsntlAtchFileMng>(
`${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/${esntlAtchFileId}`,
);
const response = await client.get<EsntlAtchFileMng>(`${BASE}/${esntlAtchFileId}`);
return response.data;
}
async function create(body: EsntlAtchFileMngSaveRequest): Promise<string> {
const response = await client.post<string>(ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE, body);
const response = await client.post<string>(BASE, body);
return response.data;
}
async function update(esntlAtchFileId: string, body: EsntlAtchFileMngSaveRequest): Promise<string> {
const response = await client.put<string>(
`${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/${esntlAtchFileId}`,
body,
);
const response = await client.put<string>(`${BASE}/${esntlAtchFileId}`, body);
return response.data;
}
async function remove(esntlAtchFileId: string): Promise<string> {
const response = await client.delete<string>(
`${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/${esntlAtchFileId}`,
);
const response = await client.delete<string>(`${BASE}/${esntlAtchFileId}`);
return response.data;
}
+1 -1
View File
@@ -10,7 +10,7 @@ import type {
import { esntlAtchFileMngService } from "./esntl-service";
const esntlAtchFileMngBaseKey = [ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE] as const;
const esntlAtchFileMngBaseKey = [ENDPOINT.AS_SERVICE.ESNTL_ATCH_FILE] as const;
export function useEsntlAtchFileMngTree(params: EsntlAtchFileMngTreeSearchParams) {
return useQuery({
@@ -1,34 +1,62 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { PageResponse } from "@/shared/model";
import type {
AdstZoneResponseDto,
InstlSncnApplySaveRequestDto,
InstlSncnMng,
InstlSncnMngDetResponse,
InstlSncnMngResponseDto,
InstlSncnMngSearchParams,
InstlSncnMngService,
SpringPage,
InstlSncnMngDetResponse,
} from "../model/instl-sncn-interface";
async function getSidoCombo(): Promise<AdstZoneResponseDto[]> {
const response = await client.get<AdstZoneResponseDto[]>(
`${ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_MST}/sido`,
);
return response.data;
}
async function getSigunguCombo(sidoCd: string): Promise<AdstZoneResponseDto[]> {
const response = await client.get<AdstZoneResponseDto[]>(
`${ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_MST}/sigungu`,
{ params: { sidoCd } },
);
return response.data;
}
const BASE = ENDPOINT.AS_SERVICE.INSTL_SNCN;
export class InstlSncnApiService implements InstlSncnMngService {
public async getPage(params: InstlSncnMngSearchParams): Promise<SpringPage<InstlSncnMng>> {
const response = await client.get<SpringPage<InstlSncnMng>>(ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN, {
public async getPage(
params: InstlSncnMngSearchParams,
): Promise<PageResponse<InstlSncnMngResponseDto>> {
const response = await client.get<PageResponse<InstlSncnMngResponseDto>>(BASE, {
params,
});
return response.data;
}
public async getById(id: string): Promise<InstlSncnMngDetResponse> {
const response = await client.get<InstlSncnMngDetResponse>(`${ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN}/${id}`);
const response = await client.get<InstlSncnMngDetResponse>(`${BASE}/${id}`);
return response.data;
}
public async saveApply(payload: InstlSncnApplySaveRequestDto): Promise<string> {
const response = await client.post<string>(
`${ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN}/save-apply`,
payload,
);
const response = await client.post<string>(`${BASE}/save-apply`, payload);
return response.data;
}
public async techReview(id: string, payload: InstlSncnApplySaveRequestDto): Promise<void> {
await client.post(`${BASE}/${id}/tech-review`, payload);
}
}
export const InstlSncnApi = new InstlSncnApiService();
export const instlSncnRegionService = {
getSidoCombo,
getSigunguCombo,
};
@@ -0,0 +1,46 @@
import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import type { InstlSncnMngSearchParams } from "../model/instl-sncn-interface";
import { InstlSncnApi, instlSncnRegionService } from "./instl-sncn-service";
export const instlSncnKeys = {
all: ["instl-sncn"] as const,
list: (params: InstlSncnMngSearchParams) => [...instlSncnKeys.all, "list", params] as const,
detail: (id: string) => [...instlSncnKeys.all, "detail", id] as const,
sidoCombo: () => [ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_MST, "sido"] as const,
sigunguCombo: (sidoCd: string) =>
[ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_MST, "sigungu", sidoCd] as const,
};
export function useInstlSncnList(params: InstlSncnMngSearchParams) {
return useQuery({
queryKey: instlSncnKeys.list(params),
queryFn: () => InstlSncnApi.getPage(params),
});
}
export function useInstlSncnDetail(id: string) {
return useQuery({
queryKey: instlSncnKeys.detail(id),
queryFn: () => InstlSncnApi.getById(id),
enabled: Boolean(id),
});
}
export function useInstlSncnSidoCombo() {
return useQuery({
queryKey: instlSncnKeys.sidoCombo(),
queryFn: () => instlSncnRegionService.getSidoCombo(),
});
}
export function useInstlSncnSigunguCombo(sidoCd: string) {
return useQuery({
queryKey: instlSncnKeys.sigunguCombo(sidoCd),
queryFn: () => instlSncnRegionService.getSigunguCombo(sidoCd),
enabled: Boolean(sidoCd),
});
}
+20 -3
View File
@@ -1,11 +1,28 @@
export { InstlSncnApi } from "./api/instl-sncn-service";
export { InstlSncnApi, instlSncnRegionService } from "./api/instl-sncn-service";
export {
instlSncnKeys,
useInstlSncnDetail,
useInstlSncnList,
useInstlSncnSidoCombo,
useInstlSncnSigunguCombo,
} from "./api/queries";
export {
type AdstZoneResponseDto,
type AtflMngClientResponseDto,
type BizCstTreeDto,
type BizSclTreeDto,
type CklstRvwRsltTreeDto,
type ElaprDocPrgrsClientResponseDto,
type EsntlAtchFileMngTreeResponseDto,
INSTL_SNCN_REGISTER_SELECTION_KEY,
type InstlSncnApplySaveRequestDto,
type InstlSncnBizCstSaveRequestDto,
type InstlSncnBzScaleSaveDto,
type InstlSncnCklstResultSaveDto,
type InstlSncnMng,
type InstlSncnMngDetResponse,
type InstlSncnMngResponseDto,
type InstlSncnMngSaveRequestDto,
type InstlSncnMngSearchParams,
type SpringPage,
type InstlSncnRvwOpnnResponseDto,
type ReviewStepCode,
} from "./model/instl-sncn-interface";
@@ -1,58 +1,100 @@
export const INSTL_SNCN_REGISTER_SELECTION_KEY = "instlSncnRegisterSelection";
import type { PageableParams, PageResponse } from "@/shared/model";
export interface SpringPage<T> {
content: T[];
totalElements: number;
totalPages: number;
number: number;
size: number;
first: boolean;
last: boolean;
numberOfElements: number;
/** 행정구역(시도·시군구) 콤보 응답 DTO */
export interface AdstZoneResponseDto {
/** 행정구역 코드 */
adstCd: string;
/** 행정구역 명 */
adstNm: string;
}
export interface InstlSncnMngSearchParams {
export const INSTL_SNCN_REGISTER_SELECTION_KEY = "instlSncnRegisterSelection";
export interface InstlSncnMngSearchParams extends PageableParams {
bizNm?: string;
taskSeComCd?: string;
sidoComCd?: string;
sggComCd?: string;
page?: number;
size?: number;
sort?: string;
}
export interface InstlSncnMng {
instlSncnDocNo: string;
admdstCd: string;
sidoNm?: string;
sggNm?: string;
chrgrNm?: string;
instNm?: string;
envoSeComCd: string;
/** 설치인가관리 저장 요청 DTO */
export interface InstlSncnMngSaveRequestDto {
/** 설치인가문서번호 */
instlSncnDocNo?: string;
/** 행정구역코드 */
admdstCd?: string;
/** 환경청구분공통코드 */
envoSeComCd?: string;
/** 인가구분공통코드 */
sncnSeComCd: string;
envoSeCdNm: string;
/** 업무구분공통코드 */
taskSeComCd: string;
taskSeCdNm: string;
bgtBizCd: string;
bgtAplyYr: string;
/** 예산사업코드 */
bgtBizCd?: string;
/** 예산신청연도 */
bgtAplyYr?: string;
/** 사업명 */
bizNm: string;
/** 사업시작연도 */
bizBgngYr: string;
/** 사업종료연도 */
bizEndYr: string;
/** 사업내용 */
bizCn: string;
aplcntNm: string;
aplcntMblTelno: string;
aplcntOgdpNm: string;
aplcntEmlAddr: string;
/** 신청자명 */
aplcntNm?: string;
/** 신청자휴대전화번호 */
aplcntMblTelno?: string;
/** 신청자소속명 */
aplcntOgdpNm?: string;
/** 신청자이메일주소 */
aplcntEmlAddr?: string;
/** 설계자명 */
dsgnrNm: string;
/** 설계자전화번호 */
dsgnrTelno: string;
/** 설계자소속명 */
dsgnrOgdpNm: string;
/** 설계자이메일주소 */
dsgnrEmlAddr: string;
atchFileGroupId: string;
prgrsSttsComCd: string;
/** 첨부파일그룹아이디 */
atchFileGroupId?: string;
/** 진행상태공통코드 */
prgrsSttsComCd?: string;
/** 재이용시설구분공통코드 */
rtlztnFcltSeComCd?: string;
/** 재이용용도구분공통코드 */
rtlztnUsgSeComCd?: string;
}
/** 설치인가관리 응답 DTO */
export interface InstlSncnMngResponseDto extends InstlSncnMngSaveRequestDto {
/** 설치인가문서번호 (필수로 재정의) */
instlSncnDocNo: string;
/** 환경청구분공통코드명 */
envoSeCdNm: string;
/** 인가구분공통코드명 */
sncnSeCdNm: string;
/** 업무구분공통코드명 */
taskSeCdNm: string;
/** 진행상태공통코드명 */
prgrsSttsCdNm: string;
/** 재이용시설구분공통코드명 */
rtlztnFcltSeComNm: string;
/** 재이용용도구분공통코드명 */
rtlztnUsgSeComNm: string;
/** 등록일시 */
regDt: string;
mdfcnDt: string;
/** 등록자 UUID */
rgtr: string;
/** 등록자명 */
rgtrNm: string;
/** 수정 일시 */
mdfcnDt: string;
/** 시도 */
sido: string;
/** 구군 */
gugun: string;
}
/** 설치인가 신청 - 사업규모 저장 DTO(배열 원소) */
@@ -88,6 +130,7 @@ export interface InstlSncnCklstResultSaveDto {
cklstRvwSn: number;
rvwStpComCd: string;
selected: "Y" | "N" | null;
picRvwCn?: string | null;
}
/** 설치인가 신청 - 필수첨부파일 저장 DTO(배열 원소) */
@@ -98,7 +141,7 @@ export interface InstlSncnEsntlAtchFileSaveDto {
/** 설치인가 신청 - 통합 저장 요청 DTO */
export interface InstlSncnApplySaveRequestDto {
instlSncnMng: InstlSncnMng;
instlSncnMng: InstlSncnMngSaveRequestDto;
bzScaleList: InstlSncnBzScaleSaveDto[];
bizCstList: InstlSncnBizCstSaveRequestDto[];
cklstResultList: InstlSncnCklstResultSaveDto[];
@@ -107,17 +150,190 @@ export interface InstlSncnApplySaveRequestDto {
export type ReviewStepCode = "LOCAL" | "ENV" | "KECO";
/** 공종 트리와 사업규모(수량) 조합 DTO */
export interface BizSclTreeDto {
/** 들여쓰기 적용된 공종명(시각적) */
indCnstpSeCdNm: string;
/** 공종 코드 */
cnstpSeCd: string;
/** 공종 코드명 */
cnstpSeCdNm: string;
/** 사용 여부(Y/N) */
useYn: string;
/** 트리 레벨(깊이) */
treeLevel: number;
/** 트리 경로 표시용 문자열 */
treePath: string;
/** 기본계획수량 */
bscPlanQty: number;
/** 예산반영수량 */
bgtRfltQty: number;
/** 금회설계수량 */
tstmDesignQty: number;
}
/** 회계구분 트리와 사업비용 조합 DTO */
export interface BizCstTreeDto {
/** 들여쓰기 적용된 회계구분명(시각적) */
indAcntgSeCdNm: string;
/** 회계구분 코드 */
acntgSeCd: string;
/** 회계구분 코드명 */
acntgSeCdNm: string;
/** 사용 여부(Y/N) */
useYn: string;
/** 트리 레벨(깊이) */
treeLevel: number;
/** 트리 경로 표시용 문자열 */
treePath: string;
/** 기본계획금액 */
bscPlanAmt: number;
/** 예산반영금액 */
bgtRfltAmt: number;
/** 금회설계금액 */
tstmDesignAmt: number;
}
/** 점검표 검토결과 트리 DTO */
export interface CklstRvwRsltTreeDto {
/** 점검표일련번호 */
cklstSn: number;
/** 점검표명 */
cklstNm: string;
/** 점검표항목일련번호 */
cklstArtclSn: number;
/** 점검표항목명 */
cklstArtclNm: string;
/** 검토일련번호 */
cklstRvwSn: number;
/** 상위검토일련번호 */
upCklstRvwSn: number | null;
/** 트리 레벨 */
treeLevel: number;
/** 검토내용 */
rvwCn: string;
/** 점검 사용 여부 */
bchckUseYn: string;
/** 정렬순서 */
sortSeq: number;
/** 업무구분코드 */
taskSeComCd: string;
/** 검토단계공통코드 */
rvwStpComCd: string;
/** 검토결과이상여부 */
rvwRsltAnmlyYn: string;
/** 검토담당자 UUID */
rvwPicUuid: string | null;
/** 담당자검토내용 */
picRvwCn: string | null;
/** 검토일시 */
rvwDt: string | null;
}
/** 필수첨부파일관리 트리 조회 응답 DTO */
export interface EsntlAtchFileMngTreeResponseDto {
/** 필수첨부파일아이디 */
esntlAtchFileId: string;
/** 문서구분공통코드 */
docSeComCd: string;
/** 필수첨부파일명 */
esntlAtchFileNm: string;
/** 필수첨부여부 */
esntlAtchYn: string;
/** 필수첨부파일비고 */
esntlAtchFileRmrk: string | null;
/** 첨부파일 코드 */
atflCd?: string;
/** 첨부파일 일련번호 */
atflSn?: number;
/** 첨부파일 ID */
atflId?: string;
/** 원본 파일명 */
orgnlFileNm?: string;
/** 첨부파일 크기 */
atflFlieSz?: number;
/** 첨부파일 타입 */
atflType?: string;
/** 다운로드 횟수 */
dwnldNocs?: number;
/** 등록일시 */
regDt?: string;
}
/** 첨부파일 응답 DTO (attach-service client) */
export interface AtflMngClientResponseDto {
atflCd: string;
atflSn: number;
atflId: string;
physFileNm: string;
orgnlFileNm: string;
atflFlieSz: number;
atflType: string;
dwnldNocs: number;
delYn: string;
lnkgDmnId: string | null;
lnkgDmnNm: string | null;
regDt: string;
}
/** 설치인가검토의견 응답 DTO */
export interface InstlSncnRvwOpnnResponseDto {
/** 설치인가문서번호 */
instlSncnDocNo: string;
/** 검토의견일련번호 */
rvwOpnnSn: number;
/** 의견자구분공통코드 */
opnrSeComCd: string;
/** 의견자구분공통코드명 */
opnrSeCdNm: string | null;
/** 의견자명 */
opnrNm: string;
/** 검토결과내용 */
rvwRsltCn: string;
/** 검토결과이상여부 */
rvwRsltAnmlyYn: string;
/** 첨부파일그룹아이디 */
atchFileGroupId: string | null;
/** 첨부파일 상세 정보 */
atchFileInfo?: AtflMngClientResponseDto;
/** 등록일시 */
regDt: string;
}
/** 전자결재 진행상태 (클라이언트 응답용) */
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;
}
/** 설치인가관리 상세 응답 DTO */
export interface InstlSncnMngDetResponse {
detDto: InstlSncnMng;
bizSclList: any[];
bizCstList: any[];
cklstResultMap?: Record<ReviewStepCode, any[]>;
esntlFileList: any[];
etcFileList: any[];
detDto: InstlSncnMngResponseDto;
bizSclList: BizSclTreeDto[];
bizCstList: BizCstTreeDto[];
cklstResultList?: CklstRvwRsltTreeDto[];
esntlFileList: EsntlAtchFileMngTreeResponseDto[];
etcFileList: AtflMngClientResponseDto[];
rvwList?: InstlSncnRvwOpnnResponseDto[];
elaprDocPrgrsList?: ElaprDocPrgrsClientResponseDto[] | null;
}
export interface InstlSncnMngService {
getPage: (params: InstlSncnMngSearchParams) => Promise<SpringPage<InstlSncnMng>>;
getPage: (params: InstlSncnMngSearchParams) => Promise<PageResponse<InstlSncnMngResponseDto>>;
getById: (id: string) => Promise<InstlSncnMngDetResponse>;
saveApply: (payload: InstlSncnApplySaveRequestDto) => Promise<string>;
techReview: (id: string, payload: InstlSncnApplySaveRequestDto) => Promise<void>;
}
@@ -0,0 +1,102 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import type {
RvwMnpwMngPageSearchDto,
RvwMnpwMngSaveRequestDto,
RvwMnpwMngSearchDto,
RvwMnpwMngUpdateRequestDto,
} from "../model/rvw-mnpw-interface";
import { rvwMnpwMngService } from "./rvw-mnpw-service";
const rvwMnpwMngBaseKey = [ENDPOINT.AS_SERVICE.RVW_MNPW] as const;
export function useRvwMnpwMngPage(
params: RvwMnpwMngPageSearchDto,
options?: { enabled?: boolean },
) {
return useQuery({
queryKey: [...rvwMnpwMngBaseKey, "findPage", params],
queryFn: () => rvwMnpwMngService.findPage(params),
enabled: options?.enabled,
});
}
export function useRvwMnpwMngLoginUserList(
searchDto: RvwMnpwMngSearchDto,
options?: { enabled?: boolean },
) {
return useQuery({
queryKey: [...rvwMnpwMngBaseKey, "findRvwLoginUserList", searchDto],
queryFn: () => rvwMnpwMngService.findRvwLoginUserList(searchDto),
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,81 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { PageResponse } from "@/shared/model";
import type {
RvwMnpwMngPageSearchDto,
RvwMnpwMngResponseDto,
RvwMnpwMngSaveRequestDto,
RvwMnpwMngSearchDto,
RvwMnpwMngService,
RvwMnpwMngUpdateRequestDto,
} from "../model/rvw-mnpw-interface";
const BASE = ENDPOINT.AS_SERVICE.RVW_MNPW;
async function findPage(
params: RvwMnpwMngPageSearchDto,
): Promise<PageResponse<RvwMnpwMngResponseDto>> {
const response = await client.get<PageResponse<RvwMnpwMngResponseDto>>(BASE, { params });
return response.data;
}
async function findRvwLoginUserList(
searchDto: RvwMnpwMngSearchDto,
): Promise<PageResponse<RvwMnpwMngResponseDto>> {
const response = await client.get<PageResponse<RvwMnpwMngResponseDto>>(`${BASE}/loginuser/me`, {
params: searchDto,
});
return response.data;
}
async function findById(
atrzSeComCd: string,
ognzSn: number,
userUuid: string,
): Promise<RvwMnpwMngResponseDto> {
const response = await client.get<RvwMnpwMngResponseDto>(
`${BASE}/${atrzSeComCd}/${ognzSn}/${userUuid}`,
);
return response.data;
}
async function saveRvwMnpwMng(body: RvwMnpwMngSaveRequestDto): Promise<RvwMnpwMngResponseDto> {
const { atrzSeComCd, ognzSn, userUuid } = body;
const response = await client.post<RvwMnpwMngResponseDto>(
`${BASE}/${atrzSeComCd}/${ognzSn}/${userUuid}`,
body,
);
return response.data;
}
async function updateRvwMnpwMng(
atrzSeComCd: string,
ognzSn: number,
userUuid: string,
body: RvwMnpwMngUpdateRequestDto,
): Promise<RvwMnpwMngResponseDto> {
const response = await client.put<RvwMnpwMngResponseDto>(
`${BASE}/${atrzSeComCd}/${ognzSn}/${userUuid}`,
body,
);
return response.data;
}
async function deleteRvwMnpwMng(
atrzSeComCd: string,
ognzSn: number,
userUuid: string,
): Promise<string> {
const response = await client.delete<string>(`${BASE}/${atrzSeComCd}/${ognzSn}/${userUuid}`);
return response.data;
}
export const rvwMnpwMngService = {
findPage,
findRvwLoginUserList,
findById,
save: saveRvwMnpwMng,
update: updateRvwMnpwMng,
delete: deleteRvwMnpwMng,
} satisfies RvwMnpwMngService;
+17
View File
@@ -0,0 +1,17 @@
export {
useDeleteRvwMnpwMng,
useRvwMnpwMng,
useRvwMnpwMngLoginUserList,
useRvwMnpwMngPage,
useSaveRvwMnpwMng,
useUpdateRvwMnpwMng,
} from "./api/queries";
export { rvwMnpwMngService } from "./api/rvw-mnpw-service";
export type {
RvwMnpwMngPageSearchDto,
RvwMnpwMngResponseDto,
RvwMnpwMngSaveRequestDto,
RvwMnpwMngSearchDto,
RvwMnpwMngService,
RvwMnpwMngUpdateRequestDto,
} from "./model/rvw-mnpw-interface";
@@ -0,0 +1,54 @@
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 */
export interface RvwMnpwMngSearchDto {
atrzSeComCd?: string;
ognzSn?: number;
userUuid?: string;
}
/** findPage — RvwMnpwMngSearchDto + Pageable */
export type RvwMnpwMngPageSearchDto = RvwMnpwMngSearchDto & PageableParams;
/** 백엔드 RvwMnpwMngSaveRequestDto */
export interface RvwMnpwMngSaveRequestDto {
atrzSeComCd: string;
ognzSn: number;
userUuid: string;
}
/** 백엔드 RvwMnpwMngUpdateRequestDto */
export interface RvwMnpwMngUpdateRequestDto {
newAtrzSeComCd: string;
}
/** Controller public 메서드명·시그니처와 동일 */
export interface RvwMnpwMngService {
findPage(params: RvwMnpwMngPageSearchDto): Promise<PageResponse<RvwMnpwMngResponseDto>>;
findRvwLoginUserList(
searchDto: 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>;
}
+1
View File
@@ -0,0 +1 @@
export { handleTusUpload, type TusUploadOptions } from "./lib/utils";
+61
View File
@@ -0,0 +1,61 @@
import { Upload } from "tus-js-client";
import { ENDPOINT, PROXY_HOST } from "@/shared/config";
import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config/env";
import { getCookie } from "@/shared/lib";
interface TusUploadOptions {
metadata?: Record<string, string>;
handleUploadStart?: (upload: Upload) => void;
handleProgress?: (percentage: number) => void;
handleSuccess?: (upload: Upload, atflCd: string, atflId: string) => void;
handleError?: (error: Error) => void;
}
function handleTusUpload(file: File, options?: TusUploadOptions) {
let atflCd = "";
let atflId = "";
const metadata = {
filename: file.name,
filetype: file.type,
...options?.metadata,
};
const upload = new Upload(file, {
endpoint: `${PROXY_HOST}${ENDPOINT.ATTACH_SERVICE.TUS_UPLOAD}`,
chunkSize: 10 * 1024 * 1024,
headers: {
[CLAIM_NAME]: getCookie(CLAIM_NAME) ?? "",
[AUTH_USER_ID]: getCookie(AUTH_USER_ID) ?? "",
},
metadata,
onError(error) {
console.error("TUS upload failed:", error);
options?.handleError?.(error);
},
onProgress(bytesUploaded, bytesTotal) {
const percentage = (bytesUploaded / bytesTotal) * 100;
options?.handleProgress?.(percentage);
},
onAfterResponse(_req, res) {
const fileInfo = res.getHeader("X-File-Info");
if (!fileInfo) return;
const parsed = JSON.parse(fileInfo) as { atflCd?: string; atflId?: string };
atflCd = parsed.atflCd ?? "";
atflId = parsed.atflId ?? "";
},
onSuccess() {
options?.handleSuccess?.(upload, atflCd, atflId);
},
});
upload.start();
options?.handleUploadStart?.(upload);
return upload;
}
export { handleTusUpload };
export type { TusUploadOptions };
@@ -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>
@@ -102,7 +102,7 @@ function CklstMngFormDialog({
<div className="space-y-2">
<Label>문서구분</Label>
<CodeSelect
upCd="ASDOCSECD"
upCd="ASDOCSE"
value={docSeComCd}
onValueChange={(v) => setValue("docSeComCd", v)}
placeholder="문서구분 선택"
@@ -112,7 +112,7 @@ function CklstMngFormDialog({
<div className="space-y-2">
<Label>업무구분</Label>
<CodeSelect
upCd={docSeComCd || "ASDOCSECD"}
upCd={docSeComCd || "ASDOCSE"}
value={watch("taskSeComCd")}
onValueChange={(v) => setValue("taskSeComCd", v)}
placeholder="업무구분 선택"
@@ -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>

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