Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/st/dev

This commit is contained in:
2026-07-08 15:56:41 +09:00
417 changed files with 23002 additions and 11722 deletions
@@ -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,14 @@
import { InstlRegistPage } from "@/pages/instl-sncn";
interface InstlSncnRegistRouteProps {
searchParams: Promise<{ taskSeComCd?: string }>;
}
export default async function Page({ searchParams }: InstlSncnRegistRouteProps) {
const params = await searchParams;
const taskSeComCd = params.taskSeComCd
? params.taskSeComCd.split(",").filter(Boolean)
: undefined;
return <InstlRegistPage taskSeComCd={taskSeComCd} />;
}
@@ -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.
+1
View File
@@ -24,6 +24,7 @@
"@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",
@@ -12,7 +12,7 @@ import {
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 });
+1 -1
View File
@@ -10,7 +10,7 @@ import type {
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({
@@ -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,22 @@
import { AxiosResponse } from "axios";
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { AttachService } from "../model/attach-interface";
async function deleteAttachmentByAtflId(atflId: string): Promise<void> {
if (!atflId) return;
await client.delete(`${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}/${atflId}`);
}
async function downloadAttachmentByAtflCd(atflCd: string): Promise<AxiosResponse> {
return await client.get<Blob>(`${ENDPOINT.ATTACH_SERVICE.DOWNLOAD}/${atflCd}`, {
responseType: "blob",
});
}
export const attachService = {
deleteAttachmentByAtflId,
downloadAttachmentByAtflCd,
} satisfies AttachService;
@@ -0,0 +1,39 @@
import { useMutation } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import { parseFilenameFromResponse } from "../lib/parse-filename-from-response";
import { attachService } from "./attach-service";
const attachBaseKey = [ENDPOINT.ATTACH_SERVICE.ATTACHMENTS] as const;
const attachDownloadBaseKey = [ENDPOINT.ATTACH_SERVICE.DOWNLOAD] as const;
export function useDeleteAttachment() {
return useMutation({
mutationKey: [...attachBaseKey, "delete"],
mutationFn: (atflId: string) => attachService.deleteAttachmentByAtflId(atflId),
});
}
export function useDownloadAttachmentByAtflCd() {
return useMutation({
mutationKey: [...attachDownloadBaseKey, "download"],
mutationFn: async (atflCd: string) => {
const response = await attachService.downloadAttachmentByAtflCd(atflCd);
const filename = parseFilenameFromResponse(response);
const blob = new Blob([response.data]);
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(link);
},
});
}
+3
View File
@@ -0,0 +1,3 @@
export { attachService } from "./api/attach-service";
export { useDeleteAttachment, useDownloadAttachmentByAtflCd } from "./api/queries";
export type { Attach, AttachService } from "./model/attach-interface";
@@ -0,0 +1,11 @@
import type { AxiosResponse } from "axios";
export function parseFilenameFromResponse(response: AxiosResponse): string {
const contentDisposition = response.headers["content-disposition"];
if (!contentDisposition) return "download";
const filenameMatch = contentDisposition.match(/filename\*=UTF-8''([^;]+)/);
if (!filenameMatch?.[1]) return "download";
return decodeURIComponent(filenameMatch[1]);
}
@@ -0,0 +1,22 @@
import { AxiosResponse } from "axios";
/** 백엔드 AtflMngResponseDto */
export interface Attach {
atflCd: string;
atflSn: number;
atflId: string;
physFileNm: string;
orgnlFileNm: string;
atflFlieSz: number;
atflType: string;
dwnldNocs: number;
delYn: string;
lnkgDmnId: string;
lnkgDmnNm: string;
regDt: string;
}
export interface AttachService {
deleteAttachmentByAtflId: (atflId: string) => Promise<void>;
downloadAttachmentByAtflCd: (atflCd: string) => Promise<AxiosResponse>;
}
@@ -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({
@@ -12,7 +12,7 @@ import {
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 });
+1 -1
View File
@@ -10,7 +10,7 @@ import type {
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({
@@ -0,0 +1,67 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { PageResponse } from "@/shared/model";
import type {
ElaprDmndResponseDto,
ElaprDmndSaveRequestDto,
ElaprDmndSearchDto,
ElaprDmndService,
ElaprDocPrgrsResponseDto,
} from "../model/elapr-dmnd-interface";
const BASE = ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND;
async function findPage(params: ElaprDmndSearchDto): Promise<PageResponse<ElaprDmndResponseDto>> {
const response = await client.get<PageResponse<ElaprDmndResponseDto>>(BASE, { params });
return response.data;
}
async function findById(elaprDmndId: string): Promise<ElaprDmndResponseDto> {
const response = await client.get<ElaprDmndResponseDto>(`${BASE}/${elaprDmndId}`);
return response.data;
}
async function save(body: ElaprDmndSaveRequestDto): Promise<ElaprDmndResponseDto> {
const response = await client.post<ElaprDmndResponseDto>(BASE, body);
return response.data;
}
async function update(
elaprDmndId: string,
body: ElaprDmndSaveRequestDto,
): Promise<ElaprDmndResponseDto> {
const response = await client.put<ElaprDmndResponseDto>(`${BASE}/${elaprDmndId}`, body);
return response.data;
}
async function updateStatus(
elaprDmndId: string,
atrzSttsComCd: string,
): Promise<ElaprDmndResponseDto> {
const response = await client.put<ElaprDmndResponseDto>(`${BASE}/${elaprDmndId}/status`, null, {
params: { atrzSttsComCd },
});
return response.data;
}
async function remove(elaprDmndId: string): Promise<void> {
await client.delete(`${BASE}/${elaprDmndId}`);
}
async function findDocPrgrsByDocNo(elaprDocNo: string): Promise<ElaprDocPrgrsResponseDto[]> {
const response = await client.get<ElaprDocPrgrsResponseDto[]>(
`${BASE}/sanc/docprgrs/${elaprDocNo}`,
);
return response.data;
}
export const elaprDmndService = {
findPage,
findById,
save,
update,
updateStatus,
remove,
findDocPrgrsByDocNo,
} satisfies ElaprDmndService;
@@ -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,
});
}
+88 -32
View File
@@ -8,6 +8,7 @@ import type {
ElaprAplnSaveRequest,
ElaprAplnUpdateRequest,
} from "../model/elapr-apln-interface";
import type { ElaprDmndSaveRequestDto, ElaprDmndSearchDto } from "../model/elapr-dmnd-interface";
import type {
ElaprTmpltSaveRequest,
ElaprTmpltSearchParams,
@@ -15,10 +16,12 @@ import type {
} from "../model/elapr-tmplt-interface";
import { elaprAplnService } from "./elapr-apln-service";
import { elaprDmndService } from "./elapr-dmnd-service";
import { elaprTmpltService } from "./elapr-tmplt-service";
const elaprTmpltBaseKey = [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_TMPLT] as const;
const elaprAplnBaseKey = [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_APLN] as const;
const elaprDmndBaseKey = [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND] as const;
export function useElaprTmpltPage(params: ElaprTmpltSearchParams) {
return useQuery({
@@ -50,13 +53,8 @@ export function useUpdateElaprTmplt() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
elaprTmpltId,
body,
}: {
elaprTmpltId: string;
body: ElaprTmpltUpdateRequest;
}) => elaprTmpltService.update(elaprTmpltId, body),
mutationFn: ({ elaprTmpltId, body }: { elaprTmpltId: string; body: ElaprTmpltUpdateRequest }) =>
elaprTmpltService.update(elaprTmpltId, body),
onSuccess: (_data, { elaprTmpltId }) => {
queryClient.invalidateQueries({ queryKey: elaprTmpltBaseKey });
queryClient.invalidateQueries({
@@ -99,12 +97,8 @@ export function useSaveMultiElaprApln() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
body,
}: {
body: ElaprAplnSaveRequest[];
elaprTmpltId: string;
}) => elaprAplnService.saveMulti(body),
mutationFn: ({ body }: { body: ElaprAplnSaveRequest[]; elaprTmpltId: string }) =>
elaprAplnService.saveMulti(body),
onSuccess: (_data, { elaprTmpltId }) => {
queryClient.invalidateQueries({ queryKey: elaprAplnBaseKey });
queryClient.invalidateQueries({
@@ -118,13 +112,8 @@ export function useUpdateElaprApln() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
aplnId,
body,
}: {
aplnId: string;
body: ElaprAplnUpdateRequest;
}) => elaprAplnService.update(aplnId, body),
mutationFn: ({ aplnId, body }: { aplnId: string; body: ElaprAplnUpdateRequest }) =>
elaprAplnService.update(aplnId, body),
onSuccess: (_data, { body, aplnId }) => {
queryClient.invalidateQueries({ queryKey: elaprAplnBaseKey });
queryClient.invalidateQueries({
@@ -141,12 +130,8 @@ export function useDeleteElaprApln() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
aplnId,
}: {
aplnId: string;
elaprTmpltId: string;
}) => elaprAplnService.remove(aplnId),
mutationFn: ({ aplnId }: { aplnId: string; elaprTmpltId: string }) =>
elaprAplnService.remove(aplnId),
onSuccess: (_data, { elaprTmpltId }) => {
queryClient.invalidateQueries({ queryKey: elaprAplnBaseKey });
queryClient.invalidateQueries({
@@ -160,12 +145,8 @@ export function useReorderElaprApln() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
body,
}: {
body: ElaprAplnReorderRequest;
elaprTmpltId: string;
}) => elaprAplnService.reorder(body),
mutationFn: ({ body }: { body: ElaprAplnReorderRequest; elaprTmpltId: string }) =>
elaprAplnService.reorder(body),
onSuccess: (_data, { elaprTmpltId }) => {
queryClient.invalidateQueries({ queryKey: elaprAplnBaseKey });
queryClient.invalidateQueries({
@@ -174,3 +155,78 @@ export function useReorderElaprApln() {
},
});
}
export function useElaprDmndPage(params: ElaprDmndSearchDto) {
return useQuery({
queryKey: [...elaprDmndBaseKey, "findPage", params],
queryFn: () => elaprDmndService.findPage(params),
});
}
export function useElaprDmnd(elaprDmndId: string | null | undefined) {
return useQuery({
queryKey: [...elaprDmndBaseKey, "findById", elaprDmndId],
queryFn: () => elaprDmndService.findById(elaprDmndId!),
enabled: Boolean(elaprDmndId),
});
}
export function useElaprDocPrgrsByDocNo(elaprDocNo: string | null | undefined) {
return useQuery({
queryKey: [...elaprDmndBaseKey, "findDocPrgrsByDocNo", elaprDocNo],
queryFn: () => elaprDmndService.findDocPrgrsByDocNo(elaprDocNo!),
enabled: Boolean(elaprDocNo),
});
}
export function useSaveElaprDmnd() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: ElaprDmndSaveRequestDto) => elaprDmndService.save(body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: elaprDmndBaseKey });
},
});
}
export function useUpdateElaprDmnd() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ elaprDmndId, body }: { elaprDmndId: string; body: ElaprDmndSaveRequestDto }) =>
elaprDmndService.update(elaprDmndId, body),
onSuccess: (_data, { elaprDmndId }) => {
queryClient.invalidateQueries({ queryKey: elaprDmndBaseKey });
queryClient.invalidateQueries({
queryKey: [...elaprDmndBaseKey, "findById", elaprDmndId],
});
},
});
}
export function useUpdateElaprDmndStatus() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ elaprDmndId, atrzSttsComCd }: { elaprDmndId: string; atrzSttsComCd: string }) =>
elaprDmndService.updateStatus(elaprDmndId, atrzSttsComCd),
onSuccess: (_data, { elaprDmndId }) => {
queryClient.invalidateQueries({ queryKey: elaprDmndBaseKey });
queryClient.invalidateQueries({
queryKey: [...elaprDmndBaseKey, "findById", elaprDmndId],
});
},
});
}
export function useRemoveElaprDmnd() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (elaprDmndId: string) => elaprDmndService.remove(elaprDmndId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: elaprDmndBaseKey });
},
});
}
+15
View File
@@ -1,4 +1,5 @@
export { elaprAplnService } from "./api/elapr-apln-service";
export { elaprDmndService } from "./api/elapr-dmnd-service";
export { elaprTmpltService } from "./api/elapr-tmplt-service";
export {
useCreateElaprTmplt,
@@ -6,11 +7,18 @@ export {
useDeleteElaprTmplt,
useElaprApln,
useElaprAplnPage,
useElaprDmnd,
useElaprDmndPage,
useElaprDocPrgrsByDocNo,
useElaprTmplt,
useElaprTmpltPage,
useRemoveElaprDmnd,
useReorderElaprApln,
useSaveElaprDmnd,
useSaveMultiElaprApln,
useUpdateElaprApln,
useUpdateElaprDmnd,
useUpdateElaprDmndStatus,
useUpdateElaprTmplt,
} from "./api/queries";
export {
@@ -24,6 +32,13 @@ export {
type ElaprAplnService,
type ElaprAplnUpdateRequest,
} from "./model/elapr-apln-interface";
export type {
ElaprDmndResponseDto,
ElaprDmndSaveRequestDto,
ElaprDmndSearchDto,
ElaprDmndService,
ElaprDocPrgrsResponseDto,
} from "./model/elapr-dmnd-interface";
export type {
ElaprTmplt,
ElaprTmpltSaveRequest,
@@ -0,0 +1,62 @@
import type { PageableParams, PageResponse } from "@/shared/model";
/** 백엔드 ElaprDmndSaveRequestDto */
export interface ElaprDmndSaveRequestDto {
elaprDocNo: string;
elaprTmpltId: string;
dmndTtl: string;
dmndCn: string;
}
/** 백엔드 ElaprDmndResponseDto */
export interface ElaprDmndResponseDto {
elaprDmndId: string;
rqstrUuid?: string;
elaprDocNo: string;
elaprTmpltId: string;
dmndTtl: string;
dmndCn: string;
atrzSttsComCd: string;
atrzSttsComNm?: string;
dmndDt?: string;
updtDt?: string;
}
/** 백엔드 ElaprDmndSearchDto + Pageable */
export interface ElaprDmndSearchDto extends PageableParams {
rqstrUuid?: string;
atrzSttsComCd?: string;
dmndTtl?: string;
dmndDtStart?: string;
dmndDtEnd?: string;
}
/** 백엔드 ElaprDocPrgrsResponseDto */
export interface ElaprDocPrgrsResponseDto {
elaprDocNo: string;
elaprDmndId: string;
elaprPrgrsId: string;
atrzSeqNo?: number;
atrzSeComCd: string;
atrzSeComNm?: string;
aprvrUuid?: string;
aprvrUuidNm?: string;
atrzPrgrsSttsComCd: string;
atrzPrgrsSttsComNm?: string;
rjctRsn?: string;
atrzDt?: string;
jbpsSeNm?: string;
jbgdSeNm?: string;
ognzNm?: string;
}
/** Controller public 메서드명·시그니처와 동일 */
export interface ElaprDmndService {
findPage(params: ElaprDmndSearchDto): Promise<PageResponse<ElaprDmndResponseDto>>;
findById(elaprDmndId: string): Promise<ElaprDmndResponseDto>;
save(body: ElaprDmndSaveRequestDto): Promise<ElaprDmndResponseDto>;
update(elaprDmndId: string, body: ElaprDmndSaveRequestDto): Promise<ElaprDmndResponseDto>;
updateStatus(elaprDmndId: string, atrzSttsComCd: string): Promise<ElaprDmndResponseDto>;
remove(elaprDmndId: string): Promise<void>;
findDocPrgrsByDocNo(elaprDocNo: string): Promise<ElaprDocPrgrsResponseDto[]>;
}
+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,41 +1,91 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { PageResponse } from "@/shared/model";
import type {
AdstZoneResponseDto,
DocPrgrsUpdateRequestDto,
InstlSncnApplySaveRequestDto,
InstlSncnMng,
InstlSncnMngDetResponse,
InstlSncnMngResponseDto,
InstlSncnMngSaveRequestDto,
InstlSncnMngSearchParams,
InstlSncnMngService,
SpringPage,
InstlSncnMngDetResponse,
InstlSncnMngUpdateRequestDto,
} from "../model/instl-sncn-interface";
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, {
params,
});
return response.data;
}
async function getSidoCombo(): Promise<AdstZoneResponseDto[]> {
const response = await client.get<AdstZoneResponseDto[]>(
`${ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_MST}/sido`,
);
public async getById(id: string): Promise<InstlSncnMngDetResponse> {
const response = await client.get<InstlSncnMngDetResponse>(`${ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN}/${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,
);
return response.data;
}
public async techReview(payload: InstlSncnApplySaveRequestDto): Promise<string> {
const response = await client.post<string>(
`${ENDPOINT.AS_INSTL_SERVICE.INSTL_SNCN}/tech-review`,
payload,
);
return response.data;
}
return response.data;
}
export const InstlSncnApi = new InstlSncnApiService();
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;
async function getPage(
params: InstlSncnMngSearchParams,
): Promise<PageResponse<InstlSncnMngResponseDto>> {
const response = await client.get<PageResponse<InstlSncnMngResponseDto>>(BASE, {
params,
});
return response.data;
}
async function getById(id: string): Promise<InstlSncnMngDetResponse> {
const response = await client.get<InstlSncnMngDetResponse>(`${BASE}/${id}`);
return response.data;
}
async function save(payload: InstlSncnMngSaveRequestDto): Promise<string> {
const response = await client.post<string>(BASE, payload);
return response.data;
}
async function saveApply(payload: InstlSncnApplySaveRequestDto): Promise<string> {
const response = await client.post<string>(`${BASE}/save-apply`, payload);
return response.data;
}
async function update(id: string, payload: InstlSncnMngUpdateRequestDto): Promise<void> {
await client.put(`${BASE}/${id}`, payload);
}
async function techReview(id: string, payload: InstlSncnApplySaveRequestDto): Promise<void> {
await client.post(`${BASE}/${id}/tech-review`, payload);
}
async function remove(id: string): Promise<void> {
await client.delete(`${BASE}/${id}`);
}
async function changePrgrsSttsComCd(payload: DocPrgrsUpdateRequestDto): Promise<void> {
await client.post(`${BASE}/doc/updatePrgrsStts`, payload);
}
export const instlSncnService = {
getPage,
getById,
save,
saveApply,
update,
techReview,
remove,
changePrgrsSttsComCd,
} satisfies InstlSncnMngService;
// TODO: st-anls-mst service 로 분리
export const instlSncnRegionService = {
getSidoCombo,
getSigunguCombo,
};
@@ -0,0 +1,126 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import type {
DocPrgrsUpdateRequestDto,
InstlSncnApplySaveRequestDto,
InstlSncnMngSaveRequestDto,
InstlSncnMngSearchParams,
InstlSncnMngUpdateRequestDto,
} from "../model/instl-sncn-interface";
import { instlSncnRegionService, instlSncnService } from "./instl-sncn-service";
export const instlSncnKeys = {
all: ["instl-sncn"] as const,
list: () => [...instlSncnKeys.all, "list"] 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: () => instlSncnService.getPage(params),
});
}
export function useInstlSncnDetail(id: string) {
return useQuery({
queryKey: instlSncnKeys.detail(id),
queryFn: () => instlSncnService.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),
});
}
export function useInstlSncnSave() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: InstlSncnMngSaveRequestDto) => instlSncnService.save(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
},
});
}
export function useInstlSncnSaveApply() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: InstlSncnApplySaveRequestDto) => instlSncnService.saveApply(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
},
});
}
export function useInstlSncnUpdate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, payload }: { id: string; payload: InstlSncnMngUpdateRequestDto }) =>
instlSncnService.update(id, payload),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
queryClient.invalidateQueries({ queryKey: instlSncnKeys.detail(variables.id) });
},
});
}
export function useInstlSncnTechReview() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, payload }: { id: string; payload: InstlSncnApplySaveRequestDto }) =>
instlSncnService.techReview(id, payload),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
queryClient.invalidateQueries({ queryKey: instlSncnKeys.detail(variables.id) });
},
});
}
export function useInstlSncnRemove() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => instlSncnService.remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
},
});
}
export function useInstlSncnChangePrgrsStts() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: DocPrgrsUpdateRequestDto) =>
instlSncnService.changePrgrsSttsComCd(payload),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
queryClient.invalidateQueries({
queryKey: instlSncnKeys.detail(variables.docNo),
});
},
});
}
+25 -3
View File
@@ -1,11 +1,33 @@
export { InstlSncnApi } from "./api/instl-sncn-service";
export { instlSncnRegionService, instlSncnService } from "./api/instl-sncn-service";
export {
instlSncnKeys,
useInstlSncnDetail,
useInstlSncnList,
useInstlSncnSidoCombo,
useInstlSncnSigunguCombo,
useInstlSncnTechReview,
} from "./api/queries";
export {
type AdstZoneResponseDto,
type AtflMngClientResponseDto,
type BizCstTreeDto,
type BizSclTreeDto,
type CklstRvwRsltTreeDto,
type DocPrgrsUpdateRequestDto,
type ElaprDocPrgrsClientResponseDto,
type EsntlAtchFileMngTreeResponseDto,
INSTL_SNCN_REGISTER_SELECTION_KEY,
type InstlSncnApplySaveRequestDto,
type InstlSncnBizCstSaveRequestDto,
type InstlSncnBzScaleSaveDto,
type InstlSncnCklstResultSaveDto,
type InstlSncnMng,
type InstlSncnEsntlAtchFileSaveDto,
type InstlSncnMngDetResponse,
type InstlSncnMngResponseDto,
type InstlSncnMngSaveRequestDto,
type InstlSncnMngSearchParams,
type SpringPage,
type InstlSncnRvwOpnnResponseDto,
type InstlSncnRvwOpnnSaveRequestDto,
type InstlSncnSplmntDmndSaveDto,
type ReviewStepCode,
} from "./model/instl-sncn-interface";
@@ -1,72 +1,127 @@
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;
/** 첨부파일그룹아이디 */
atchFileGroupId?: string;
/** 진행상태공통코드 */
prgrsSttsComCd?: string;
/** 재이용시설구분공통코드 */
rtlztnFcltSeComCd?: string;
/** 재이용용도구분공통코드 */
rtlztnUsgSeComCd?: string;
}
/** 설치인가관리 수정 요청 DTO */
export type InstlSncnMngUpdateRequestDto = InstlSncnMngSaveRequestDto;
/** 문서 진행상태 수정 요청 DTO */
export interface DocPrgrsUpdateRequestDto {
/** 행정지원문서번호 */
docNo: string;
/** 문서구분코드 */
docSeComCd: string;
/** 진행상태공통코드 */
prgrsSttsComCd: 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(배열 원소) */
export interface InstlSncnBzScaleSaveDto {
/** 공종구분코드 */
cnstpSeCd: string;
/** 기본계획(A) */
/** 기본계획수량 */
bscPl: number;
/** 예산반영(B) */
/** 예산반영수량 */
budgetReflct: number;
/** 금회설계시(C) */
/** 금회설계수량 */
thtiDsgn: number;
/** 증감(C-B) */
diff: number;
/** 증감(C-B) — 클라이언트 계산용, API 미전송 */
diff?: number;
}
/** 설치인가 신청 - 총사업비 저장 DTO(배열 원소) */
@@ -83,42 +138,284 @@ export interface InstlSncnBizCstSaveRequestDto {
/** 설치인가 신청 - 체크리스트 결과 저장 DTO(배열 원소) */
export interface InstlSncnCklstResultSaveDto {
/** 점검표일련번호 */
cklstSn: number;
/** 점검표항목일련번호 */
cklstArtclSn: number;
/** 점검표검토일련번호 */
cklstRvwSn: number;
/** 검토단계공통코드 */
rvwStpComCd: string;
/** 선택값(Y/N/null) */
selected: "Y" | "N" | null;
/** 담당자검토내용(기술검토의견) */
picRvwCn?: string | null;
}
/** 설치인가 신청 - 필수첨부파일 저장 DTO(배열 원소) */
export interface InstlSncnEsntlAtchFileSaveDto {
/** 필수첨부파일아이디 */
esntlAtchFileId: string;
/** 첨부파일그룹아이디 */
atchFileGroupId: string;
}
/** 설치인가검토의견 저장 요청 DTO */
export interface InstlSncnRvwOpnnSaveRequestDto {
/** 설치인가문서번호 */
instlSncnDocNo: string;
/** 검토의견일련번호 */
rvwOpnnSn?: number;
/** 의견자구분공통코드 */
opnrSeComCd?: string;
/** 의견자명 */
opnrNm?: string;
/** 검토결과내용 */
rvwRsltCn?: string;
/** 검토결과이상여부 */
rvwRsltAnmlyYn?: string;
/** 첨부파일그룹아이디 */
atchFileGroupId?: string;
}
/** 설치인가 보완요구 저장 DTO(배열 원소) */
export interface InstlSncnSplmntDmndSaveDto {
/** 보완요구 일련번호 */
splmntDmndSn?: number;
/** 보완항목 공통코드 */
splmntArtclComCd?: string;
/** 보완요구 내용 */
splmntDmndCn?: string;
/** 보완요구 일시 */
splmntDmndDt?: string;
/** 보완요구 첨부파일 그룹 ID */
splmntDmndAtchFileGroupId?: string;
/** 보완 내용 */
splmntCn?: string;
/** 보완 담당자 명 */
splmntPicNm?: string;
/** 보완완료 일시 */
splmntCmptnDt?: string;
/** 보완 첨부파일 그룹 ID */
splmntAtchFileGroupId?: string;
/** 완료 여부 */
cmptnYn?: string;
}
/** 설치인가 신청 - 통합 저장 요청 DTO */
export interface InstlSncnApplySaveRequestDto {
instlSncnMng: InstlSncnMng;
/** 기술검토저장구분(SV.저장, CP.검토완료) */
techReViewSaveCd?: string;
/** 검토단계공통코드(ENV.환경청.EP, KECO.환경공단.TE) */
rvwStpCd?: string;
/** 설치인가관리 정보 */
instlSncnMng: InstlSncnMngSaveRequestDto;
/** 사업규모 목록 */
bzScaleList: InstlSncnBzScaleSaveDto[];
/** 총사업비 목록 */
bizCstList: InstlSncnBizCstSaveRequestDto[];
/** 체크리스트 검토결과 목록 */
cklstResultList: InstlSncnCklstResultSaveDto[];
/** 체크리스트 검토결과 맵 (rvwStpComCd -> 목록) */
cklstResultMap?: Record<string, InstlSncnCklstResultSaveDto[]>;
/** 필수첨부파일 목록 */
esntlAtchFileList: InstlSncnEsntlAtchFileSaveDto[];
/** 검토의견 목록 */
rvwOpnnList?: InstlSncnRvwOpnnSaveRequestDto[];
/** 보완요구 목록 */
splmntDmndList?: InstlSncnSplmntDmndSaveDto[];
}
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[];
cklstResultList?: 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>;
save: (payload: InstlSncnMngSaveRequestDto) => Promise<string>;
saveApply: (payload: InstlSncnApplySaveRequestDto) => Promise<string>;
update: (id: string, payload: InstlSncnMngUpdateRequestDto) => Promise<void>;
techReview: (id: string, payload: InstlSncnApplySaveRequestDto) => Promise<void>;
remove: (id: string) => Promise<void>;
changePrgrsSttsComCd: (payload: DocPrgrsUpdateRequestDto) => 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 */
@@ -145,6 +145,15 @@ export function collectDescendantRowKeys(
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,
@@ -153,12 +162,7 @@ export function insertChildSlot(
const parentIndex = structure.findIndex((item) => item.rowKey === parentRowKey);
if (parentIndex === -1) return [...structure, slot];
const parentDepth = structure[parentIndex].depth;
let insertAt = parentIndex + 1;
while (insertAt < structure.length && structure[insertAt].depth > parentDepth) {
insertAt += 1;
}
const insertAt = parentIndex + 1;
return [...structure.slice(0, insertAt), slot, ...structure.slice(insertAt)];
}
@@ -214,6 +218,21 @@ export function markAcntgRowsDeleted(
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);
@@ -13,11 +13,13 @@ import {
hasAcntgGridPendingChanges,
hydrateGridFromServer,
insertChildSlot,
insertRootSlot,
markAcntgRowsDeleted,
mergeAcntgRow,
mergeAllAcntgRows,
patchAcntgGridRow,
removeDraftFromGrid,
validateDraftParentAcntgSeCd,
} from "./acntg-se-grid";
import { buildSaveMultiPayload } from "./build-save-multi-payload";
@@ -81,51 +83,43 @@ export function useAcntgSeRows({ serverContent, onAlert, onSave }: UseAcntgSeRow
useYn: "Y",
},
},
structure: [...prev.structure, { rowKey, depth: 0, parentRowKey: null }],
structure: insertRootSlot(prev.structure, { rowKey, depth: 0, parentRowKey: null }),
}));
}, []);
const addChildRow = useCallback(
(parent: AcntgSeRow) => {
if (parent.isNew) {
onAlert("상위 행을 먼저 저장한 후 하위 항목을 추가할 수 있습니다.");
return;
}
const addChildRow = useCallback((parent: AcntgSeRow) => {
const rowKey = createDraftRowKey();
const slot = {
rowKey,
depth: 0,
parentRowKey: parent.rowKey,
};
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;
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",
},
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,
}),
};
});
},
[onAlert],
);
},
structure: insertChildSlot(prev.structure, parent.rowKey, {
...slot,
depth,
}),
};
});
}, []);
const removeDraft = useCallback((rowKey: string) => {
setGrid((prev) => removeDraftFromGrid(prev, rowKey));
@@ -146,6 +140,12 @@ export function useAcntgSeRows({ serverContent, onAlert, onSave }: UseAcntgSeRow
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);
@@ -86,15 +86,10 @@ function AcntgCodeManagementPage() {
<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" onClick={addRootRow}>
<Button type="button" color="green" onClick={addRootRow}>
코드 추가
</Button>
<Button
type="button"
color="green"
disabled={!canSave || isSaving}
onClick={handleSave}
>
<Button type="button" disabled={!canSave || isSaving} onClick={handleSave}>
저장
</Button>
</div>
@@ -110,38 +105,40 @@ function AcntgCodeManagementPage() {
등록된 코드가 없습니다. [코드 추가]로 1단계 코드를 등록하세요.
</p>
) : (
<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 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>
@@ -41,7 +41,7 @@ function AcntgSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Acn
onChange={(e) => onChange({ acntgSeCd: e.target.value })}
placeholder="코드"
maxLength={10}
readOnly={!row.isNew}
disabled={!row.isNew}
className="flex-1"
/>
</div>
@@ -85,12 +85,12 @@ function AcntgSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Acn
type="button"
size="sm"
color="white"
disabled={row.isNew || !canAddChildRow()}
disabled={!canAddChildRow()}
onClick={onAddChild}
>
하위추가
</Button>
<Button type="button" size="sm" color="red" onClick={onDelete}>
<Button type="button" size="sm" color="gray" onClick={onDelete}>
삭제
</Button>
</div>
@@ -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>
@@ -92,7 +92,7 @@ function ElaprTmpltFormDialog({
<div className="space-y-2">
<Label>문서구분</Label>
<CodeSelect
upCd="ASDOCSECD"
upCd="ASDOCSE"
value={docSeComCd}
onValueChange={(v) => setValue("docSeComCd", v)}
placeholder="문서구분 선택"
@@ -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>
@@ -67,6 +67,7 @@ function CklstPreviewDialog({
<TableBody>
{displayRows.map(({ row, depth }) => {
const branchPrefix = formatTreeBranchPrefix(depth);
const showRadios = row.bchckUseYn !== "N";
if (row.useYn === "N") return null;
@@ -82,16 +83,20 @@ function CklstPreviewDialog({
</TableCell>
<TableCell>{row.rvwCn ?? "—"}</TableCell>
<TableCell align="center">
<RadioGroup className="flex items-center justify-center">
<div className="flex items-center gap-1">
<RadioGroupItem value="Y" id={`${row.rowKey}-use-y`} />
<Label htmlFor={`${row.rowKey}-use-y`}>예</Label>
</div>
<div className="flex items-center gap-1">
<RadioGroupItem value="N" id={`${row.rowKey}-use-n`} />
<Label htmlFor={`${row.rowKey}-use-n`}>아니오</Label>
</div>
</RadioGroup>
{showRadios ? (
<RadioGroup className="flex items-center justify-center">
<div className="flex items-center gap-1">
<RadioGroupItem value="Y" id={`${row.rowKey}-preview-y`} />
<Label htmlFor={`${row.rowKey}-preview-y`}>예</Label>
</div>
<div className="flex items-center gap-1">
<RadioGroupItem value="N" id={`${row.rowKey}-preview-n`} />
<Label htmlFor={`${row.rowKey}-preview-n`}>아니오</Label>
</div>
</RadioGroup>
) : (
"—"
)}
</TableCell>
</TableRow>
);
@@ -113,6 +113,7 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) {
rvwCnLvNo: 1,
rvwCn: "",
useYn: "Y",
bchckUseYn: "Y",
sortSeq: maxSort + 1,
isNew: true,
isDirty: true,
@@ -145,6 +146,7 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) {
rvwCnLvNo: parentLv + 1,
rvwCn: "",
useYn: "Y",
bchckUseYn: "Y",
sortSeq: maxSort + 1,
isNew: true,
isDirty: true,
@@ -189,6 +191,8 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) {
const handleSave = async () => {
if (!cklstSn) return;
if (!(await confirm("체크리스트 항목을 저장하시겠습니까?"))) return;
const dirty = getDirtyRows().sort((a, b) => (a.rvwCnLvNo ?? 1) - (b.rvwCnLvNo ?? 1));
for (const row of dirty) {
@@ -198,8 +202,6 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) {
return;
}
if (!(await confirm("체크리스트 항목을 저장하시겠습니까?"))) return;
if (row.isNew) {
await createMutation.mutateAsync({
cklstSn: row.cklstSn,
@@ -209,7 +211,7 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) {
rvwCn: row.rvwCn,
useYn: row.useYn,
sortSeq: row.sortSeq,
bchckUseYn: "Y",
bchckUseYn: row.bchckUseYn ?? "Y",
});
} else if (row.cklstRvwSn != null) {
await updateMutation.mutateAsync({
@@ -248,12 +250,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}
>
@@ -278,17 +279,19 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) {
) : (
<Table className="w-full">
<colgroup>
<col width="20%" />
<col width="50%" />
<col width="15%" />
<col width="18%" />
<col width="42%" />
<col width="12%" />
<col width="12%" />
<col width="5%" />
<col width="10%" />
<col width="11%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>점검항목</TableHead>
<TableHead>주요 검토내용</TableHead>
<TableHead>사용 여부</TableHead>
<TableHead>체크 사용</TableHead>
<TableHead>구분</TableHead>
<TableHead>관리</TableHead>
</TableRow>
@@ -91,6 +91,22 @@ function CklstRvwRowComponent({
</div>
</RadioGroup>
</TableCell>
<TableCell align="center">
<RadioGroup
value={row.bchckUseYn ?? "Y"}
onValueChange={(v) => onChange({ bchckUseYn: v as "Y" | "N", isDirty: true })}
className="flex justify-center gap-4"
>
<div className="flex items-center gap-2">
<RadioGroupItem value="Y" id={`${row.rowKey}-bchck-y`} />
<Label htmlFor={`${row.rowKey}-bchck-y`}>예</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="N" id={`${row.rowKey}-bchck-n`} />
<Label htmlFor={`${row.rowKey}-bchck-n`}>아니오</Label>
</div>
</RadioGroup>
</TableCell>
<TableCell align="center">{levelLabel}</TableCell>
<TableCell align="center">
<div className="flex justify-center gap-2">
@@ -103,7 +119,7 @@ function CklstRvwRowComponent({
>
하위추가
</Button>
<Button type="button" size="sm" color="red" onClick={onDelete}>
<Button type="button" size="sm" color="gray" onClick={onDelete}>
삭제
</Button>
</div>
@@ -145,6 +145,15 @@ export function collectDescendantRowKeys(
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,
@@ -153,12 +162,7 @@ export function insertChildSlot(
const parentIndex = structure.findIndex((item) => item.rowKey === parentRowKey);
if (parentIndex === -1) return [...structure, slot];
const parentDepth = structure[parentIndex].depth;
let insertAt = parentIndex + 1;
while (insertAt < structure.length && structure[insertAt].depth > parentDepth) {
insertAt += 1;
}
const insertAt = parentIndex + 1;
return [...structure.slice(0, insertAt), slot, ...structure.slice(insertAt)];
}
@@ -214,6 +218,21 @@ export function markCnstpRowsDeleted(
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);
@@ -14,11 +14,13 @@ import {
hasCnstpGridPendingChanges,
hydrateGridFromServer,
insertChildSlot,
insertRootSlot,
markCnstpRowsDeleted,
mergeAllCnstpRows,
mergeCnstpRow,
patchCnstpGridRow,
removeDraftFromGrid,
validateDraftParentCnstpSeCd,
} from "./cnstp-se-grid";
function validateCnstpSeRow(row: CnstpSeRow): string | null {
@@ -80,47 +82,39 @@ export function useCnstpSeRows({ serverContent, onAlert, onSave }: UseCnstpSeRow
useYn: "Y",
},
},
structure: [...prev.structure, { rowKey, depth: 0, parentRowKey: null }],
structure: insertRootSlot(prev.structure, { rowKey, depth: 0, parentRowKey: null }),
}));
}, []);
const addChildRow = useCallback(
(parent: CnstpSeRow) => {
if (parent.isNew) {
onAlert("상위 행을 먼저 저장한 후 하위 항목을 추가할 수 있습니다.");
return;
}
const addChildRow = useCallback((parent: CnstpSeRow) => {
const rowKey = createDraftRowKey();
const rowKey = createDraftRowKey();
setGrid((prev) => {
const parentSlot = prev.structure.find((item) => item.rowKey === parent.rowKey);
const depth = (parentSlot?.depth ?? 0) + 1;
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, {
return {
...prev,
drafts: {
...prev.drafts,
[rowKey]: {
rowKey,
depth,
parentRowKey: parent.rowKey,
}),
};
});
},
[onAlert],
);
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));
@@ -141,6 +135,12 @@ export function useCnstpSeRows({ serverContent, onAlert, onSave }: UseCnstpSeRow
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);
@@ -86,15 +86,10 @@ function CnstpCodeManagementPage() {
<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" onClick={addRootRow}>
<Button type="button" color="green" onClick={addRootRow}>
코드 추가
</Button>
<Button
type="button"
color="green"
disabled={!canSave || isSaving}
onClick={handleSave}
>
<Button type="button" disabled={!canSave || isSaving} onClick={handleSave}>
저장
</Button>
</div>
@@ -110,38 +105,40 @@ function CnstpCodeManagementPage() {
등록된 코드가 없습니다. [코드 추가]로 1단계 코드를 등록하세요.
</p>
) : (
<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 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>
@@ -41,7 +41,7 @@ function CnstpSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Cns
onChange={(e) => onChange({ cnstpSeCd: e.target.value })}
placeholder="코드"
maxLength={10}
readOnly={!row.isNew}
disabled={!row.isNew}
className="flex-1"
/>
</div>
@@ -85,12 +85,12 @@ function CnstpSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Cns
type="button"
size="sm"
color="white"
disabled={row.isNew || !canAddChildRow()}
disabled={!canAddChildRow()}
onClick={onAddChild}
>
하위추가
</Button>
<Button type="button" size="sm" color="red" onClick={onDelete}>
<Button type="button" size="sm" color="gray" onClick={onDelete}>
삭제
</Button>
</div>
@@ -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>
@@ -94,7 +94,7 @@ function EsntlAtchFileFormDialog({
<div className="space-y-2">
<Label>문서구분</Label>
<CodeSelect
upCd="ASDOCSECD"
upCd="ASDOCSE"
value={docSeComCd}
onValueChange={(v) => setValue("docSeComCd", v, { shouldValidate: true })}
placeholder="문서구분 선택"
@@ -36,7 +36,7 @@ function EsntlAtchFileList({
onPaginationChange,
}: EsntlAtchFileListProps) {
const { confirm } = useConfirm();
const { data: docSeCodes } = useCodeList("ASDOCSECD");
const { data: docSeCodes } = useCodeList("ASDOCSE");
const [dialogMode, setDialogMode] = useState<"create" | "edit" | null>(null);
const [editingRow, setEditingRow] = useState<EsntlAtchFileMng | null>(null);
@@ -32,7 +32,7 @@ const DEFAULT_SEARCH_PARAMS = {
};
function EsntlAtchFileMngPage() {
const { data: docSeCodes, isPending: isDocSeLoading } = useCodeList("ASDOCSECD");
const { data: docSeCodes, isPending: isDocSeLoading } = useCodeList("ASDOCSE");
const [docSeComCd, setDocSeComCd] = useState(ESNTL_ATCH_FILE_ALL_DOC_SE);
const [pagination, setPagination] = useState<PaginationState>({
@@ -0,0 +1,4 @@
export { CnfmListPage } from "./ui/cnfm-list-page";
export { CnfmViewPage } from "./ui/cnfm-view-page";
export { CnfmRegistPage } from "./ui/cnfm-regist-page";
export { CnfmModifyPage } from "./ui/cnfm-modify-page";
@@ -9,8 +9,8 @@ import { dayjs } from "@/shared/lib";
import { useCodeList } from "@/entities/code";
import {
INSTL_SNCN_REGISTER_SELECTION_KEY,
InstlSncnApi,
type InstlSncnMngSearchParams,
instlSncnService,
} from "@/entities/instl-sncn";
import "./cnfm-page.css";
@@ -78,14 +78,14 @@ function CnfmListPage() {
useEffect(() => {
if (!taskDialogOpen) return;
if (selectedTaskSeComCd.length) return;
if (fnrsCodeList.length === 0) return;
// setSelectedTaskSeComCd([fnrsCodeList[0].lwpoCd]);
if (fnrsCodeList.length === 0) return;
// setSelectedTaskSeComCd([fnrsCodeList[0].lwpoCd]);
}, [taskDialogOpen, selectedTaskSeComCd, fnrsCodeList]);
const query = useQuery({
queryKey: ["instl-sncn-list", search, pageIndex, pageSize],
queryFn: () =>
InstlSncnApi.getPage({
instlSncnService.getPage({
...search,
page: pageIndex,
size: pageSize,
@@ -120,13 +120,15 @@ function CnfmListPage() {
// prepare arrays of selected codes and names
const selectedCodes = selectedTaskSeComCd;
const selectedNames = selectedTaskSeComCd.map((code) => fnrsCodeList.find((item) => item.lwpoCd === code)?.lwpoCdNm || "");
const selectedNames = selectedTaskSeComCd.map(
(code) => fnrsCodeList.find((item) => item.lwpoCd === code)?.lwpoCdNm || "",
);
sessionStorage.setItem(
INSTL_SNCN_REGISTER_SELECTION_KEY,
JSON.stringify({
taskSeComCdList: selectedCodes,
taskSeComCdNmList: selectedNames
})
taskSeComCdNmList: selectedNames,
}),
);
setTaskDialogOpen(false);
router.push("/instl-sncn/register");
@@ -138,7 +140,9 @@ function CnfmListPage() {
<div className="cnfm-card">
<form onSubmit={handleSearch} className="cnfm-search-bar">
<div className="cnfm-search-field">
<label className="cnfm-search-label" htmlFor="sidoComCd">시도</label>
<label className="cnfm-search-label" htmlFor="sidoComCd">
시도
</label>
<select
id="sidoComCd"
className="cnfm-select"
@@ -146,13 +150,17 @@ function CnfmListPage() {
onChange={(e) => setSidoComCd(e.target.value)}
>
{SIDO_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="cnfm-search-field">
<label className="cnfm-search-label" htmlFor="sggComCd">시군구</label>
<label className="cnfm-search-label" htmlFor="sggComCd">
시군구
</label>
<select
id="sggComCd"
className="cnfm-select"
@@ -164,7 +172,9 @@ function CnfmListPage() {
</div>
<div className="cnfm-search-field cnfm-search-field--grow">
<label className="cnfm-search-label" htmlFor="bizNm">사업명</label>
<label className="cnfm-search-label" htmlFor="bizNm">
사업명
</label>
<input
id="bizNm"
type="text"
@@ -176,7 +186,9 @@ function CnfmListPage() {
</div>
<div className="cnfm-search-field">
<label className="cnfm-search-label" htmlFor="taskSeComCd">구분</label>
<label className="cnfm-search-label" htmlFor="taskSeComCd">
구분
</label>
<select
id="taskSeComCd"
className="cnfm-select cnfm-select--wide"
@@ -184,12 +196,16 @@ function CnfmListPage() {
onChange={(e) => setTaskSeComCd(e.target.value)}
>
{TASK_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<button type="submit" className="cnfm-btn-search">검색</button>
<button type="submit" className="cnfm-btn-search">
검색
</button>
</form>
</div>
@@ -198,11 +214,7 @@ function CnfmListPage() {
<div className="cnfm-list-header">
<h3 className="cnfm-list-title">설치인가 목록</h3>
<div className="cnfm-list-actions">
<button
type="button"
className="cnfm-btn-register"
onClick={handleOpenTaskDialog}
>
<button type="button" className="cnfm-btn-register" onClick={handleOpenTaskDialog}>
✎ 신청
</button>
<select
@@ -214,7 +226,9 @@ function CnfmListPage() {
}}
>
{[10, 20, 30, 50].map((size) => (
<option key={size} value={size}>{size}개</option>
<option key={size} value={size}>
{size}개
</option>
))}
</select>
</div>
@@ -231,18 +245,22 @@ function CnfmListPage() {
<th>담당자</th>
<th>해당기관</th>
<th>진행상황</th>
<th>시행일</th>
<th>신청일</th>
</tr>
</thead>
<tbody>
{query.isLoading && (
<tr>
<td colSpan={8} className="cnfm-td-empty">조회 중입니다...</td>
<td colSpan={8} className="cnfm-td-empty">
조회 중입니다...
</td>
</tr>
)}
{!query.isLoading && (query.data?.content?.length ?? 0) === 0 && (
<tr>
<td colSpan={8} className="cnfm-td-empty">조회 결과가 없습니다.</td>
<td colSpan={8} className="cnfm-td-empty">
조회 결과가 없습니다.
</td>
</tr>
)}
{(query.data?.content ?? []).map((row) => {
@@ -253,12 +271,12 @@ function CnfmListPage() {
className="cnfm-row-clickable"
onClick={() => router.push(`/instl-sncn/${row.instlSncnDocNo}`)}
>
<td>{row.sidoNm || "-"}</td>
<td>{row.sggNm || "-"}</td>
<td>{row.sido || "-"}</td>
<td>{row.gugun || "-"}</td>
<td className="text-left">{row.bizNm || "-"}</td>
<td>{row.taskSeCdNm || row.taskSeComCd || "-"}</td>
<td>{row.chrgrNm || row.aplcntNm || "-"}</td>
<td>{row.instNm || row.aplcntOgdpNm || "-"}</td>
<td>{row.aplcntNm || "-"}</td>
<td>{row.envoSeCdNm || "-"}</td>
<td>
<div className="cnfm-step-wrap">
<div className="cnfm-step-row">
@@ -267,10 +285,13 @@ function CnfmListPage() {
index < currentStepIndex
? "completed"
: index === currentStepIndex
? "current"
: "pending";
? "current"
: "pending";
return (
<span key={step.code} className={`cnfm-step-badge cnfm-step-badge--${state}`}>
<span
key={step.code}
className={`cnfm-step-badge cnfm-step-badge--${state}`}
>
<span className={`cnfm-step-dot cnfm-step-dot--${state}`} />
{step.label}
</span>
@@ -284,10 +305,13 @@ function CnfmListPage() {
index < currentStepIndex
? "completed"
: index === currentStepIndex
? "current"
: "pending";
? "current"
: "pending";
return (
<span key={step.code} className={`cnfm-step-badge cnfm-step-badge--${state}`}>
<span
key={step.code}
className={`cnfm-step-badge cnfm-step-badge--${state}`}
>
<span className={`cnfm-step-dot cnfm-step-dot--${state}`} />
{step.label}
</span>
@@ -378,7 +402,9 @@ function CnfmListPage() {
checked={selectedTaskSeComCd.includes(item.lwpoCd)}
onChange={() => {
setSelectedTaskSeComCd((prev) =>
prev.includes(item.lwpoCd) ? prev.filter((v) => v !== item.lwpoCd) : [...prev, item.lwpoCd],
prev.includes(item.lwpoCd)
? prev.filter((v) => v !== item.lwpoCd)
: [...prev, item.lwpoCd],
);
}}
/>
@@ -396,7 +422,7 @@ function CnfmListPage() {
>
취소
</button>
<button
<button
type="button"
className="rounded bg-blue-500 px-5 py-2 text-[1.3rem] font-semibold text-white hover:bg-blue-600 disabled:cursor-not-allowed disabled:bg-gray-300"
onClick={handleConfirmTaskDialog}
@@ -1,9 +1,10 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { CnfmRegistPage } from "./cnfm-regist-page";
import { InstlSncnApi } from "@/entities/instl-sncn";
import { instlSncnService } from "@/entities/instl-sncn";
import { CnfmRegistPage } from "./cnfm-regist-page";
import "./cnfm-page.css";
@@ -11,7 +12,7 @@ function CnfmModifyPage({ id }: { id: string }) {
const query = useQuery({
// share cache with view page
queryKey: ["instl-sncn-detail", id],
queryFn: () => InstlSncnApi.getById(id),
queryFn: () => instlSncnService.getById(id),
});
if (query.isLoading) {
@@ -10,27 +10,25 @@ import { type HttpResponse, Upload } from "tus-js-client";
import { AUTH_USER_ID, CLAIM_NAME, ENDPOINT, PROXY_HOST } from "@/shared/config";
import { getCookie, setCookie, useAlertStore, useUserStore } from "@/shared/lib";
import { deleteAttachment } from "@/shared/lib/attach";
import { formatFileSize } from "@/shared/lib/file-utils";
import type { AcntgSeMngTreeDto } from "@/entities/acntg";
import { acntgSeMngService } from "@/entities/acntg";
import { attachService } from "@/entities/attach";
import type { CklstTreeNode } from "@/entities/cklst";
import { cklstMngService } from "@/entities/cklst";
import type { CnstpSeMngTreeDto } from "@/entities/cnstp";
import { cnstpSeMngService } from "@/entities/cnstp";
import {
esntlAtchFileMngService,
type EsntlAtchFileMngTreeResponseDto,
} from "@/entities/esntl";
import { esntlAtchFileMngService, type EsntlAtchFileMngTreeResponseDto } from "@/entities/esntl";
import {
INSTL_SNCN_REGISTER_SELECTION_KEY,
InstlSncnApi,
type InstlSncnApplySaveRequestDto,
type InstlSncnBizCstSaveRequestDto,
type InstlSncnBzScaleSaveDto,
type InstlSncnMng,
type InstlSncnMngResponseDto,
InstlSncnMngSaveRequestDto,
instlSncnService,
} from "@/entities/instl-sncn";
import { EtcAttachments,NumCell, PrivacyConsent, ReadCell } from "@/widgets/cnfm";
import { EtcAttachments, NumCell, PrivacyConsent, ReadCell } from "@/widgets/cnfm";
import { Button, Input } from "@common/components";
import "./cnfm-page.css";
@@ -128,7 +126,7 @@ const normalizeChecklistResultRows = (payload: any): any[] => {
/* 메인 컴포넌트 */
/* ------------------------------------------------------------------ */
/**
* initialData 매핑 안내
* initialData 매핑 안내
* - initialData 데이터는 cnfm-modify-page에서 서버로부터 받아온 상세 데이터를 그대로 전달받거나, cnfm-regist-page에서 수정 모드로 진입할 때 전달받을 수 있습니다.
* - 이 컴포넌트는 수정 모드로 호출될 때 `initialData`를 받습니다.
* - 서버 응답은 두 가지 형태 중 하나일 수 있습니다:
@@ -155,12 +153,14 @@ const normalizeChecklistResultRows = (payload: any): any[] => {
*/
function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
const router = useRouter();
const userInfo2 = useUserStore((state) => state.userInfo);
const userInfo = useUserStore((state) => state.userInfo);
// userInfo?.dtlInstCd 테스트 코드 ENV.ROLE_USER03 , KECO 권한 테스트용 (실제 권한에 맞게 변경 필요)
/*
const userInfo = {
...userInfo2,
dtlInstCd: "ROLE_USER03", //ROLE_USER03, ROLE_USER02, ROLE_USER01 KECO 권한 테스트용 (실제 권한에 맞게 변경 필요)
};
*/
const alert = useAlertStore((state) => state.setMessage);
const qc = useQueryClient();
const currentDet = useMemo(() => initialData?.detDto ?? initialData, [initialData]);
@@ -182,7 +182,6 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
return visibleReviewStepCodes.includes(step) ? step : null;
}, [isModifyMode, userInfo?.dtlInstCd, visibleReviewStepCodes]);
const [selectedReviewStep, setSelectedReviewStep] = useState<ReviewStepCode>("LOCAL");
useEffect(() => {
if (visibleReviewStepCodes.length === 0) return;
@@ -204,9 +203,11 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
const [docSeComCd] = useState("INSTL");
const [taskSeComCdNm, setTaskSeComCdNm] = useState<string>(
() => (currentDet?.taskSeComCdNm as string) ?? "재이용수",
() => (currentDet?.taskSeComCdNm as string) ?? "",
);
const [taskSeComCd, setTaskSeComCd] = useState<string>(
() => (currentDet?.taskSeComCd as string) ?? "RE",
);
const [taskSeComCd, setTaskSeComCd] = useState<string>(() => (currentDet?.taskSeComCd as string) ?? "RE");
useEffect(() => {
// do not override server-provided initialData (modify mode)
@@ -260,13 +261,10 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
// selected task code for header filtering (null = none selected)
const [selectedTaskCode, setSelectedTaskCode] = useState<string | null>(null);
const [requiredFiles, setRequiredFiles] = useState<Record<RequiredAttachKey, RequiredAttachItem>>({});
const [requiredFiles, setRequiredFiles] = useState<Record<RequiredAttachKey, RequiredAttachItem>>(
{},
);
const requiredFileInputRefs = useRef<Record<RequiredAttachKey, HTMLInputElement | null>>({});
useEffect(() => {
@@ -350,22 +348,22 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
upload.start();
};
/* ── 기타 첨부파일 ── */
type EtcFile = {
id: string;
name: string;
ext: string;
size: string;
uploader: string;
atflCd: string;
atflId: string;
};
const [etcFiles, setEtcFiles] = useState<EtcFile[]>([]);
const [etcNoticeOpen, setEtcNoticeOpen] = useState(true);
const [etcFileGroupId, setEtcFileGroupId] = useState("");
const [etcUploading, setEtcUploading] = useState(false);
const etcFileGroupIdRef = useRef("");
const initialMappedRef = useRef(false);
/* ── 기타 첨부파일 ── */
type EtcFile = {
id: string;
name: string;
ext: string;
size: string;
uploader: string;
atflCd: string;
atflId: string;
};
const [etcFiles, setEtcFiles] = useState<EtcFile[]>([]);
const [etcNoticeOpen, setEtcNoticeOpen] = useState(true);
const [etcFileGroupId, setEtcFileGroupId] = useState("");
const [etcUploading, setEtcUploading] = useState(false);
const etcFileGroupIdRef = useRef("");
const initialMappedRef = useRef(false);
// 초기 데이터가 주어지면 폼 상태로 매핑 (수정 모드에서 사용)
useEffect(() => {
@@ -380,7 +378,6 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
//const cklstResultList = initialData.cklstResultList ?? [];
const esntlFileList = initialData.esntlFileList ?? [];
const etcFileList = initialData.etcFileList ?? [];
// 기본 정보 및 설계자 정보
setBzNm(det.bizNm ?? "");
@@ -410,9 +407,12 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
.filter((f: any) => f && typeof f === "object")
.map((f: any) => {
const fileName = f.orgnlFileNm ?? f.physFileNm ?? "";
const ext = fileName && typeof fileName === "string" && fileName.includes(".") ? fileName.split(".").pop() ?? "" : "";
const ext =
fileName && typeof fileName === "string" && fileName.includes(".")
? (fileName.split(".").pop() ?? "")
: "";
return {
id: f.atflId ,
id: f.atflId,
name: fileName,
ext,
size: f.atflFlieSz ?? 0,
@@ -426,17 +426,18 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
// 수정모드시 기타첨부 그룹아이디 적용 (중복 매핑 방지)
// - det.atchFileGroupId 또는 det.atflCd 우선, 없으면 etcFileList 첫 항목의 atflCd 사용
const existingGroup = (det.atchFileGroupId ) || (Array.isArray(etcFileList) && etcFileList[0]?.atflCd) || "";
const existingGroup =
det.atchFileGroupId || (Array.isArray(etcFileList) && etcFileList[0]?.atflCd) || "";
//console.log("etcFileGroupIdRef.current 초기화1:", existingGroup);
if (existingGroup && etcFileGroupIdRef.current !== existingGroup) {
etcFileGroupIdRef.current = existingGroup;
setEtcFileGroupId((prev) => prev || existingGroup);
}
// console.log("etcFileGroupIdRef.current 초기화:", etcFileGroupIdRef.current);
// console.log("etcFileGroupIdRef.current 초기화:", etcFileGroupIdRef.current);
// 수정모드인 경우 서버가 전달한 업무구분(단일 혹은 콤마로 결합된 복수)을 상태로 복원
setTaskSeComCd(det.taskSeComCd);
setTaskSeComCdNm(det.taskSeComCdNm );
setTaskSeComCd(det.taskSeComCd);
setTaskSeComCdNm(det.taskSeCdNm);
// 매핑 완료 플래그 설정 — 이후 동일 컴포넌트 인스턴스에서 재매핑 방지
initialMappedRef.current = true;
@@ -498,17 +499,17 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
};
}
setAmtMap(map);
}
}
} catch (err) {
console.error("initialData 매핑 실패", err);
}
}, [initialData]);
/** 기타첨부파일 삭제 핸들러: 백엔드에 DELETE 요청을 보내 소프트삭제 처리 후 상태 갱신 */
const handleDeleteEtcFile = async (fileId: string, atflId: string) => {
/** 기타첨부파일 삭제 핸들러: 백엔드에 DELETE 요청을 보내 소프트삭제 처리 후 상태 갱신 */
const handleDeleteEtcFile = async (fileId: string, atflId: string) => {
try {
if (atflId) {
await deleteAttachment(atflId);
await attachService.deleteAttachmentByAtflId(atflId);
}
} catch (error) {
console.error("기타 첨부파일 삭제 실패", error);
@@ -526,18 +527,21 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
});
};
const handleRequiredFileChange = (key: RequiredAttachKey, e: React.ChangeEvent<HTMLInputElement>) => {
const handleRequiredFileChange = (
key: RequiredAttachKey,
e: React.ChangeEvent<HTMLInputElement>,
) => {
const file = e.target.files?.[0];
if (!file) return;
uploadRequiredFile(file, key);
e.target.value = "";
};
const uploadEtcFile = (file: File): Promise<EtcFile> =>
new Promise((resolve, reject) => {
let uploadedAtflCd = "";
let uploadedAtflId = "";
console.log("기타첨부파일 업로드 시작:", file.name ,etcFileGroupIdRef.current);
console.log("기타첨부파일 업로드 시작:", file.name, etcFileGroupIdRef.current);
const upload = new Upload(file, {
endpoint: `${PROXY_HOST}${ENDPOINT.ATTACH_SERVICE.TUS_UPLOAD}`,
chunkSize: 10 * 1024 * 1024,
@@ -567,7 +571,7 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
}
},
onSuccess() {
const ext = file.name.includes(".") ? file.name.split(".").pop() ?? "" : "";
const ext = file.name.includes(".") ? (file.name.split(".").pop() ?? "") : "";
resolve({
id: uploadedAtflId || `${Date.now()}_${Math.random()}`,
name: file.name,
@@ -634,7 +638,6 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
const { data: cklstTree = [] } = useQuery<CklstTreeNode[]>({
queryKey: ["cklstTree", docSeComCd, taskSeComCd],
queryFn: async () => {
console.log("Fetching checklist tree with", { docSeComCd, taskSeComCd });
// support comma-separated task codes (e.g. "RE,SP") by splitting
const raw = taskSeComCd ?? "";
const codes = raw
@@ -665,7 +668,7 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
const cklstRows = useMemo(() => {
// bchckUseYn 필터 제거: 부모가 있는 자식 노드(bchckUseYn=N)도 트리에 포함
// cklstTree 내의 피드 taskSeComCd 에 따라서 그룹핑 되도록 해줘
const useRows = selectedTaskCode
? cklstTree.filter((r) => (r as any).taskSeComCd === selectedTaskCode)
: cklstTree;
@@ -709,7 +712,7 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
const codes = Array.from(
new Set((cklstTree ?? []).map((r) => (r as any).taskSeComCd).filter(Boolean)),
);
if (codes.length === 0) {
if (selectedTaskCode !== null) setSelectedTaskCode(null);
return;
@@ -790,7 +793,9 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
const el = cklstRowRefs.current[pendingInvalidKey];
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
const firstRadio = el.querySelector<HTMLInputElement>(`input[name="chk_${pendingInvalidKey}"]`);
const firstRadio = el.querySelector<HTMLInputElement>(
`input[name="chk_${pendingInvalidKey}"]`,
);
firstRadio?.focus();
setPendingInvalidKey(null);
}
@@ -799,7 +804,7 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
// 수정 모드에서 서버의 체크리스트 결과값을 폼 상태로 반영
useEffect(() => {
if (!initialData || cklstRows.length === 0) return;
if (!initialData || cklstRows.length === 0) return;
const results = normalizeChecklistResultRows(initialData);
if (!Array.isArray(results) || results.length === 0) return;
@@ -865,7 +870,6 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
return { total, completed, percent };
}, [cklstRows, checkResults, selectedReviewStep]);
const toCklstSaveDtoList = () => {
const persistedStepCodeSet = new Set<ReviewStepCode>();
normalizeChecklistResultRows(initialData).forEach((r: any) => {
@@ -876,9 +880,9 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
visibleReviewStepCodes.forEach((code) => persistedStepCodeSet.add(code));
// only persist rows that are enabled for checkbox (`bchckUseYn === "Y")
// only persist rows that are enabled for checkbox (`bchckUseYn === "Y")
// Use full cklstTree so items not currently visible (other task codes) are included
const relevantRows = (cklstTree ?? []).filter((row) => row.bchckUseYn === "Y");
// only persist rows that are enabled for checkbox (`bchckUseYn === "Y")
// Use full cklstTree so items not currently visible (other task codes) are included
const relevantRows = (cklstTree ?? []).filter((row) => row.bchckUseYn === "Y");
// Ensure we generate DTOs grouped by all known task codes (TASK_CODE_LABELS keys)
const allTaskCodes = Object.keys(TASK_CODE_LABELS);
@@ -929,7 +933,9 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
const subtotalScale = (node: CnstpSeMngTreeDto, col: keyof BzScaleRow): number =>
cnstpTree
.filter((n) => cnstpLeafSet.has(n.cnstpSeCd) && n.treePath.split("|").includes(node.cnstpSeCd))
.filter(
(n) => cnstpLeafSet.has(n.cnstpSeCd) && n.treePath.split("|").includes(node.cnstpSeCd),
)
.reduce((sum, n) => sum + num((bzScaleMap[n.cnstpSeCd] ?? emptyScale())[col]), 0);
const scaleNodeVal = (node: CnstpSeMngTreeDto, col: keyof BzScaleRow): number =>
@@ -1045,7 +1051,6 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
/* ── 검증 ── */
function fnValidation(mode: "SAVE" | "PRESENTN") {
if (!bzNm.trim()) {
alert("사업명을 입력하세요.");
return false;
@@ -1062,8 +1067,7 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
alert("개인정보 제공에 동의하셔야 제출이 가능합니다.");
return false;
}
if (!editableReviewStep) {
alert("현재 사용자 권한/진행상태 조합에서 입력 가능한 체크리스트 단계가 없습니다.");
return false;
@@ -1099,14 +1103,18 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
visibleReviewStepCodes.some((step) => !checkResults[cklstKey(row, step)]),
);
if (missingRow) {
const missingStep = visibleReviewStepCodes.find((step) => !checkResults[cklstKey(missingRow, step)])!;
const missingStep = visibleReviewStepCodes.find(
(step) => !checkResults[cklstKey(missingRow, step)],
)!;
const invalidKey = cklstKey(missingRow, missingStep);
console.warn("검토 결과 미선택 항목 발견:", invalidKey, missingRow, missingStep);
const invalidRowEl = cklstRowRefs.current[invalidKey];
if (invalidRowEl) {
invalidRowEl.scrollIntoView({ behavior: "smooth", block: "center" });
const firstRadio = invalidRowEl.querySelector<HTMLInputElement>(`input[name="chk_${invalidKey}"]`);
const firstRadio = invalidRowEl.querySelector<HTMLInputElement>(
`input[name="chk_${invalidKey}"]`,
);
firstRadio?.focus();
} else {
// row not visible: switch header filter to the missing row's task code,
@@ -1124,18 +1132,14 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
return true;
}
const toInstlSncnMngPayload = (prgrsSttsComCd: "SV" | "ST"| "ER"): InstlSncnMng => ({
instlSncnDocNo: docId ?? "", // 수정 시 기존 문서번호 유지, 신규 저장 시 빈값
const toInstlSncnMngPayload = (
prgrsSttsComCd: "SV" | "ST" | "ER",
): InstlSncnMngSaveRequestDto => ({
instlSncnDocNo: docId ?? "",
admdstCd: "",
sidoNm: "",
sggNm: "",
chrgrNm: "",
instNm: "",
envoSeComCd: currentDet?.envoSeComCd ?? "",
sncnSeComCd: sncnSeComCd || DEFAULT_SNCN_SE_COM_CD,
envoSeCdNm: "",
taskSeComCd,
taskSeCdNm: taskSeComCdNm,
bgtBizCd: "",
bgtAplyYr: "",
bizNm: bzNm,
@@ -1152,10 +1156,6 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
dsgnrEmlAddr,
atchFileGroupId: etcFileGroupId,
prgrsSttsComCd,
prgrsSttsCdNm: "",
regDt: "",
mdfcnDt: "",
rgtrNm: "",
});
async function handleSave() {
@@ -1170,7 +1170,7 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
};
try {
const issuedDocNo = await InstlSncnApi.saveApply(payload);
const issuedDocNo = await instlSncnService.saveApply(payload);
alert(`저장되었습니다. 문서번호: ${issuedDocNo}`);
} catch (error) {
console.error("저장 API 호출 실패", error);
@@ -1185,16 +1185,16 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
async function handleSubmit() {
if (!fnValidation("SAVE")) return;
const payload: InstlSncnApplySaveRequestDto = {
const payload: InstlSncnApplySaveRequestDto = {
instlSncnMng: toInstlSncnMngPayload("SV"), // 저장 시 진행상태는 SV (임시저장)
bzScaleList: toBzScaleSaveDtoList(),
bizCstList: toBizCstSaveDtoList(),
cklstResultList: toCklstSaveDtoList(),
esntlAtchFileList: toEsntlAtchFileSaveDtoList(),
};
};
try {
const issuedDocNo = await InstlSncnApi.saveApply(payload);
try {
const issuedDocNo = await instlSncnService.saveApply(payload);
alert(`제출 되었습니다. 문서번호: ${issuedDocNo}`);
} catch (error) {
console.error("저장 API 호출 실패", error);
@@ -1261,7 +1261,7 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
<button className="cnfm-btn-search whitespace-nowrap">🔍 사업찾기</button>
</div>
</td>
<th style={{ textAlign: "center" }}>
<th style={{ textAlign: "center" }}>
<span className="text-red-500">*</span> 사업기간
</th>
<td>
@@ -1283,7 +1283,7 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
</td>
</tr>
<tr>
<th style={{ textAlign: "center" }}>
<th style={{ textAlign: "center" }}>
<span className="text-red-500">*</span> 사업내용
</th>
<td colSpan={3}>
@@ -1296,26 +1296,30 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
</td>
</tr>
<tr>
<th style={{ textAlign: "center" }}>구분</th>
<th style={{ textAlign: "center" }}>구분</th>
<td>
<div className="flex gap-4 text-[1.3rem]">{taskSeComCdNm}</div>
<input type="hidden" name="taskSeComCd" value={taskSeComCd} />
</td>
<th style={{ textAlign: "center" }}>신청 지자체</th>
<th style={{ textAlign: "center" }}>신청 지자체</th>
<td>
<span className="inline-block min-w-[16rem] whitespace-nowrap text-[1.3rem] text-gray-500">
{userInfo?.enoNm ?? "\u00A0"}
{isModifyMode
? `${currentDet?.sido ?? "-"} ${currentDet?.gugun ?? "-"}`
: (userInfo?.laeSeNm ?? "\u00A0")}
</span>
</td>
</tr>
<tr>
<th style={{ textAlign: "center" }}><span className="text-red-500">*</span>사업구분</th>
<th style={{ textAlign: "center" }}>
<span className="text-red-500">*</span>사업구분
</th>
<td colSpan={3}>
<div className="flex gap-4 text-[1.3rem]">
{[
["CNFM", "설치인가"],
["APR", "설치승인"],
["AFA", "폐지인가"],
["1", "설치인가"],
["2", "설치승인"],
["3", "폐지승인"],
].map(([val, label]) => (
<label key={val} className="flex cursor-pointer items-center gap-1">
<input
@@ -1341,31 +1345,47 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
<table className="cnfm-form-table">
<tbody>
<tr>
<th rowSpan={2} style={{ textAlign: "center" }} className="text-center align-middle w-[6.5rem]">신청자</th>
<th style={{ textAlign: "center" }}>이름</th>
<th
rowSpan={2}
style={{ textAlign: "center" }}
className="w-[6.5rem] text-center align-middle"
>
신청자
</th>
<th style={{ textAlign: "center" }}>이름</th>
<td>
<span className="inline-block min-w-[16rem] whitespace-nowrap text-[1.3rem] text-gray-500">{aplcntNm || userInfo?.userName || "\u00A0"}</span>
<span className="inline-block min-w-[16rem] whitespace-nowrap text-[1.3rem] text-gray-500">
{aplcntNm || userInfo?.userName || "\u00A0"}
</span>
</td>
<th style={{ textAlign: "center" }}>전화번호</th>
<th style={{ textAlign: "center" }}>전화번호</th>
<td>
<span className="inline-block min-w-[16rem] whitespace-nowrap text-[1.3rem] text-gray-500">{aplcntMblTelno || userInfo?.mphn || userInfo?.telno || "\u00A0"}</span>
<span className="inline-block min-w-[16rem] whitespace-nowrap text-[1.3rem] text-gray-500">
{aplcntMblTelno || userInfo?.mphn || userInfo?.telno || "\u00A0"}
</span>
</td>
</tr>
<tr>
<th style={{ textAlign: "center" }}>부서</th>
<th style={{ textAlign: "center" }}>부서</th>
<td>
<span className="text-[1.3rem] text-gray-500">{aplcntOgdpNm || userInfo?.instSeNm || "\u00A0"}</span>
<span className="text-[1.3rem] text-gray-500">
{aplcntOgdpNm || userInfo?.instSeNm || "\u00A0"}
</span>
</td>
<th style={{ textAlign: "center" }}>이메일</th>
<th style={{ textAlign: "center" }}>이메일</th>
<td>
<span className="text-[1.3rem] text-gray-500">{aplcntEmlAddr || userInfo?.email || "\u00A0"}</span>
<span className="text-[1.3rem] text-gray-500">
{aplcntEmlAddr || userInfo?.email || "\u00A0"}
</span>
</td>
</tr>
<tr>
<th rowSpan={2} style={{ textAlign: "center" }}>설계자</th>
<th style={{ textAlign: "center" }}>이름</th>
<th rowSpan={2} style={{ textAlign: "center" }}>
설계자
</th>
<th style={{ textAlign: "center" }}>이름</th>
<td>
<Input
placeholder="설계자 성명"
@@ -1374,7 +1394,7 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
onChange={(e) => setDsgnrNm(e.target.value)}
/>
</td>
<th style={{ textAlign: "center" }}>전화번호</th>
<th style={{ textAlign: "center" }}>전화번호</th>
<td>
<Input
placeholder="설계자 전화번호"
@@ -1394,8 +1414,8 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
onChange={(e) => setDsgnrPsitn(e.target.value)}
/>
</td>
<th style={{ textAlign: "center" }}>이메일</th>
<th style={{ textAlign: "center" }}>이메일</th>
<td>
<Input
placeholder="설계자 이메일"
@@ -1413,29 +1433,34 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
<PrivacyConsent value={privacyAgree} onChange={setPrivacyAgree}>
<p className="mb-1 font-semibold">[ 개인정보 수집항목 ]</p>
<div>
* 필수항목 : 소속기관, 소속부서, 담당자, 직급, 사무실 번호, FAX 번호<br />
․ 선택항목 : 휴대폰 번호, 이메일 주소<br />
․ 기타항목 : IP Address, 쿠키, 시스템 접속 및 이용 기록<br />
※ 시스템 이용과정에서 기타 항목의 정보들이 자동으로 생성되어 수집될 수 있습니다.
* 필수항목 : 소속기관, 소속부서, 담당자, 직급, 사무실 번호, FAX 번호
<br />
․ 선택항목 : 휴대폰 번호, 이메일 주소
<br />
․ 기타항목 : IP Address, 쿠키, 시스템 접속 및 이용 기록
<br />※ 시스템 이용과정에서 기타 항목의 정보들이 자동으로 생성되어 수집될 수 있습니다.
</div>
<p className="mt-4 font-semibold">[ 개인정보 이용목적 ]</p>
<div>
․ 국가하수도정보시스템 웹서비스 이용에 따른 본인 확인 절차에 이용<br />
․ 국가하수도정보시스템 사용에 필요한 권한 부여<br />
․ 사용자별 맞춤 컨텐츠 (처리시설의 운영 데이터 조회, 수질현황 데이터 조회, 예산 편성 / 예산편성 등) 제공에 이용
․ 국가하수도정보시스템 웹서비스 이용에 따른 본인 확인 절차에 이용
<br />
․ 국가하수도정보시스템 사용에 필요한 권한 부여
<br />․ 사용자별 맞춤 컨텐츠 (처리시설의 운영 데이터 조회, 수질현황 데이터 조회, 예산 편성
/ 예산편성 등) 제공에 이용
</div>
<div className="tit mt-4">● 개인정보의 보유 및 이용기간</div>
<div>
․ 수집된 고객의 개인정보는 2년 동안 보유하며 그 이후에는 문서를 파쇄합니다.<br />
․ 정보제공자가 개인정보 파쇄를 요청할 경우 즉시 파쇄합니다.
․ 수집된 고객의 개인정보는 2년 동안 보유하며 그 이후에는 문서를 파쇄합니다.
<br />․ 정보제공자가 개인정보 파쇄를 요청할 경우 즉시 파쇄합니다.
</div>
<br />
<div className="tit">● 개인정보 제공 거부에 따른 제한사항</div>
<div>
․ 귀하는 개인정보 제공 동의를 거부할 권리가 있으나, 동의 거부 시 국가하수도정보시스템 이용 서비스에 제한이 있을 수 있습니다.
․ 귀하는 개인정보 제공 동의를 거부할 권리가 있으나, 동의 거부 시 국가하수도정보시스템 이용
서비스에 제한이 있을 수 있습니다.
</div>
</PrivacyConsent>
@@ -1608,25 +1633,27 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
<div className="cnfm-card p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-[1.5rem] font-bold text-gray-700">체크리스트</h3>
<div className="flex gap-2 items-center">
{Array.from(new Set((cklstTree ?? []).map((r) => (r as any).taskSeComCd).filter(Boolean))).map(
(code) => {
const name = TASK_CODE_LABELS[code] ?? code;
const active = selectedTaskCode === code;
return (
<button
key={code}
type="button"
className={`rounded border px-3 py-1 text-[1.2rem] font-semibold ${
active ? "border-blue-500 bg-blue-50 text-blue-700" : "border-gray-300 bg-white text-gray-600"
}`}
onClick={() => setSelectedTaskCode(code)}
>
{name}
</button>
);
},
)}
<div className="flex items-center gap-2">
{Array.from(
new Set((cklstTree ?? []).map((r) => (r as any).taskSeComCd).filter(Boolean)),
).map((code) => {
const name = TASK_CODE_LABELS[code] ?? code;
const active = selectedTaskCode === code;
return (
<button
key={code}
type="button"
className={`rounded border px-3 py-1 text-[1.2rem] font-semibold ${
active
? "border-blue-500 bg-blue-50 text-blue-700"
: "border-gray-300 bg-white text-gray-600"
}`}
onClick={() => setSelectedTaskCode(code)}
>
{name}
</button>
);
})}
<div className="text-[1.1rem] text-gray-500">{`(${cklstStats.completed}/${cklstStats.total})`}</div>
</div>
</div>
@@ -1658,8 +1685,9 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
)}
</div>
<div className="mb-2 text-[1.25rem] text-gray-500">
체크리스트 진행상황 [{selectedReviewStep}] — 총합: <strong>{cklstStats.total}</strong> · 완료: {" "}
<strong>{cklstStats.completed}</strong> · 진행률: <strong>{cklstStats.percent}%</strong>
체크리스트 진행상황 [{selectedReviewStep}] — 총합: <strong>{cklstStats.total}</strong> ·
완료: <strong>{cklstStats.completed}</strong> · 진행률:{" "}
<strong>{cklstStats.percent}%</strong>
</div>
<div className="cnfm-table-wrap">
<table className="cnfm-table">
@@ -1724,7 +1752,7 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
cklstRows.map((row) => {
const key = cklstKey(row, selectedReviewStep);
const rvwPaddingLeft = `${Math.max(0, row.treeLevel - 1) * 16 + 8}px`;
//bchckUseYn 의 값이 "Y"인 항목은 라디오 박스가 보이고 없으면 안보이게 처리
//bchckUseYn 의 값이 "Y"인 항목은 라디오 박스가 보이고 없으면 안보이게 처리
const showRadios = row.bchckUseYn === "Y";
return (
<tr
@@ -1734,7 +1762,10 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
}}
>
<td className="text-center text-[1.2rem]">{row.cklstArtclNm}</td>
<td className="!text-left pl-2 text-[1.2rem] text-gray-600" style={{ paddingLeft: rvwPaddingLeft }}>
<td
className="pl-2 !text-left text-[1.2rem] text-gray-600"
style={{ paddingLeft: rvwPaddingLeft }}
>
{row.treeLevel > 1 ? "└ " : ""}
{row.rvwCn}
</td>
@@ -1753,7 +1784,9 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
value="Y"
checked={checkResults[key] === "Y"}
disabled={editableReviewStep !== selectedReviewStep}
onChange={() => setCheckResults((prev) => ({ ...prev, [key]: "Y" }))}
onChange={() =>
setCheckResults((prev) => ({ ...prev, [key]: "Y" }))
}
/>
</label>
</td>
@@ -1765,14 +1798,16 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
value="N"
checked={checkResults[key] === "N"}
disabled={editableReviewStep !== selectedReviewStep}
onChange={() => setCheckResults((prev) => ({ ...prev, [key]: "N" }))}
onChange={() =>
setCheckResults((prev) => ({ ...prev, [key]: "N" }))
}
/>
</label>
</td>
</>
)}
{showRadios && selectedReviewStep !== "LOCAL" ? (
{showRadios && selectedReviewStep !== "LOCAL" ? (
<td className="text-left">
<textarea
className="cnfm-input w-full"
@@ -1823,7 +1858,9 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
<td className="pl-2">
<div className="w-full text-left">
{item.esntlAtchFileNm}
{item.esntlAtchYn === "Y" ? <span className="ml-1 text-red-500">*</span> : null}
{item.esntlAtchYn === "Y" ? (
<span className="ml-1 text-red-500">*</span>
) : null}
</div>
</td>
<td>{fileFormat}</td>
@@ -1833,7 +1870,11 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
type="text"
placeholder="첨부문서를 올려주세요"
className="cnfm-input flex-1"
value={requiredFile?.uploading ? "업로드 중..." : requiredFile?.fileName || ""}
value={
requiredFile?.uploading
? "업로드 중..."
: requiredFile?.fileName || ""
}
readOnly
/>
<input
@@ -11,8 +11,11 @@ import { useRouter } from "next/navigation";
import { PROXY_HOST } from "@/shared/config";
import { formatFileSize } from "@/shared/lib/file-utils";
import axios from "axios";
import { processElaprPrgrs } from "@/entities/elapr/api/elapr-prgrs-service";
import { elaprDmndService } from "@/entities/elapr";
import { dayjs, useUserStore, useAlertStore } from "@/shared/lib";
import { InstlSncnApi, type InstlSncnApplySaveRequestDto } from "@/entities/instl-sncn";
import { instlSncnService, type InstlSncnApplySaveRequestDto } from "@/entities/instl-sncn";
import { client } from "@/shared/api";
import "./cnfm-page.css";
@@ -64,9 +67,7 @@ const TASK_CODE_LABELS: Record<string, string> = {
SF: "하수처리시설",
};
const normalizeChecklistResultMap = (
raw: unknown,
): Record<ReviewStepCode, any[]> => {
const normalizeChecklistResultMap = (raw: unknown): Record<ReviewStepCode, any[]> => {
const base: Record<ReviewStepCode, any[]> = { LOCAL: [], ENV: [], KECO: [] };
if (!raw) return base;
@@ -98,7 +99,7 @@ function CnfmViewPage({ id }: { id: string }) {
const query = useQuery({
queryKey: ["instl-sncn-detail", id],
queryFn: () => InstlSncnApi.getById(id),
queryFn: () => instlSncnService.getById(id),
});
const data = (query.data ?? {}) as any;
@@ -124,6 +125,155 @@ function CnfmViewPage({ id }: { id: string }) {
}, [cklstAll]);
const esntlFileList = data.esntlFileList ?? [];
const etcFileList = data.etcFileList ?? [];
const elaprDocPrgrsList = (data.elaprDocPrgrsList ?? []) as any[];
const renderApproverCell = (code: string) => {
const row = elaprDocPrgrsList.find((r) => r.atrzSeComCd === code);
if (!row) return <div className="text-center">-</div>;
const status = row.atrzPrgrsSttsComCd ?? "";
const statusMap: Record<string, { label: string; badgeClass: string; icon: string }> = {
ATRZ_PRGRS_001: { label: "대기", badgeClass: "bg-gray-100 text-gray-700", icon: "⏳" },
ATRZ_PRGRS_002: { label: "요청", badgeClass: "bg-yellow-100 text-yellow-800", icon: "📨" },
ATRZ_PRGRS_003: { label: "승인", badgeClass: "bg-green-100 text-green-800", icon: "✔️" },
ATRZ_PRGRS_004: { label: "반려", badgeClass: "bg-red-100 text-red-800", icon: "✖️" },
};
const st = statusMap[status] ?? {
label: status || "",
badgeClass: "bg-gray-100 text-gray-700",
icon: "",
};
return (
<div className="text-center">
<div className="font-semibold">{row.aprvrUuidNm ?? "-"}</div>
<div className="text-[0.95rem] text-gray-500">
{row.ognzNm ? `${row.ognzNm}` : ""}
{row.jbpsSeNm || row.jbgdSeNm
? ` · ${row.jbpsSeNm ?? ""}${row.jbgdSeNm ? ` ${row.jbgdSeNm}` : ""}`
: ""}
</div>
<div className="mt-1 flex items-center justify-center gap-2 text-[0.9rem]">
{row.atrzDt ? (
<span className="text-gray-400">{dayjs(row.atrzDt).format("YYYY-MM-DD HH:mm:ss")}</span>
) : null}
{st.label ? (
<span
title={`${row.atrzPrgrsSttsComNm ?? st.label}${row.rjctRsn ? ` — 사유: ${row.rjctRsn}` : ""}${row.atrzDt ? ` · ${dayjs(row.atrzDt).format("YYYY-MM-DD HH:mm:ss")}` : ""}`}
className={`ml-1 inline-flex items-center rounded-md px-2 py-0.5 text-xs font-semibold ${st.badgeClass}`}
>
<span className="mr-1">{st.icon}</span>
<span>{st.label}</span>
</span>
) : null}
</div>
</div>
);
};
const orderedApprovers = useMemo(() => {
return (elaprDocPrgrsList ?? []).slice().sort((a: any, b: any) => {
const A = Number(a?.atrzSeqNo ?? 0);
const B = Number(b?.atrzSeqNo ?? 0);
return A - B;
});
}, [elaprDocPrgrsList]);
const handleApproveClick = async (elaprPrgrsId: any, atrzSeComCd: any) => {
if (!elaprPrgrsId) {
alert("결재 정보가 올바르지 않습니다.");
return;
}
try {
await processElaprPrgrs(String(elaprPrgrsId), "ATRZ_PRGRS_003"); //진행상태를 승인으로 업데이트
alert("결재가 완료되었습니다.");
try {
await query.refetch();
} catch (e) {
/* ignore refetch errors */
}
} catch (err: any) {
console.error("결재 처리 실패", err);
const msg =
axios.isAxiosError(err) && err.response?.data?.message
? err.response.data.message
: "결재 처리 중 오류가 발생했습니다.";
alert(msg);
}
};
const handleAssignApproverClick = async (elaprTmpltId: string) => {
try {
const payload = {
elaprDocNo: det.instlSncnDocNo,
elaprTmpltId: elaprTmpltId /* 팝업에서 선택된 전자결제 템플릿 */,
dmndTtl: det.bizNm /* "구례 하수도 처리시설 2026", */,
dmndCn: det.bizCn /*"구례 발주 하수도 처리시설 사업 2026년도 진행분 결제 요청 입니다."*/,
};
await elaprDmndService.save(payload);
alert("결재자 지정(결재요청 등록)이 완료되었습니다.");
try {
await query.refetch();
} catch (e) {
/* ignore refetch errors */
}
} catch (err: any) {
console.error("결재자 지정 실패", err);
const msg =
axios.isAxiosError(err) && err.response?.data?.message
? err.response.data.message
: "결재자 지정 중 오류가 발생했습니다.";
alert(msg);
}
};
const renderApproverRow = (row: any) => {
if (!row) return <div className="text-center">-</div>;
const status = row.atrzPrgrsSttsComCd ?? "";
const statusMap: Record<string, { label: string; badgeClass: string; icon: string }> = {
ATRZ_PRGRS_001: { label: "대기", badgeClass: "bg-gray-100 text-gray-700", icon: "⏳" },
ATRZ_PRGRS_002: { label: "요청", badgeClass: "bg-yellow-100 text-yellow-800", icon: "📨" },
ATRZ_PRGRS_003: { label: "승인", badgeClass: "bg-green-100 text-green-800", icon: "✔️" },
ATRZ_PRGRS_004: { label: "반려", badgeClass: "bg-red-100 text-red-800", icon: "✖️" },
};
const st = statusMap[status] ?? {
label: status || "",
badgeClass: "bg-gray-100 text-gray-700",
icon: "",
};
return (
<div className="text-center">
<div className="font-semibold">{row.aprvrUuidNm ?? "-"}</div>
<div className="text-[0.95rem] text-gray-500">
{row.ognzNm ? `${row.ognzNm}` : ""}
{row.jbpsSeNm || row.jbgdSeNm
? ` · ${row.jbpsSeNm ?? ""}${row.jbgdSeNm ? ` ${row.jbgdSeNm}` : ""}`
: ""}
</div>
<div className="mt-1 flex items-center justify-center gap-2 text-[0.9rem]">
{row.atrzDt ? (
<span className="text-gray-400">{dayjs(row.atrzDt).format("YYYY-MM-DD HH:mm:ss")}</span>
) : null}
{st.label ? (
<>
<span
title={`${row.atrzPrgrsSttsComNm ?? st.label}${row.rjctRsn ? ` — 사유: ${row.rjctRsn}` : ""}${row.atrzDt ? ` · ${dayjs(row.atrzDt).format("YYYY-MM-DD HH:mm:ss")}` : ""}`}
className={`ml-1 inline-flex items-center rounded-md px-2 py-0.5 text-xs font-semibold ${st.badgeClass}`}
>
<span className="mr-1">{st.icon}</span>
<span>{st.label}</span>
</span>
{status === "ATRZ_PRGRS_002" && userInfo?.userUnqId === row.aprvrUuid ? (
<button
type="button"
onClick={() => handleApproveClick(row.elaprPrgrsId, row.atrzSeComCd)}
className="ml-2 rounded bg-blue-500 px-2 py-1 text-xs text-white"
>
결제
</button>
) : null}
</>
) : null}
</div>
</div>
);
};
const currentPrgrsSttsComCd = (det.prgrsSttsComCd as string | undefined) ?? "ST";
const roleBasedMaxStepCount = getRoleStepCount(userInfo?.dtlInstCd);
const progressBasedMaxStepCount = getProgressStepCount(currentPrgrsSttsComCd);
@@ -138,7 +288,9 @@ function CnfmViewPage({ id }: { id: string }) {
// derive available task codes from checklist results
const taskCodes = useMemo(() => {
return Array.from(new Set((cklstAll ?? []).map((r: any) => (r as any).taskSeComCd).filter(Boolean)));
return Array.from(
new Set((cklstAll ?? []).map((r: any) => (r as any).taskSeComCd).filter(Boolean)),
);
}, [cklstAll]);
useEffect(() => {
@@ -166,8 +318,11 @@ function CnfmViewPage({ id }: { id: string }) {
);
return rows
.slice()
.sort((a: any, b: any) =>
(a.cklstArtclSn ?? 0) - (b.cklstArtclSn ?? 0) || (a.sortSeq ?? 0) - (b.sortSeq ?? 0) || (a.cklstRvwSn ?? 0) - (b.cklstRvwSn ?? 0),
.sort(
(a: any, b: any) =>
(a.cklstArtclSn ?? 0) - (b.cklstArtclSn ?? 0) ||
(a.sortSeq ?? 0) - (b.sortSeq ?? 0) ||
(a.cklstRvwSn ?? 0) - (b.cklstRvwSn ?? 0),
);
}, [cklstResultMap, selectedReviewStep, visibleReviewStepCodes.length, selectedTaskCode]);
@@ -176,11 +331,15 @@ function CnfmViewPage({ id }: { id: string }) {
const cklstStats = useMemo(() => {
if (!selectedReviewStep) return { total: 0, completed: 0, percent: 0 };
const rows = (cklstResultMap[selectedReviewStep] ?? []).filter((r: any) => r.bchckUseYn === "Y" && (selectedTaskCode ? (r.taskSeComCd ?? "") === selectedTaskCode : true));
const rows = (cklstResultMap[selectedReviewStep] ?? []).filter(
(r: any) =>
r.bchckUseYn === "Y" &&
(selectedTaskCode ? (r.taskSeComCd ?? "") === selectedTaskCode : true),
);
const total = rows.length;
const completed = rows.reduce((acc: number, row: any) => {
const key = `${row.taskSeComCd ?? ""}_${row.cklstSn}_${row.cklstArtclSn ?? ""}_${row.cklstRvwSn}_${selectedReviewStep}`;
const val = (viewCheckResults[key] ?? row.rvwRsltAnmlyYn) ?? "";
const val = viewCheckResults[key] ?? row.rvwRsltAnmlyYn ?? "";
return acc + (val ? 1 : 0);
}, 0);
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
@@ -199,8 +358,8 @@ function CnfmViewPage({ id }: { id: string }) {
.filter((r: any) => r.bchckUseYn === "Y")
.map((row: any) => {
const key = `${row.taskSeComCd ?? ""}_${row.cklstSn}_${row.cklstArtclSn ?? ""}_${row.cklstRvwSn}_${selectedReviewStep}`;
const rvwRsltAnmlyYn = (viewCheckResults[key] ?? row.rvwRsltAnmlyYn) ?? null;
const picRvwCn = (viewCheckComments[key] ?? row.picRvwCn) ?? null;
const rvwRsltAnmlyYn = viewCheckResults[key] ?? row.rvwRsltAnmlyYn ?? null;
const picRvwCn = viewCheckComments[key] ?? row.picRvwCn ?? null;
return {
taskSeComCd: row.taskSeComCd ?? "",
cklstSn: row.cklstSn,
@@ -222,7 +381,7 @@ function CnfmViewPage({ id }: { id: string }) {
} as unknown as InstlSncnApplySaveRequestDto;
try {
const issuedDocNo = await InstlSncnApi.techReview(payload);
const issuedDocNo = await instlSncnService.techReview(det.instlSncnDocNo, payload);
alert(`기술검토 등록이 완료되었습니다. 문서번호: ${issuedDocNo}`);
} catch (error) {
console.error("기술검토 등록 API 호출 실패", error);
@@ -239,7 +398,8 @@ function CnfmViewPage({ id }: { id: string }) {
const comments: Record<string, string> = {};
(selectedChecklistRows ?? []).forEach((row: any) => {
const key = `${row.taskSeComCd ?? ""}_${row.cklstSn}_${row.cklstArtclSn ?? ""}_${row.cklstRvwSn}_${row.rvwStpComCd ?? "LOCAL"}`;
if (row.rvwRsltAnmlyYn === "Y" || row.rvwRsltAnmlyYn === "N") results[key] = row.rvwRsltAnmlyYn;
if (row.rvwRsltAnmlyYn === "Y" || row.rvwRsltAnmlyYn === "N")
results[key] = row.rvwRsltAnmlyYn;
if (row.picRvwCn) comments[key] = String(row.picRvwCn);
});
setViewCheckResults(results);
@@ -267,9 +427,60 @@ function CnfmViewPage({ id }: { id: string }) {
return (
<div className="mt-6 space-y-4">
{/* orderedApprovers.length > 0 && ( */}
<div className="cnfm-card p-4">
<h3 className="text-[1.5rem] font-semibold">결재 정보</h3>
<div className="cnfm-table-wrap mt-3">
<table className="cnfm-table w-full">
<thead>
<tr>
{orderedApprovers.length === 0 ? (
<>
<th className="text-center">접수자</th>
<th className="text-center">검토자</th>
<th className="text-center">승인자</th>
<th className="text-center">최종승인자</th>
</>
) : (
orderedApprovers.map((r: any) => (
<th key={String(r.elaprPrgrsId ?? r.atrzSeComCd)} className="text-center">
{r.atrzSeComNm ?? r.atrzSeComCd}
</th>
))
)}
</tr>
</thead>
<tbody>
<tr>
{orderedApprovers.length === 0 ? (
<>
<td>{renderApproverCell("ATRZ01")}</td>
<td>{renderApproverCell("ATRZ02")}</td>
<td>{renderApproverCell("ATRZ03")}</td>
<td>{renderApproverCell("ATRZ99")}</td>
</>
) : (
orderedApprovers.map((r: any) => (
<td key={String(r.elaprPrgrsId ?? r.atrzSeComCd)}>{renderApproverRow(r)}</td>
))
)}
</tr>
</tbody>
</table>
{/** 결제자 지정 버튼 */}
<div className="mt-4 text-right">
<Button type="button" onClick={() => handleAssignApproverClick("TMPLT20260611000001")}>
결재자 지정
</Button>
</div>
</div>
</div>
{/* )} */}
<div className="cnfm-card p-4">
<h2 className="cnfm-section-title">설치인가 상세보기</h2>
<p className="mt-2 text-[1.3rem] text-gray-600">문서번호: <strong>{det.instlSncnDocNo}</strong></p>
<p className="mt-2 text-[1.3rem] text-gray-600">
문서번호: <strong>{det.instlSncnDocNo}</strong>
</p>
</div>
<div className="cnfm-card p-4">
@@ -279,30 +490,31 @@ function CnfmViewPage({ id }: { id: string }) {
<tr>
<th>사업명</th>
<td>{det.bizNm || "-"}</td>
<th>사업기간</th>
<td>{(det.bizBgngYr || "-") + " ~ " + (det.bizEndYr || "-")}</td>
<th>사업기간</th>
<td>{(det.bizBgngYr || "-") + " ~ " + (det.bizEndYr || "-")}</td>
</tr>
<tr>
<th>사업내용</th>
<td colSpan={3}>{det.bizCn || "-"}</td>
</tr>
<tr>
<th>업무구분</th>
<th>구분</th>
<td>{det.taskSeCdNm || det.taskSeComCd || "-"}</td>
<th>행정동ID</th>
<td>{det.admdstCd || "-"}</td>
<th>신청지자체</th>
<td>
{det.sido || "-"} {det.gugun || "-"}
</td>
</tr>
<tr>
<th>사업구분(인가구분)</th>
<td colSpan={3}>{det.sncnSeComCd || "-"}</td>
<th>사업구분</th>
<td colSpan={3}>{det.sncnSeCdNm || "-"}</td>
</tr>
<tr>
<th>진행상태</th>
<td>{det.prgrsSttsCdNm || det.prgrsSttsComCd || "-"}</td>
<td>{det.prgrsSttsCdNm || det.prgrsSttsComCd || "-"}</td>
<th>예산사업코드</th>
<td>{det.bgtBizCd || "-"}</td>
</tr>
</tbody>
</table>
</div>
@@ -376,7 +588,9 @@ function CnfmViewPage({ id }: { id: string }) {
<tbody>
{bizSclList.length === 0 ? (
<tr>
<td colSpan={5} className="cnfm-td-empty">조회된 사업규모 항목이 없습니다.</td>
<td colSpan={5} className="cnfm-td-empty">
조회된 사업규모 항목이 없습니다.
</td>
</tr>
) : (
bizSclList.map((node: any) => {
@@ -386,12 +600,28 @@ function CnfmViewPage({ id }: { id: string }) {
const tstmDesignQty = Number(node.tstmDesignQty) || 0;
const diff = tstmDesignQty - budgetReflct;
return (
<tr key={node.cnstpSeCd} className={node.treeLevel === 1 ? "bg-gray-50 font-semibold" : ""}>
<td className="bg-gray-50 pl-2 text-left" style={{ paddingLeft: `${pl + 8}px` }}>{node.cnstpSeCdNm}</td>
<td><div className="text-right">{fmt(bscPlanQty)}</div></td>
<td><div className="text-right">{fmt(budgetReflct)}</div></td>
<td><div className="text-right">{fmt(tstmDesignQty)}</div></td>
<td><div className="text-right">{diff > 0 ? `+${fmt(diff)}` : fmt(diff)}</div></td>
<tr
key={node.cnstpSeCd}
className={node.treeLevel === 1 ? "bg-gray-50 font-semibold" : ""}
>
<td
className="bg-gray-50 pl-2 text-left"
style={{ paddingLeft: `${pl + 8}px` }}
>
{node.cnstpSeCdNm}
</td>
<td>
<div className="text-right">{fmt(bscPlanQty)}</div>
</td>
<td>
<div className="text-right">{fmt(budgetReflct)}</div>
</td>
<td>
<div className="text-right">{fmt(tstmDesignQty)}</div>
</td>
<td>
<div className="text-right">{diff > 0 ? `+${fmt(diff)}` : fmt(diff)}</div>
</td>
</tr>
);
})
@@ -403,7 +633,9 @@ function CnfmViewPage({ id }: { id: string }) {
{/* 총사업비 */}
<div className="cnfm-card p-4">
<h3 className="mb-3 border-b pb-2 text-[1.5rem] font-bold text-gray-700">총사업비 <span className="text-[1.2rem] font-normal text-gray-400">(단위: 백만원)</span></h3>
<h3 className="mb-3 border-b pb-2 text-[1.5rem] font-bold text-gray-700">
총사업비 <span className="text-[1.2rem] font-normal text-gray-400">(단위: 백만원)</span>
</h3>
<div className="cnfm-table-wrap">
<table className="cnfm-table w-full table-fixed">
<thead>
@@ -423,7 +655,6 @@ function CnfmViewPage({ id }: { id: string }) {
</thead>
<tbody>
{bizCstList.map((node: any) => {
const pl = indentPx(node.indAcntgSeCdNm);
const bscPlanAmt = Number(node.bscPlanAmt) || 0;
const budgetReflct = Number(node.bgtRfltAmt) || 0;
@@ -438,12 +669,14 @@ function CnfmViewPage({ id }: { id: string }) {
node.treeLevel === 1
? "bg-blue-50 font-bold"
: node.treeLevel === 2
? "bg-gray-100 font-semibold"
: ""
? "bg-gray-100 font-semibold"
: ""
}
>
{node.treeLevel <= 2 ? (
<td colSpan={2} className="pl-2 text-left">{node.acntgSeCdNm}</td>
<td colSpan={2} className="pl-2 text-left">
{node.acntgSeCdNm}
</td>
) : (
<>
<td className="w-[4rem]"></td>
@@ -470,7 +703,9 @@ function CnfmViewPage({ id }: { id: string }) {
<div className="text-right">{diff > 0 ? `+${fmt(diff)}` : fmt(diff)}</div>
</td>
<td>
<div className="text-right">{budgetReflct !== 0 ? `${Math.round((diff / budgetReflct) * 100)}%` : "-"}</div>
<div className="text-right">
{budgetReflct !== 0 ? `${Math.round((diff / budgetReflct) * 100)}%` : "-"}
</div>
</td>
</tr>
);
@@ -481,31 +716,33 @@ function CnfmViewPage({ id }: { id: string }) {
</div>
{/* 체크리스트 */}
<div className="cnfm-card p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-[1.5rem] font-bold text-gray-700">체크리스트</h3>
<div className="flex items-center gap-4">
<div className="flex gap-2 items-center">
{Array.from(new Set(taskCodes)).map((code) => {
const name = TASK_CODE_LABELS[code] ?? code;
const active = selectedTaskCode === code;
return (
<button
key={code}
type="button"
className={`rounded border px-3 py-1 text-[1.2rem] font-semibold ${
active ? "border-blue-500 bg-blue-50 text-blue-700" : "border-gray-300 bg-white text-gray-600"
}`}
onClick={() => setSelectedTaskCode(code)}
>
{name}
</button>
);
})}
</div>
<div className="text-[1.1rem] text-gray-500">{`(${cklstStats.completed}/${cklstStats.total})`}</div>
<div className="cnfm-card p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-[1.5rem] font-bold text-gray-700">체크리스트</h3>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
{Array.from(new Set(taskCodes)).map((code) => {
const name = TASK_CODE_LABELS[code] ?? code;
const active = selectedTaskCode === code;
return (
<button
key={code}
type="button"
className={`rounded border px-3 py-1 text-[1.2rem] font-semibold ${
active
? "border-blue-500 bg-blue-50 text-blue-700"
: "border-gray-300 bg-white text-gray-600"
}`}
onClick={() => setSelectedTaskCode(code)}
>
{name}
</button>
);
})}
</div>
<div className="text-[1.1rem] text-gray-500">{`(${cklstStats.completed}/${cklstStats.total})`}</div>
</div>
</div>
<div className="mb-3 flex flex-wrap gap-2">
{visibleReviewStepCodes.length === 0 ? (
<span className="text-[1.2rem] text-gray-500">
@@ -529,89 +766,101 @@ function CnfmViewPage({ id }: { id: string }) {
)}
</div>
<div className="mb-2 text-[1.25rem] text-gray-500">
체크리스트 진행상황 [{selectedReviewStep}] — 총합: <strong>{cklstStats.total}</strong> · 완료: <strong>{cklstStats.completed}</strong> · 진행률: <strong>{cklstStats.percent}%</strong>
체크리스트 진행상황 [{selectedReviewStep}] — 총합: <strong>{cklstStats.total}</strong> ·
완료: <strong>{cklstStats.completed}</strong> · 진행률:{" "}
<strong>{cklstStats.percent}%</strong>
</div>
<div className="cnfm-table-wrap mt-3">
<table className="cnfm-table">
<thead>
<tr>
<th className="w-[20rem]" rowSpan={2}>검토항목</th>
<th className="w-[20rem]" rowSpan={2}>
검토항목
</th>
<th rowSpan={2}>주요 검토내용</th>
<th colSpan={2}>검토 결과(✓)</th>
{selectedReviewStep !== "LOCAL" ? (
<th rowSpan={2} className="w-[20rem]">기술검토 의견</th>
<th rowSpan={2} className="w-[20rem]">
기술검토 의견
</th>
) : null}
</tr>
<tr>
<th className="w-[9rem]">
<button
type="button"
>
예
</button>
<button type="button">예</button>
</th>
<th className="w-[9rem]">
<button
type="button"
>
아니오
</button>
<button type="button">아니오</button>
</th>
</tr>
</thead>
<tbody>
{selectedChecklistRows.length === 0 ? (
<tr>
<td colSpan={5} className="cnfm-td-empty">조회된 체크리스트 항목이 없습니다.</td>
<td colSpan={5} className="cnfm-td-empty">
조회된 체크리스트 항목이 없습니다.
</td>
</tr>
) : (
selectedChecklistRows.map((row: any) => {
const key = `${row.taskSeComCd ?? ""}_${row.cklstSn}_${row.cklstArtclSn ?? ""}_${row.cklstRvwSn}_${row.rvwStpComCd ?? "LOCAL"}`;
const hasResult = row.rvwRsltAnmlyYn === "Y" || row.rvwRsltAnmlyYn === "N";
const showRadios = row.bchckUseYn === "Y" ;
const showRadios = row.bchckUseYn === "Y";
return (
<tr key={key}>
<td className="text-center text-[1.2rem]">{row.cklstArtclNm}</td>
<td className="!text-left pl-2 text-[1.2rem] text-gray-600" style={{ paddingLeft: `${Math.max(0, row.treeLevel - 1) * 16 + 8}px` }}>{row.treeLevel > 1 ? "└ " : ""}{row.rvwCn}</td>
{!showRadios ? (
<>
<td className="text-center">-</td>
<td className="text-center">-</td>
</>
) : (
<>
<td className="text-center">
<input
type="radio"
name={`chk_${key}`}
value="Y"
checked={(viewCheckResults[key] ?? "") === "Y"}
disabled={selectedReviewStep === "LOCAL"}
onChange={() => setViewCheckResults((prev) => ({ ...prev, [key]: "Y" }))}
/>
</td>
<td className="text-center">
<input
type="radio"
name={`chk_${key}`}
value="N"
checked={(viewCheckResults[key] ?? "") === "N"}
disabled={selectedReviewStep === "LOCAL"}
onChange={() => setViewCheckResults((prev) => ({ ...prev, [key]: "N" }))}
/>
</td>
</>
)}
{showRadios && selectedReviewStep !== "LOCAL" ? (
<td className="text-left">
<textarea
className="cnfm-input w-full"
value={viewCheckComments[key] ?? ""}
onChange={(e) => setViewCheckComments((prev) => ({ ...prev, [key]: e.target.value }))}
/>
</td>
) : null}
<td
className="pl-2 !text-left text-[1.2rem] text-gray-600"
style={{ paddingLeft: `${Math.max(0, row.treeLevel - 1) * 16 + 8}px` }}
>
{row.treeLevel > 1 ? "└ " : ""}
{row.rvwCn}
</td>
{!showRadios ? (
<>
<td className="text-center">-</td>
<td className="text-center">-</td>
</>
) : (
<>
<td className="text-center">
<input
type="radio"
name={`chk_${key}`}
value="Y"
checked={(viewCheckResults[key] ?? "") === "Y"}
disabled={selectedReviewStep === "LOCAL"}
onChange={() =>
setViewCheckResults((prev) => ({ ...prev, [key]: "Y" }))
}
/>
</td>
<td className="text-center">
<input
type="radio"
name={`chk_${key}`}
value="N"
checked={(viewCheckResults[key] ?? "") === "N"}
disabled={selectedReviewStep === "LOCAL"}
onChange={() =>
setViewCheckResults((prev) => ({ ...prev, [key]: "N" }))
}
/>
</td>
</>
)}
{showRadios && selectedReviewStep !== "LOCAL" ? (
<td className="text-left">
<textarea
className="cnfm-input w-full"
value={viewCheckComments[key] ?? ""}
onChange={(e) =>
setViewCheckComments((prev) => ({ ...prev, [key]: e.target.value }))
}
/>
</td>
) : null}
</tr>
);
})
@@ -628,7 +877,7 @@ function CnfmViewPage({ id }: { id: string }) {
<table className="cnfm-table">
<thead>
<tr>
<th>필수 자료명</th>
<th>필수 자료명</th>
<th>파일명</th>
<th className="w-[8rem] text-right">크기</th>
</tr>
@@ -636,7 +885,9 @@ function CnfmViewPage({ id }: { id: string }) {
<tbody>
{esntlFileList.length === 0 ? (
<tr>
<td colSpan={3} className="cnfm-td-empty">조회된 필수 첨부파일 항목이 없습니다.</td>
<td colSpan={3} className="cnfm-td-empty">
조회된 필수 첨부파일 항목이 없습니다.
</td>
</tr>
) : (
esntlFileList.map((f: any) => (
@@ -644,17 +895,28 @@ function CnfmViewPage({ id }: { id: string }) {
<td className="pl-2">
<div className="w-full text-left">
{f.esntlAtchFileNm}
{f.esntlAtchYn === "Y" ? <span className="ml-1 text-red-500">*</span> : null}
{f.esntlAtchYn === "Y" ? (
<span className="ml-1 text-red-500">*</span>
) : null}
</div>
</td>
<td>
{f.atflCd ? (
<a className="text-blue-600" href={`${PROXY_HOST}/attach-service/api/v1/atflcd/download/${f.atflCd}`} target="_blank" rel="noreferrer">{f.orgnlFileNm || "다운로드"}</a>
<a
className="text-blue-600"
href={`${PROXY_HOST}/attach-service/api/v1/atflcd/download/${f.atflCd}`}
target="_blank"
rel="noreferrer"
>
{f.orgnlFileNm || "다운로드"}
</a>
) : (
<span className="text-gray-500">첨부없음</span>
)}
</td>
<td className="w-[8rem] text-right"><div className="text-right">{formatFileSize(f.atflFlieSz)}</div></td>
<td className="w-[8rem] text-right">
<div className="text-right">{formatFileSize(f.atflFlieSz)}</div>
</td>
</tr>
))
)}
@@ -677,19 +939,30 @@ function CnfmViewPage({ id }: { id: string }) {
<tbody>
{etcFileList.length === 0 ? (
<tr>
<td colSpan={2} className="cnfm-td-empty">등록된 기타첨부파일이 없습니다.</td>
<td colSpan={2} className="cnfm-td-empty">
등록된 기타첨부파일이 없습니다.
</td>
</tr>
) : (
etcFileList.map((f: any) => (
<tr key={f.atflId}>
<td>
{f.orgnlFileNm ? (
<a className="text-blue-600" href={`${PROXY_HOST}/attach-service/api/v1/atflcd/download/${f.atflCd}`} target="_blank" rel="noreferrer">{f.orgnlFileNm}</a>
<a
className="text-blue-600"
href={`${PROXY_HOST}/attach-service/api/v1/atflcd/download/${f.atflCd}`}
target="_blank"
rel="noreferrer"
>
{f.orgnlFileNm}
</a>
) : (
<span className="text-gray-700">{f.physFileNm || f.atflId}</span>
)}
</td>
<td className="w-[8rem] text-right text-gray-500">{formatFileSize(f.atflFlieSz)}</td>
<td className="w-[8rem] text-right text-gray-500">
{formatFileSize(f.atflFlieSz)}
</td>
</tr>
))
)}
@@ -699,7 +972,9 @@ function CnfmViewPage({ id }: { id: string }) {
</div>
<div className="cnfm-action-row">
<Button color="white" onClick={() => router.push("/instl-sncn")}>목록</Button>
<Button color="white" onClick={() => router.push("/instl-sncn")}>
목록
</Button>
<Button onClick={() => router.push(`/instl-sncn/${id}/modify`)}>수정</Button>
<Button onClick={() => void hadleTechReviewSubmit()}>기술검토등록</Button>
</div>
+5 -4
View File
@@ -1,4 +1,5 @@
export { CnfmListPage } from "./ui/cnfm-list-page";
export { CnfmViewPage } from "./ui/cnfm-view-page";
export { CnfmRegistPage } from "./ui/cnfm-regist-page";
export { CnfmModifyPage } from "./ui/cnfm-modify-page";
export { InstlEditPage } from "./ui/instl-edit-page";
export { InstlFormPage } from "./ui/instl-form-page";
export { InstlListPage } from "./ui/instl-list-page";
export { InstlRegistPage } from "./ui/instl-regist-page";
export { InstlViewPage } from "./ui/instl-view-page";
@@ -0,0 +1,28 @@
import type { ElaprDocPrgrsClientResponseDto } from "@/entities/instl-sncn";
export interface ApproverStatusConfig {
label: string;
badgeClass: string;
}
export const APPROVER_STATUS_MAP: Record<string, ApproverStatusConfig> = {
ATRZ_PRGRS_001: { label: "대기", badgeClass: "bg-gray-100 text-gray-700" },
ATRZ_PRGRS_002: { label: "요청", badgeClass: "bg-yellow-100 text-yellow-800" },
ATRZ_PRGRS_003: { label: "승인", badgeClass: "bg-green-100 text-green-800" },
ATRZ_PRGRS_004: { label: "반려", badgeClass: "bg-red-100 text-red-800" },
};
export function sortByAtrzSeqNo(
list: ElaprDocPrgrsClientResponseDto[],
): ElaprDocPrgrsClientResponseDto[] {
return list.slice().sort((a, b) => Number(a.atrzSeqNo ?? 0) - Number(b.atrzSeqNo ?? 0));
}
export function getApproverStatus(status: string | undefined): ApproverStatusConfig {
return (
APPROVER_STATUS_MAP[status ?? ""] ?? {
label: status || "-",
badgeClass: "bg-gray-100 text-gray-700",
}
);
}
@@ -0,0 +1,158 @@
import type { InstlSncnBizCstSaveRequestDto } from "@/entities/instl-sncn";
export interface CostItemDef {
code: string;
readonly?: boolean;
children?: string[];
}
function sumChildAmounts(list: InstlSncnBizCstSaveRequestDto[], childCodes: string[]) {
let bscPlanAmt = 0;
let bgtRfltAmt = 0;
let tstmDesignAmt = 0;
childCodes.forEach((childCode) => {
const child = list.find((c) => c.acntgSeCd === childCode);
if (child) {
bscPlanAmt += child.bscPlanAmt || 0;
bgtRfltAmt += child.bgtRfltAmt || 0;
tstmDesignAmt += child.tstmDesignAmt || 0;
}
});
return { bscPlanAmt, bgtRfltAmt, tstmDesignAmt };
}
export function recalculateBizCstTotals(
list: InstlSncnBizCstSaveRequestDto[],
items: CostItemDef[],
): InstlSncnBizCstSaveRequestDto[] {
const newList = [...list];
let hasChanges = false;
for (let i = items.length - 1; i >= 0; i--) {
const item = items[i];
if (!item.readonly || !item.children) continue;
const idx = newList.findIndex((c) => c.acntgSeCd === item.code);
if (idx < 0) continue;
const { bscPlanAmt, bgtRfltAmt, tstmDesignAmt } = sumChildAmounts(newList, item.children);
const current = newList[idx];
if (
(current.bscPlanAmt || 0) !== bscPlanAmt ||
(current.bgtRfltAmt || 0) !== bgtRfltAmt ||
(current.tstmDesignAmt || 0) !== tstmDesignAmt
) {
newList[idx] = { ...current, bscPlanAmt, bgtRfltAmt, tstmDesignAmt };
hasChanges = true;
}
}
return hasChanges ? newList : list;
}
export function calculateSewerPipeCost(
bizCstList: InstlSncnBizCstSaveRequestDto[],
newPipeCode: string,
replaceCode: string,
targetCode: string,
): InstlSncnBizCstSaveRequestDto {
const newPipe = bizCstList.find((item) => item.acntgSeCd === newPipeCode);
const replace = bizCstList.find((item) => item.acntgSeCd === replaceCode);
const bscPlanAmt = (newPipe?.bscPlanAmt || 0) + (replace?.bscPlanAmt || 0);
const bgtRfltAmt = (newPipe?.bgtRfltAmt || 0) + (replace?.bgtRfltAmt || 0);
const tstmDesignAmt = (newPipe?.tstmDesignAmt || 0) + (replace?.tstmDesignAmt || 0);
return {
acntgSeCd: targetCode,
bscPlanAmt,
bgtRfltAmt,
tstmDesignAmt,
};
}
export function calculateConstructionCost(
bizCstList: InstlSncnBizCstSaveRequestDto[],
codes: string[],
targetCode: string,
): InstlSncnBizCstSaveRequestDto {
let bscPlanAmt = 0;
let bgtRfltAmt = 0;
let tstmDesignAmt = 0;
codes.forEach((code) => {
const item = bizCstList.find((i) => i.acntgSeCd === code);
if (item) {
bscPlanAmt += item.bscPlanAmt || 0;
bgtRfltAmt += item.bgtRfltAmt || 0;
tstmDesignAmt += item.tstmDesignAmt || 0;
}
});
return {
acntgSeCd: targetCode,
bscPlanAmt,
bgtRfltAmt,
tstmDesignAmt,
};
}
export function calculateIncidentalExpenses(
bizCstList: InstlSncnBizCstSaveRequestDto[],
codes: string[],
targetCode: string,
): InstlSncnBizCstSaveRequestDto {
let bscPlanAmt = 0;
let bgtRfltAmt = 0;
let tstmDesignAmt = 0;
codes.forEach((code) => {
const item = bizCstList.find((i) => i.acntgSeCd === code);
if (item) {
bscPlanAmt += item.bscPlanAmt || 0;
bgtRfltAmt += item.bgtRfltAmt || 0;
tstmDesignAmt += item.tstmDesignAmt || 0;
}
});
return {
acntgSeCd: targetCode,
bscPlanAmt,
bgtRfltAmt,
tstmDesignAmt,
};
}
export function calculateTotalCost(
bizCstList: InstlSncnBizCstSaveRequestDto[],
codes: string[],
targetCode: string,
): InstlSncnBizCstSaveRequestDto {
let bscPlanAmt = 0;
let bgtRfltAmt = 0;
let tstmDesignAmt = 0;
codes.forEach((code) => {
const item = bizCstList.find((i) => i.acntgSeCd === code);
if (item) {
bscPlanAmt += item.bscPlanAmt || 0;
bgtRfltAmt += item.bgtRfltAmt || 0;
tstmDesignAmt += item.tstmDesignAmt || 0;
}
});
return {
acntgSeCd: targetCode,
bscPlanAmt,
bgtRfltAmt,
tstmDesignAmt,
};
}
export function calculateDiffPercent(diff: number, baseline: number): string {
if (baseline === 0) return "-";
return `${Math.round((diff / baseline) * 100)}%`;
}
@@ -0,0 +1,35 @@
import type { BizCstTreeDto } from "@/entities/instl-sncn";
const ACNTG_TREE_PATH_SEPARATOR = "|";
/** treePath를 회계구분 코드 배열로 파싱 */
export function parseAcntgTreePath(treePath: string): string[] {
return treePath.split(ACNTG_TREE_PATH_SEPARATOR).filter(Boolean);
}
/** treePath에서 직계 부모 acntgSeCd 반환 (루트면 null) */
export function getParentAcntgSeCd(node: Pick<BizCstTreeDto, "treePath">): string | null {
const segments = parseAcntgTreePath(node.treePath);
if (segments.length <= 1) return null;
return segments[segments.length - 2];
}
/** treePath 기준으로 bizCstList 정렬 (원본 불변) */
export function sortBizCstListByTreePath(list: BizCstTreeDto[]): BizCstTreeDto[] {
return [...list].sort((a, b) => {
const byPath = a.treePath.localeCompare(b.treePath);
if (byPath !== 0) return byPath;
return a.acntgSeCd.localeCompare(b.acntgSeCd);
});
}
/** 자식 노드가 없는 leaf 여부 (treePath | 구분자 기준) */
export function isBizCstLeafNode(
node: Pick<BizCstTreeDto, "acntgSeCd" | "treePath">,
allNodes: Pick<BizCstTreeDto, "acntgSeCd" | "treePath">[],
): boolean {
const childPrefix = `${node.treePath}${ACNTG_TREE_PATH_SEPARATOR}`;
return !allNodes.some(
(other) => other.acntgSeCd !== node.acntgSeCd && other.treePath.startsWith(childPrefix),
);
}
@@ -0,0 +1,133 @@
export const INSTL_SNCN_VIEW_BUTTON_ACTION = {
strg: "strg",
aply: "aply",
del: "del",
splmntCmptn: "splmntCmptn",
instlSncnAncmnt: "instlSncnAncmnt",
envoSbmsn: "envoSbmsn",
rjct: "rjct",
sncnAprv: "sncnAprv",
rvwRply: "rvwRply",
rqstTechRvw: "rqstTechRvw",
dsgntRvwr: "dsgntRvwr",
rvwStrg: "rvwStrg",
rvwCmptn: "rvwCmptn",
dmndSplmnt: "dmndSplmnt",
atrzAprv: "atrzAprv",
atrzRply: "atrzRply",
grndsChck: "grndsChck",
} as const;
export type InstlSncnViewButtonAction =
(typeof INSTL_SNCN_VIEW_BUTTON_ACTION)[keyof typeof INSTL_SNCN_VIEW_BUTTON_ACTION];
const ROLE_STATUS_BUTTON_ACTIONS: Record<string, Record<string, InstlSncnViewButtonAction[]>> = {
ROLE_USER05: {
"": [
INSTL_SNCN_VIEW_BUTTON_ACTION.strg,
INSTL_SNCN_VIEW_BUTTON_ACTION.aply,
INSTL_SNCN_VIEW_BUTTON_ACTION.del,
],
SV: [
INSTL_SNCN_VIEW_BUTTON_ACTION.strg,
INSTL_SNCN_VIEW_BUTTON_ACTION.aply,
INSTL_SNCN_VIEW_BUTTON_ACTION.del,
],
SL: [INSTL_SNCN_VIEW_BUTTON_ACTION.splmntCmptn],
TA: [INSTL_SNCN_VIEW_BUTTON_ACTION.instlSncnAncmnt],
},
ROLE_USER04: {
ST: [INSTL_SNCN_VIEW_BUTTON_ACTION.envoSbmsn, INSTL_SNCN_VIEW_BUTTON_ACTION.rjct],
EP: [INSTL_SNCN_VIEW_BUTTON_ACTION.sncnAprv],
},
ROLE_USER03: {
LE: [INSTL_SNCN_VIEW_BUTTON_ACTION.rvwRply, INSTL_SNCN_VIEW_BUTTON_ACTION.rqstTechRvw],
TP: [INSTL_SNCN_VIEW_BUTTON_ACTION.rvwRply],
},
ROLE_USER01: {
TE: [INSTL_SNCN_VIEW_BUTTON_ACTION.grndsChck],
SL: [INSTL_SNCN_VIEW_BUTTON_ACTION.splmntCmptn],
},
};
const ROLE_USER01_ATRZ_STATUS_CODES = ["ER", "TE"] as const;
const ROLE_USER01_ATRZ_STATUS_ACTIONS: Record<
string,
Record<string, InstlSncnViewButtonAction[]>
> = {
ER: {
ATRZ01: [INSTL_SNCN_VIEW_BUTTON_ACTION.dsgntRvwr],
},
TE: {
ATRZ02: [
INSTL_SNCN_VIEW_BUTTON_ACTION.rvwStrg,
INSTL_SNCN_VIEW_BUTTON_ACTION.rvwCmptn,
INSTL_SNCN_VIEW_BUTTON_ACTION.dmndSplmnt,
],
ATRZ03: [INSTL_SNCN_VIEW_BUTTON_ACTION.atrzAprv],
ATRZ99: [INSTL_SNCN_VIEW_BUTTON_ACTION.atrzRply],
},
};
function resolveButtonConfigs(actions: InstlSncnViewButtonAction[]): InstlSncnViewButtonAction[] {
return actions.map((action) => INSTL_SNCN_VIEW_BUTTON_ACTION[action]);
}
function resolveRoleUser01AtrzButtonActions(
prgrsSttsComCd: string,
atrzSeComCdList: string[],
): InstlSncnViewButtonAction[] {
const atrzStatusMap = ROLE_USER01_ATRZ_STATUS_ACTIONS[prgrsSttsComCd];
if (!atrzStatusMap || atrzSeComCdList.length === 0) return [];
const seen = new Set<InstlSncnViewButtonAction>();
const actions: InstlSncnViewButtonAction[] = [];
for (const atrzSeComCd of atrzSeComCdList) {
const mappedActions = atrzStatusMap[atrzSeComCd];
if (!mappedActions) continue;
for (const action of mappedActions) {
if (seen.has(action)) continue;
seen.add(action);
actions.push(action);
}
}
return actions;
}
export function needsRoleUser01AtrzResolve(
dtlInstCd: string | undefined,
prgrsSttsComCd: string,
): boolean {
return (
dtlInstCd === "ROLE_USER01" &&
ROLE_USER01_ATRZ_STATUS_CODES.includes(
prgrsSttsComCd as (typeof ROLE_USER01_ATRZ_STATUS_CODES)[number],
)
);
}
export function getButtonConfigs(
dtlInstCd: string | undefined,
prgrsSttsComCd: string,
atrzSeComCdList?: string[],
): InstlSncnViewButtonAction[] {
if (!dtlInstCd) return [];
if (needsRoleUser01AtrzResolve(dtlInstCd, prgrsSttsComCd)) {
return resolveButtonConfigs(
resolveRoleUser01AtrzButtonActions(prgrsSttsComCd, atrzSeComCdList ?? []),
);
}
const statusMap = ROLE_STATUS_BUTTON_ACTIONS[dtlInstCd];
if (!statusMap) return [];
const actions = statusMap[prgrsSttsComCd];
if (!actions) return [];
return resolveButtonConfigs(actions);
}
@@ -0,0 +1,118 @@
import type { CklstTreeNode } from "@/entities/cklst";
interface CklstResultListItem {
cklstSn: number;
cklstArtclSn: number;
cklstRvwSn: number;
rvwStpComCd: string;
selected: "Y" | "N" | null;
picRvwCn?: string | null;
}
function compareCklstSiblings(a: CklstTreeNode, b: CklstTreeNode): number {
if (a.cklstArtclSn !== b.cklstArtclSn) {
return a.cklstArtclSn - b.cklstArtclSn;
}
if (a.sortSeq !== b.sortSeq) {
return b.sortSeq - a.sortSeq;
}
return a.cklstRvwSn - b.cklstRvwSn;
}
export function orderCklstTreeNodes(rows: CklstTreeNode[]): CklstTreeNode[] {
const childMap = new Map<number | null, CklstTreeNode[]>();
rows.forEach((row) => {
const parent = row.upCklstRvwSn ?? null;
if (!childMap.has(parent)) childMap.set(parent, []);
childMap.get(parent)!.push(row);
});
childMap.forEach((siblings) => siblings.sort(compareCklstSiblings));
const ordered: CklstTreeNode[] = [];
function walk(parent: number | null) {
(childMap.get(parent) ?? []).forEach((child) => {
ordered.push(child);
walk(child.cklstRvwSn);
});
}
walk(null);
return ordered;
}
function groupCklstTreeByTaskSeComCd(cklstTree: CklstTreeNode[]): Map<string, CklstTreeNode[]> {
const grouped = new Map<string, CklstTreeNode[]>();
cklstTree.forEach((node) => {
const taskCode = node.taskSeComCd ?? "";
if (!taskCode) return;
if (!grouped.has(taskCode)) grouped.set(taskCode, []);
grouped.get(taskCode)!.push(node);
});
return grouped;
}
function getOrderedSelectableNodes(
cklstTree: CklstTreeNode[],
taskSeComCdArray: string[],
): CklstTreeNode[] {
const grouped = groupCklstTreeByTaskSeComCd(cklstTree);
return taskSeComCdArray.flatMap((code) =>
orderCklstTreeNodes(grouped.get(code) ?? []).filter((node) => node.bchckUseYn === "Y"),
);
}
export function isSameCklstResultList(
prev: CklstResultListItem[],
next: CklstResultListItem[],
): boolean {
if (prev.length !== next.length) return false;
return prev.every((item, index) => {
const candidate = next[index];
return (
item.cklstRvwSn === candidate.cklstRvwSn &&
item.cklstSn === candidate.cklstSn &&
item.cklstArtclSn === candidate.cklstArtclSn &&
item.rvwStpComCd === candidate.rvwStpComCd &&
item.selected === candidate.selected &&
(item.picRvwCn ?? null) === (candidate.picRvwCn ?? null)
);
});
}
export function syncCklstResultListFromTree(
cklstTree: CklstTreeNode[],
taskSeComCdArray: string[],
prev: CklstResultListItem[],
): CklstResultListItem[] {
if (!taskSeComCdArray.length) {
return prev.length === 0 ? prev : [];
}
const prevByRvwSn = new Map(prev.map((item) => [item.cklstRvwSn, item]));
const orderedSelectable = getOrderedSelectableNodes(cklstTree, taskSeComCdArray);
const next = orderedSelectable.map((node) => {
const existing = prevByRvwSn.get(node.cklstRvwSn);
return (
existing ?? {
cklstSn: node.cklstSn,
cklstArtclSn: node.cklstArtclSn,
cklstRvwSn: node.cklstRvwSn,
rvwStpComCd: "LOCAL",
selected: null,
picRvwCn: null,
}
);
});
if (isSameCklstResultList(prev, next)) return prev;
return next;
}
@@ -0,0 +1,181 @@
import type {
CklstRvwRsltTreeDto,
InstlSncnCklstResultSaveDto,
ReviewStepCode,
} from "@/entities/instl-sncn";
import {
type ChecklistFormState,
type ChecklistProgress,
type ChecklistRowKey,
type ChecklistStepMap,
REVIEW_STEP_CODES,
} from "../model/view-types";
export function buildChecklistRowKey(
row: CklstRvwRsltTreeDto,
reviewStep?: ReviewStepCode,
): ChecklistRowKey {
const step = reviewStep ?? (row.rvwStpComCd as ReviewStepCode) ?? "LOCAL";
return `${row.taskSeComCd}_${row.cklstSn}_${row.cklstArtclSn}_${row.cklstRvwSn}_${step}`;
}
export function groupByReviewStep(rows: CklstRvwRsltTreeDto[]): ChecklistStepMap {
const base: ChecklistStepMap = { LOCAL: [], ENV: [], KECO: [] };
rows.forEach((row) => {
const step = (row.rvwStpComCd ?? "LOCAL") as ReviewStepCode;
if (REVIEW_STEP_CODES.includes(step)) base[step].push(row);
});
return base;
}
export function extractTaskCodes(rows: CklstRvwRsltTreeDto[]): string[] {
return Array.from(new Set(rows.map((row) => row.taskSeComCd).filter(Boolean)));
}
export function filterChecklistRows(
rows: CklstRvwRsltTreeDto[],
taskCode: string | null,
): CklstRvwRsltTreeDto[] {
return rows.filter((row) => (taskCode ? row.taskSeComCd === taskCode : true)).slice();
}
export function getChecklistDisplayRows(
rows: CklstRvwRsltTreeDto[],
taskCode?: string | null,
): CklstRvwRsltTreeDto[] {
const filtered = filterChecklistRows(rows, taskCode ?? null);
const seen = new Set<string>();
const deduped: CklstRvwRsltTreeDto[] = [];
for (const row of filtered) {
const dedupeKey = `${row.taskSeComCd}_${row.cklstRvwSn}`;
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
deduped.push(row);
}
return deduped;
}
export function computeChecklistProgress(
rows: CklstRvwRsltTreeDto[],
results: ChecklistFormState["results"],
): ChecklistProgress {
const activeRows = rows.filter((row) => row.bchckUseYn === "Y");
const total = activeRows.length;
const completed = activeRows.reduce((acc, row) => {
const key = buildChecklistRowKey(row);
const val = results[key] ?? row.rvwRsltAnmlyYn ?? "";
return acc + (val ? 1 : 0);
}, 0);
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
return { total, completed, percent };
}
export function filterChecklistRowsByReviewStep(
rows: CklstRvwRsltTreeDto[],
reviewStep: ReviewStepCode,
): CklstRvwRsltTreeDto[] {
return rows.filter((row) => row.rvwStpComCd === reviewStep);
}
export function computeReviewStepChecklistProgress(
rows: CklstRvwRsltTreeDto[],
results: ChecklistFormState["results"],
reviewStep: ReviewStepCode,
): ChecklistProgress {
const activeRows = getChecklistDisplayRows(rows).filter((row) => row.bchckUseYn === "Y");
const total = activeRows.length;
const completed = activeRows.reduce((acc, row) => {
const key = buildChecklistRowKey(row, reviewStep);
const val = results[key];
return acc + (val === "Y" || val === "N" ? 1 : 0);
}, 0);
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
return { total, completed, percent };
}
export function buildChecklistFormState(rows: CklstRvwRsltTreeDto[]): ChecklistFormState {
const results: ChecklistFormState["results"] = {};
const comments: ChecklistFormState["comments"] = {};
rows.forEach((row) => {
const key = buildChecklistRowKey(row);
if (row.rvwRsltAnmlyYn === "Y" || row.rvwRsltAnmlyYn === "N") {
results[key] = row.rvwRsltAnmlyYn;
}
if (row.picRvwCn) comments[key] = row.picRvwCn;
});
return {
results,
comments,
progress: computeChecklistProgress(rows, results),
};
}
export function mergeChecklistFormStateFromServer(
prev: ChecklistFormState,
allRows: CklstRvwRsltTreeDto[],
): ChecklistFormState {
const results = { ...prev.results };
const comments = { ...prev.comments };
for (const row of allRows) {
const step = row.rvwStpComCd as ReviewStepCode | undefined;
if (!step || !REVIEW_STEP_CODES.includes(step)) continue;
const key = buildChecklistRowKey(row);
if ((row.rvwRsltAnmlyYn === "Y" || row.rvwRsltAnmlyYn === "N") && results[key] === undefined) {
results[key] = row.rvwRsltAnmlyYn;
}
if (row.picRvwCn && !comments[key]) {
comments[key] = row.picRvwCn;
}
}
return {
results,
comments,
progress: computeChecklistProgress(getChecklistDisplayRows(allRows), results),
};
}
export function patchChecklistFormState(
prev: ChecklistFormState,
patch: Partial<Pick<ChecklistFormState, "results" | "comments">>,
allRows: CklstRvwRsltTreeDto[],
reviewStep?: ReviewStepCode,
): ChecklistFormState {
const results = patch.results ?? prev.results;
const comments = patch.comments ?? prev.comments;
const progress = reviewStep
? computeReviewStepChecklistProgress(allRows, results, reviewStep)
: computeChecklistProgress(getChecklistDisplayRows(allRows), results);
return {
results,
comments,
progress,
};
}
export function toCklstSaveDto(
rows: CklstRvwRsltTreeDto[],
formState: ChecklistFormState,
reviewStep: ReviewStepCode,
): InstlSncnCklstResultSaveDto[] {
return getChecklistDisplayRows(rows)
.filter((row) => row.bchckUseYn === "Y")
.map((row) => {
const key = buildChecklistRowKey(row, reviewStep);
const selected = formState.results[key] ?? null;
return {
cklstSn: row.cklstSn,
cklstArtclSn: row.cklstArtclSn,
cklstRvwSn: row.cklstRvwSn,
rvwStpComCd: reviewStep,
selected,
picRvwCn: formState.comments[key] ?? null,
};
});
}
@@ -0,0 +1,103 @@
import { z } from "zod";
export const cnfmRegistSchema = z.object({
instlSncnMng: z.object({
bizNm: z.string().min(1, "사업명은 필수입니다"),
bizBgngYr: z.string().min(1, "사업시작연도는 필수입니다"),
bizEndYr: z.string().min(1, "사업종료연도는 필수입니다"),
bizCn: z.string().min(1, "사업내용은 필수입니다"),
taskSeComCd: z.array(z.string()).min(1, "구분은 필수입니다"),
sncnSeComCd: z.string().min(1, "사업구분은 필수입니다"),
dsgnrNm: z.string().min(1, "설계자 이름은 필수입니다"),
dsgnrTelno: z.string().min(1, "설계자 전화번호는 필수입니다"),
dsgnrOgdpNm: z.string().min(1, "설계자 소속은 필수입니다"),
dsgnrEmlAddr: z
.string()
.min(1, "설계자 이메일은 필수입니다")
.email("올바른 이메일 형식이 아닙니다"),
bgtBizCd: z.string().optional(),
bgtAplyYr: z.string().optional(),
aplcntNm: z.string().optional(),
aplcntMblTelno: z.string().optional(),
aplcntOgdpNm: z.string().optional(),
aplcntEmlAddr: z.string().optional(),
instlSncnDocNo: z.string().optional(),
admdstCd: z.string().optional(),
envoSeComCd: z.string().optional(),
prgrsSttsComCd: z.string().optional(),
atchFileGroupId: z.string().optional(),
rtlztnFcltSeComCd: z.string().optional(),
rtlztnUsgSeComCd: z.string().optional(),
envoSeCdNm: z.string().optional(),
sncnSeCdNm: z.string().optional(),
taskSeCdNm: z.string().optional(),
prgrsSttsCdNm: z.string().optional(),
sido: z.string().optional(),
gugun: z.string().optional(),
regDt: z.string().optional(),
rgtr: z.string().optional(),
rgtrNm: z.string().optional(),
mdfcnDt: z.string().optional(),
}),
bzScaleList: z.array(
z.object({
cnstpSeCd: z.string(),
bscPl: z.number(),
budgetReflct: z.number(),
thtiDsgn: z.number(),
diff: z.number(),
}),
),
bizCstList: z.array(
z.object({
acntgSeCd: z.string(),
bscPlanAmt: z.number(),
bgtRfltAmt: z.number(),
tstmDesignAmt: z.number(),
}),
),
cklstResultList: z.array(
z.object({
cklstSn: z.number(),
cklstArtclSn: z.number(),
cklstRvwSn: z.number(),
rvwStpComCd: z.string(),
selected: z.enum(["Y", "N"]).nullable(),
picRvwCn: z.string().nullable().optional(),
}),
),
esntlAtchFileList: z.array(
z.object({
esntlAtchFileId: z.string(),
atchFileGroupId: z.string(),
}),
),
privacyConsent: z.enum(["Y", "N"], {
required_error: "개인정보 수집/이용 동의는 필수입니다",
}),
});
export type CnfmRegistFormData = z.infer<typeof cnfmRegistSchema>;
export function formatTaskSeComCdForApi(taskSeComCd: string[]): string {
return taskSeComCd.join(",");
}
export function parseTaskSeComCdFromApi(taskSeComCd?: string): string[] {
if (!taskSeComCd) return [];
return taskSeComCd.split(",").filter(Boolean);
}
export const cnfmRegistSaveSchema = cnfmRegistSchema.pick({
instlSncnMng: true,
bzScaleList: true,
bizCstList: true,
});
export const cnfmRegistSubmitSchema = cnfmRegistSchema.refine(
(data) => data.privacyConsent === "Y",
{
message: "개인정보 수집/이용에 동의해야 제출할 수 있습니다",
path: ["privacyConsent"],
},
);
@@ -0,0 +1,121 @@
import type { IUserInfo } from "@/shared/store/user-store";
import type { InstlSncnMngDetResponse } from "@/entities/instl-sncn";
import type { CnfmRegistFormData } from "./cnfm-regist-schema";
import { parseTaskSeComCdFromApi } from "./cnfm-regist-schema";
function mapSelectedValue(value?: string | null): "Y" | "N" | null {
if (value === "Y" || value === "N") return value;
return null;
}
function mapEsntlAtchFileList(esntlFileList: InstlSncnMngDetResponse["esntlFileList"]) {
const esntlMap = new Map<string, string>();
for (const item of esntlFileList) {
if (!item.esntlAtchFileId || !item.atflCd) continue;
esntlMap.set(item.esntlAtchFileId, item.atflCd);
}
return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({
esntlAtchFileId,
atchFileGroupId,
}));
}
export function mapDetailToFormData(data: InstlSncnMngDetResponse): CnfmRegistFormData {
const { detDto, bizSclList, bizCstList, cklstResultList, esntlFileList } = data;
return {
instlSncnMng: {
instlSncnDocNo: detDto.instlSncnDocNo,
bizNm: detDto.bizNm,
bizBgngYr: detDto.bizBgngYr,
bizEndYr: detDto.bizEndYr,
bizCn: detDto.bizCn,
taskSeComCd: parseTaskSeComCdFromApi(detDto.taskSeComCd),
sncnSeComCd: detDto.sncnSeComCd,
dsgnrNm: detDto.dsgnrNm,
dsgnrTelno: detDto.dsgnrTelno,
dsgnrOgdpNm: detDto.dsgnrOgdpNm,
dsgnrEmlAddr: detDto.dsgnrEmlAddr,
aplcntNm: detDto.aplcntNm ?? "",
aplcntMblTelno: detDto.aplcntMblTelno ?? "",
aplcntOgdpNm: detDto.aplcntOgdpNm ?? "",
aplcntEmlAddr: detDto.aplcntEmlAddr ?? "",
bgtBizCd: detDto.bgtBizCd,
bgtAplyYr: detDto.bgtAplyYr,
admdstCd: detDto.admdstCd,
envoSeComCd: detDto.envoSeComCd,
prgrsSttsComCd: detDto.prgrsSttsComCd,
atchFileGroupId: detDto.atchFileGroupId ?? "",
rtlztnFcltSeComCd: detDto.rtlztnFcltSeComCd ?? "",
rtlztnUsgSeComCd: detDto.rtlztnUsgSeComCd ?? "",
envoSeCdNm: detDto.envoSeCdNm,
sncnSeCdNm: detDto.sncnSeCdNm,
taskSeCdNm: detDto.taskSeCdNm,
prgrsSttsCdNm: detDto.prgrsSttsCdNm,
sido: detDto.sido,
gugun: detDto.gugun,
regDt: detDto.regDt,
rgtr: detDto.rgtr,
rgtrNm: detDto.rgtrNm,
mdfcnDt: detDto.mdfcnDt,
},
bzScaleList: bizSclList.map((item) => ({
cnstpSeCd: item.cnstpSeCd,
bscPl: item.bscPlanQty,
budgetReflct: item.bgtRfltQty,
thtiDsgn: item.tstmDesignQty,
diff: item.tstmDesignQty - item.bgtRfltQty,
})),
bizCstList: bizCstList.map((item) => ({
acntgSeCd: item.acntgSeCd,
bscPlanAmt: item.bscPlanAmt,
bgtRfltAmt: item.bgtRfltAmt,
tstmDesignAmt: item.tstmDesignAmt,
})),
cklstResultList:
cklstResultList?.map((item) => ({
cklstSn: item.cklstSn,
cklstArtclSn: item.cklstArtclSn,
cklstRvwSn: item.cklstRvwSn,
rvwStpComCd: item.rvwStpComCd,
selected: mapSelectedValue(item.rvwRsltAnmlyYn),
picRvwCn: item.picRvwCn,
})) ?? [],
esntlAtchFileList: mapEsntlAtchFileList(esntlFileList),
privacyConsent: "Y",
};
}
export function getCreateDefaultValues(
userInfo: IUserInfo | null,
taskSeComCd?: string[],
): CnfmRegistFormData {
return {
instlSncnMng: {
bizNm: "",
bizBgngYr: "",
bizEndYr: "",
bizCn: "",
taskSeComCd: taskSeComCd ?? [],
sncnSeComCd: "",
dsgnrNm: "",
dsgnrTelno: "",
dsgnrOgdpNm: "",
dsgnrEmlAddr: "",
aplcntNm: userInfo?.userName ?? "",
aplcntMblTelno: userInfo?.mphn ?? "",
aplcntOgdpNm: userInfo?.ogdpSeNm ?? "",
aplcntEmlAddr: userInfo?.email ?? "",
prgrsSttsComCd: "SV",
atchFileGroupId: "",
},
bzScaleList: [],
bizCstList: [],
cklstResultList: [],
esntlAtchFileList: [],
privacyConsent: "N",
};
}
@@ -0,0 +1,16 @@
export function fmtNumber(value: number | null | undefined): string {
if (typeof value === "number") return value.toLocaleString();
if (value === null || value === undefined) return "";
return String(value);
}
export function indentPx(indNm: string | undefined): number {
if (!indNm) return 0;
const spaces = indNm.length - indNm.trimStart().length;
return (spaces / 4) * 16;
}
export function calcDiffPercent(diff: number, baseline: number): string {
if (baseline === 0) return "-";
return `${Math.round((diff / baseline) * 100)}%`;
}
@@ -0,0 +1,37 @@
import type {
InstlSncnMngDetResponse,
InstlSncnRvwOpnnSaveRequestDto,
} from "@/entities/instl-sncn";
export interface ReviewOpinionFormData {
rvwRsltCn: string;
rvwRsltAnmlyYn: "Y" | "N" | "";
atchFileGroupId: string;
opnrSeComCd: string;
}
export function toRvwOpnnSaveDto(
data: InstlSncnMngDetResponse,
reviewOpinionFormData: ReviewOpinionFormData | null | undefined,
opnrNm?: string,
): InstlSncnRvwOpnnSaveRequestDto[] | undefined {
if (!reviewOpinionFormData) return undefined;
const hasContent =
reviewOpinionFormData.rvwRsltCn.trim() ||
reviewOpinionFormData.rvwRsltAnmlyYn ||
reviewOpinionFormData.atchFileGroupId;
if (!hasContent) return undefined;
return [
{
instlSncnDocNo: data.detDto.instlSncnDocNo,
opnrSeComCd: reviewOpinionFormData.opnrSeComCd,
opnrNm: opnrNm ?? "",
rvwRsltCn: reviewOpinionFormData.rvwRsltCn,
rvwRsltAnmlyYn: reviewOpinionFormData.rvwRsltAnmlyYn || undefined,
atchFileGroupId: reviewOpinionFormData.atchFileGroupId || undefined,
},
];
}
@@ -0,0 +1,43 @@
import type { ReviewStepCode } from "@/entities/instl-sncn";
import {
INSTL_PROGRESS_ORDER,
REVIEW_STEP_CODES,
REVIEW_STEP_ROLE_RANK,
ROLE_TO_REVIEW_STEP,
} from "../model/view-types";
export function getProgressStepCount(prgrsSttsComCd?: string): number {
const idx = INSTL_PROGRESS_ORDER.indexOf(prgrsSttsComCd as (typeof INSTL_PROGRESS_ORDER)[number]);
if (idx <= INSTL_PROGRESS_ORDER.indexOf("SV")) return 1;
if (idx <= INSTL_PROGRESS_ORDER.indexOf("LE")) return 2;
return 3;
}
export function getRoleStepCount(dtlInstCd?: string): number {
const mapped = dtlInstCd ? ROLE_TO_REVIEW_STEP[dtlInstCd] : undefined;
return mapped ? REVIEW_STEP_ROLE_RANK[mapped] : 0;
}
export function getVisibleReviewSteps(
prgrsSttsComCd: string,
userRoleCode?: string,
): ReviewStepCode[] {
const count = Math.min(getProgressStepCount(prgrsSttsComCd), getRoleStepCount(userRoleCode));
return REVIEW_STEP_CODES.slice(0, count);
}
export function getEditableReviewStep(
dtlInstCd?: string,
prgrsSttsComCd?: string,
): ReviewStepCode | undefined {
if (dtlInstCd === "ROLE_USER03" && (prgrsSttsComCd === "LE" || prgrsSttsComCd === "TP")) {
return "ENV";
}
if (dtlInstCd === "ROLE_USER01" && prgrsSttsComCd === "TE") {
return "KECO";
}
return undefined;
}

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