[as] feat: 공종코드 & 회계코드 서비스 구현

This commit is contained in:
2026-06-01 16:07:04 +09:00
parent 9b9edec627
commit b6afaba02a
10 changed files with 902 additions and 159 deletions
+385
View File
@@ -0,0 +1,385 @@
# 화면설계서: 코드관리
## 1. 기본 정보
| 항목 | 내용 |
| ---------- | -------------------------------------------- |
| 버전 | 0.1 |
| 프로세스명 | UI설계 |
| 화면명 | 코드관리 |
| 화면 유형 | [v] 조회 [v] 입력 [v] 수정 [v] 삭제 [ ] 출력 |
| 메뉴 경로 | 디지털행정지원 > 사이트관리 > 코드관리 |
---
## 2. 화면 목적
설치인가 등 AS 업무에서 참조하는 **공종구분코드**(공종코드)와 **회계구분코드**(회계코드)를 한 화면에서 조회·등록·수정·삭제한다. 좌측 **공종코드 관리**와 우측 **회계코드 관리**는 독립된 목록·CRUD 영역이며, 각 섹션은 자체 API(`cnstp-se`, `acntg-se`)와 연동한다.
---
## 3. 화면 구성
### 3-1. 전체 레이아웃
```
┌───────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 페이지 타이틀: 코드관리 브레드크럼: 디지털행정지원 > 사이트관리 > 코드관리 │
├──────────────────────────────────────────────────┬────────────────────────────────────────────────┤
│ [영역 A] 공종코드 관리 │ [영역 B] 회계코드 관리 │
│ [등록] │ [등록] │
│ ┌────────────────────────────────────────────┐ │ ┌──────────────────────────────────────────┐ │
│ │ 코드 | 코드명 | 설명 | 사용여부 | 관리 │ │ │ 코드 | 코드명 | 설명 | 사용여부 | 관리 │ │
│ ├────────────────────────────────────────────┤ │ ├──────────────────────────────────────────┤ │
│ │ WC001 공공하수처리시설 … 사용 [수정][삭제]│ │ │ AC101 시설비 … 사용 [수정][삭제] │ │
│ │ WC002 하수관로정비 … 사용 [수정][삭제]│ │ │ AC102 용역비 … 사용 [수정][삭제] │ │
│ │ WC003 재이용시설 … 사용 [수정][삭제]│ │ │ AC103 운영비 … 미사용 [수정][삭제] │ │
│ └────────────────────────────────────────────┘ │ └──────────────────────────────────────────┘ │
│ « 1 2 3 … » │ « 1 2 3 … » │
└──────────────────────────────────────────────────┴────────────────────────────────────────────────┘
```
### 3-2. 화면 데이터 흐름
```
BEGIN
// 최초 진입 — 좌·우 독립 조회
CALL cnstpSeMngService.findAll({ page, size })
DISPLAY 공종코드 그리드 (영역 A)
CALL acntgSeMngService.findAll({ page, size })
DISPLAY 회계코드 그리드 (영역 B)
// 영역 A: 공종코드 등록
ON 영역 A [등록] 버튼 CLICK DO
OPEN 공종코드 등록 다이얼로그
ON [저장] DO
VALIDATE cnstpSeCd
CALL cnstpSeMngService.save(CnstpSeMngSaveRequestDto)
CALL cnstpSeMngService.findAll
CLOSE 다이얼로그
END ON
ON [취소] / [닫기] DO
CLOSE 다이얼로그
END ON
END ON
// 영역 A: 공종코드 수정
ON 영역 A 행 [수정] CLICK DO
CALL cnstpSeMngService.findById(cnstpSeCd)
OPEN 공종코드 수정 다이얼로그 (기존 값 초기화)
ON [저장] DO
CALL cnstpSeMngService.update(cnstpSeCd, CnstpSeMngSaveRequestDto)
CALL cnstpSeMngService.findAll
CLOSE 다이얼로그
END ON
END ON
// 영역 A: 공종코드 삭제
ON 영역 A 행 [삭제] CLICK DO
CONFIRM "선택한 공종코드를 삭제하시겠습니까?"
CALL cnstpSeMngService.delete(cnstpSeCd)
CALL cnstpSeMngService.findAll
END ON
// 영역 B: 회계코드 등록
ON 영역 B [등록] 버튼 CLICK DO
OPEN 회계코드 등록 다이얼로그
ON [저장] DO
VALIDATE acntgSeCd, acntgSeCdNm
CALL acntgSeMngService.save(AcntgSeMngSaveRequestDto)
CALL acntgSeMngService.findAll
CLOSE 다이얼로그
END ON
ON [취소] / [닫기] DO
CLOSE 다이얼로그
END ON
END ON
// 영역 B: 회계코드 수정
ON 영역 B 행 [수정] CLICK DO
CALL acntgSeMngService.findById(acntgSeCd)
OPEN 회계코드 수정 다이얼로그 (기존 값 초기화)
ON [저장] DO
CALL acntgSeMngService.update(acntgSeCd, AcntgSeMngSaveRequestDto)
CALL acntgSeMngService.findAll
CLOSE 다이얼로그
END ON
END ON
// 영역 B: 회계코드 삭제
ON 영역 B 행 [삭제] CLICK DO
CONFIRM "선택한 회계코드를 삭제하시겠습니까?"
CALL acntgSeMngService.delete(acntgSeCd)
CALL acntgSeMngService.findAll
END ON
END
```
---
## 4. 영역 A: 공종코드 관리
백엔드 **공종구분코드**(`CnstpSeMng`) CRUD. 계층 구조(`upCnstpCd`, `cdLvNo`)를 지원하나, 본 화면은 **평면 목록**으로 표시.
### 4-1. 헤더 / 툴바
| 항목 | 내용 |
| ----------- | ------------------------------------- |
| 섹션 타이틀 | 공종코드 관리 |
| 우측 버튼 | **[등록]** — 공종코드 등록 다이얼로그 |
### 4-2. 그리드 컬럼
| 컬럼명 | Field | 타입 | 비고 |
| -------- | ------------- | ------ | ------------------------ |
| 코드 | `cnstpSeCd` | string | PK, 최대 10자 |
| 코드명 | `cnstpSeCdNm` | string | 최대 100자 |
| 설명 | `expln` | string | 최대 1000자, 선택 |
| 사용여부 | `useYn` | string | `Y`→사용, `N`→미사용 |
| 관리 | — | — | 행별 **[수정] · [삭제]** |
- 행 클릭 시 별도 상세 이동 없음. 수정은 **[수정]** 버튼으로 다이얼로그 진입.
- 페이지네이션: Spring `Pageable` (`page`, `size`, `sort`) — **UI 노출**. 기본 `size: 10`, `@common/components` 공통 페이지네이션 컴포넌트 사용.
### 4-3. 버튼
| 버튼명 | 위치 | 동작 |
| ------ | -------------- | ---------------------------------------- |
| 등록 | 섹션 우측 상단 | 공종코드 등록 다이얼로그 오픈 |
| 수정 | 행별 | 상세 조회 후 수정 다이얼로그 (값 초기화) |
| 삭제 | 행별 | 확인 후 삭제, 목록 갱신 |
### 4-4. 다이얼로그: 공종코드 등록 / 수정
다이얼로그 입력 필드는 **코드 · 코드명 · 설명 · 사용여부** 4개만 노출한다.
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
| -------- | --------- | ---- | ------ | ----------------- | ------------------------------- |
| 코드 | Input | Y | — | `cnstpSeCd` | 등록 시 입력, 수정 시 읽기 전용 |
| 코드명 | Input | N | — | `cnstpSeCdNm` | 최대 100자 |
| 설명 | Input | N | — | `expln` | 최대 1000자 |
| 사용여부 | Radio | N | Y | `useYn` | `Y`→사용, `N`→미사용 |
> UI에 노출하지 않는 API 필드(`upCnstpCd`, `cdLvNo`): 등록 시 `cdLvNo: 1` 등 기본값 전송, 수정 시 `findById` 응답값 유지.
| 버튼명 | 동작 |
| ------ | ------------------------------------------------ |
| 저장 | POST(등록) 또는 PUT(수정) 후 `findAll` 목록 갱신 |
| 취소 | 다이얼로그 닫기, 입력값 폐기 |
| 닫기 | 다이얼로그 닫기 |
---
## 5. 영역 B: 회계코드 관리
백엔드 **회계구분관리**(`AcntgSeMng`) CRUD. 영역 A와 동일한 **평면 목록** UI 패턴을 따른다.
### 5-1. 헤더 / 툴바
| 항목 | 내용 |
| ----------- | ------------------------------------- |
| 섹션 타이틀 | 회계코드 관리 |
| 우측 버튼 | **[등록]** — 회계코드 등록 다이얼로그 |
### 5-2. 그리드 컬럼
| 컬럼명 | Field | 타입 | 비고 |
| -------- | ------------- | ------ | ------------------------ |
| 코드 | `acntgSeCd` | string | PK, 최대 10자 |
| 코드명 | `acntgSeCdNm` | string | 최대 100자 |
| 설명 | `expln` | string | 최대 1000자, 선택 |
| 사용여부 | `useYn` | string | `Y`→사용, `N`→미사용 |
| 관리 | — | — | 행별 **[수정] · [삭제]** |
- 페이지네이션: 영역 A와 동일 — **UI 노출**, 기본 `size: 10`.
### 5-3. 버튼
| 버튼명 | 위치 | 동작 |
| ------ | -------------- | ---------------------------------------- |
| 등록 | 섹션 우측 상단 | 회계코드 등록 다이얼로그 오픈 |
| 수정 | 행별 | 상세 조회 후 수정 다이얼로그 (값 초기화) |
| 삭제 | 행별 | 확인 후 삭제, 목록 갱신 |
### 5-4. 다이얼로그: 회계코드 등록 / 수정
다이얼로그 입력 필드는 **코드 · 코드명 · 설명 · 사용여부** 4개만 노출한다. (영역 A와 동일 패턴)
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
| -------- | --------- | ---- | ------ | ----------------- | ------------------------------- |
| 코드 | Input | Y | — | `acntgSeCd` | 등록 시 입력, 수정 시 읽기 전용 |
| 코드명 | Input | Y | — | `acntgSeCdNm` | 최대 100자 |
| 설명 | Input | N | — | `expln` | 최대 1000자 |
| 사용여부 | Radio | N | Y | `useYn` | `Y`→사용, `N`→미사용 |
> UI에 노출하지 않는 API 필드(`upAcntgSeCd`, `cdLvNo`): 등록 시 `cdLvNo: 1` 등 기본값 전송, 수정 시 `findById` 응답값 유지.
| 버튼명 | 동작 |
| ------ | ------------------------------------------------ |
| 저장 | POST(등록) 또는 PUT(수정) 후 `findAll` 목록 갱신 |
| 취소 | 다이얼로그 닫기, 입력값 폐기 |
| 닫기 | 다이얼로그 닫기 |
---
## 6. API/DTO 명세
### 6-1. 공통
| 항목 | 내용 |
| ----------- | ---------------------------------------------------------------------------------- |
| 서비스 | `as-service` |
| Base prefix | `/as-service/api/v1` (`ENDPOINT.AS_COMMON_SERVICE`) |
| 인증 | JWT (`Authorization` 헤더), `X-Site-Id`, `token-id` |
| 페이징 | Spring `Pageable`: `page`, `size`, `sort` → `PageResponse<T>` |
| 엔티티 | `@/entities/cnstp` (`cnstpSeMngService`), `@/entities/acntg` (`acntgSeMngService`) |
**부가 API (본 화면 목록 외 참조용)**
| 리소스 | Method | URL | Response | 용도 |
| ------------ | ------ | --------------------- | --------------------- | --------------------------------- |
| 공종구분코드 | GET | `/cnstp-se/list/tree` | `CnstpSeMngTreeDto[]` | 계층 트리 (타 화면·상위코드 선택) |
| 회계구분관리 | GET | `/acntg-se/list/tree` | `AcntgSeMngTreeDto[]` | 계층 트리 (타 화면·상위코드 선택) |
### 6-2. 공종구분코드 (영역 A) — `cnstp-se`
| 기능 | Method | URL | Request / Query | Response | 클라이언트 |
| --------- | ------ | ----------------------- | -------------------------------- | ----------------------------- | ---------------------------- |
| 목록 조회 | GET | `/cnstp-se` | `CnstpSeMngSearchDto` + Pageable | `Page<CnstpSeMngResponseDto>` | `cnstpSeMngService.findAll` |
| 트리 조회 | GET | `/cnstp-se/list/tree` | — | `CnstpSeMngTreeDto[]` | `cnstpSeMngService.findTree` |
| 상세 조회 | GET | `/cnstp-se/{cnstpSeCd}` | — | `CnstpSeMngResponseDto` | `cnstpSeMngService.findById` |
| 등록 | POST | `/cnstp-se` | `CnstpSeMngSaveRequestDto` | `string` (cnstpSeCd) | `cnstpSeMngService.save` |
| 수정 | PUT | `/cnstp-se/{cnstpSeCd}` | `CnstpSeMngSaveRequestDto` | `string` (cnstpSeCd) | `cnstpSeMngService.update` |
| 삭제 | DELETE | `/cnstp-se/{cnstpSeCd}` | — | `string` (cnstpSeCd) | `cnstpSeMngService.delete` |
**CnstpSeMngResponseDto**
| Field | 타입 | 설명 |
| ------------- | ------- | ------------------ |
| `cnstpSeCd` | string | 공종구분코드 |
| `cnstpSeCdNm` | string | 공종구분코드명 |
| `upCnstpCd` | string? | 상위공종코드 |
| `cdLvNo` | number | 코드레벨번호 |
| `useYn` | string | 사용여부 |
| `expln` | string? | 설명 (최대 1000자) |
| `rgtr` | string? | 등록자 UUID |
| `rgtrNm` | string? | 등록자명 |
| `mdfr` | string? | 수정자 UUID |
| `regDt` | string? | 등록일시 |
| `mdfcnDt` | string? | 수정일시 |
**CnstpSeMngSaveRequestDto**: `cnstpSeCd`, `cnstpSeCdNm?`, `upCnstpCd?`, `cdLvNo?`, `useYn?`, `expln?`
**CnstpSeMngSearchDto**: `cnstpSeCd?`, `cnstpSeCdNm?`, `useYn?` + `page`, `size`, `sort`
**React Query hooks**: `useCnstpSeMngPage`, `useCnstpSeMngTree`, `useCnstpSeMng`, `useSaveCnstpSeMng`, `useUpdateCnstpSeMng`, `useDeleteCnstpSeMng`
### 6-3. 회계구분관리 (영역 B) — `acntg-se`
| 기능 | Method | URL | Request / Query | Response | 클라이언트 |
| --------- | ------ | ----------------------- | -------------------------------- | ----------------------------- | ---------------------------- |
| 목록 조회 | GET | `/acntg-se` | `AcntgSeMngSearchDto` + Pageable | `Page<AcntgSeMngResponseDto>` | `acntgSeMngService.findAll` |
| 트리 조회 | GET | `/acntg-se/list/tree` | — | `AcntgSeMngTreeDto[]` | `acntgSeMngService.findTree` |
| 상세 조회 | GET | `/acntg-se/{acntgSeCd}` | — | `AcntgSeMngResponseDto` | `acntgSeMngService.findById` |
| 등록 | POST | `/acntg-se` | `AcntgSeMngSaveRequestDto` | `string` (acntgSeCd) | `acntgSeMngService.save` |
| 수정 | PUT | `/acntg-se/{acntgSeCd}` | `AcntgSeMngSaveRequestDto` | `string` (acntgSeCd) | `acntgSeMngService.update` |
| 삭제 | DELETE | `/acntg-se/{acntgSeCd}` | — | `string` (acntgSeCd) | `acntgSeMngService.delete` |
**AcntgSeMngResponseDto**
| Field | 타입 | 설명 |
| ------------- | ------- | ------------------ |
| `acntgSeCd` | string | 회계구분코드 |
| `acntgSeCdNm` | string | 회계구분코드명 |
| `upAcntgSeCd` | string? | 상위회계구분코드 |
| `cdLvNo` | number | 코드레벨번호 |
| `useYn` | string | 사용여부 |
| `expln` | string? | 설명 (최대 1000자) |
| `rgtr` | string? | 등록자 UUID |
| `rgtrNm` | string? | 등록자명 |
| `mdfr` | string? | 수정자 UUID |
| `regDt` | string? | 등록일시 |
| `mdfcnDt` | string? | 수정일시 |
**AcntgSeMngSaveRequestDto**: `acntgSeCd`, `acntgSeCdNm`, `upAcntgSeCd?`, `cdLvNo`, `useYn?`, `expln?`
**AcntgSeMngSearchDto**: `acntgSeCd?`, `acntgSeCdNm?`, `useYn?` + `page`, `size`, `sort`
**React Query hooks**: `useAcntgSeMngPage`, `useAcntgSeMngTree`, `useAcntgSeMng`, `useSaveAcntgSeMng`, `useUpdateAcntgSeMng`, `useDeleteAcntgSeMng`
---
## 7. UI 구현 가이드
### 7-1. 기술 스택·원칙
- Next.js App Router, React Query (`@tanstack/react-query`)
- `@common/components` — `Card`, `Button`, `Dialog`, `DataTable` / `useDataTable`
- `@tanstack/react-table` — 컬럼 정의는 `model/*-table-columns.tsx` 분리
- 기존 entity 레이어 재사용: `@/entities/cnstp`, `@/entities/acntg` (service·queries·interface 구현 완료)
- 좌·우 2열 레이아웃: `grid grid-cols-1 lg:grid-cols-2 gap-4` (모바일 1열 스택)
- 삭제 전 `confirm` / Toast 피드백 — 프로젝트 공통 패턴 준수
- 목록 하단 **페이지네이션 UI 노출** — 영역 A·B 각각 독립 page state
- **검색 필터 UI 미제공** — `findAll` 호출 시 `page`, `size`만 전달
- **등록/수정 다이얼로그** — 코드·코드명·설명은 `Input`, 사용여부는 `Radio` (4필드만 노출)
- 상위 코드 삭제 시 **하위 코드 연쇄 삭제 UI·로직 미구현** — 단건 삭제만 수행
### 7-2. 모듈·파일 배치
| 구분 | 경로 (제안) |
| ----------------- | -------------------------------------------------------------------- |
| 라우트 | `apps/as/src/app/(authenticated)/site/code-management/page.tsx` |
| 페이지 컴포넌트 | `apps/as/src/pages/code-management/ui/code-management-page.tsx` |
| 공종 그리드 컬럼 | `apps/as/src/pages/code-management/model/cnstp-se-table-columns.tsx` |
| 회계 그리드 컬럼 | `apps/as/src/pages/code-management/model/acntg-se-table-columns.tsx` |
| 공종 등록/수정 폼 | `apps/as/src/pages/code-management/ui/cnstp-se-form-dialog.tsx` |
| 회계 등록/수정 폼 | `apps/as/src/pages/code-management/ui/acntg-se-form-dialog.tsx` |
| API·타입 (기존) | `@/entities/cnstp`, `@/entities/acntg` |
### 7-3. 영역별 UI 매핑
| 영역 | UI 컴포넌트 | 데이터 소스 | 비고 |
| ------------ | ------------------------------------- | ------------------------------------------- | ------------------------------ |
| A | `CnstpSeSection` + Table + Pagination | `useCnstpSeMngPage({ page, size })` | 독립 page state·mutation |
| B | `AcntgSeSection` + Table + Pagination | `useAcntgSeMngPage({ page, size })` | A와 상호 의존 없음 |
| A 다이얼로그 | `CnstpSeFormDialog` | `useCnstpSeMng`, `useSave/UpdateCnstpSeMng` | Input 4필드 + Radio 사용여부 |
| B 다이얼로그 | `AcntgSeFormDialog` | `useAcntgSeMng`, `useSave/UpdateAcntgSeMng` | Input 4필드 + Radio 사용여부 |
### 7-4. 다이얼로그·라우팅
| 다이얼로그 | 트리거 | 라우팅 |
| ------------- | ---------------- | -------------------- |
| 공종코드 등록 | 영역 A [등록] | URL 변경 없음 (모달) |
| 공종코드 수정 | 영역 A 행 [수정] | URL 변경 없음 (모달) |
| 회계코드 등록 | 영역 B [등록] | URL 변경 없음 (모달) |
| 회계코드 수정 | 영역 B 행 [수정] | URL 변경 없음 (모달) |
### 7-5. 데이터 흐름 (구현 관점)
```
BEGIN
// 페이지 마운트 — 검색 조건 없이 page·size만 전달
const cnstpQuery = useCnstpSeMngPage({ page: 0, size: 20 })
const acntgQuery = useAcntgSeMngPage({ page: 0, size: 20 })
DISPLAY CodeManagementPage
├─ CnstpSeSection(data=cnstpQuery.data, pagination)
└─ AcntgSeSection(data=acntgQuery.data, pagination)
// 페이지 변경
ON pagination page/size CHANGE DO
refetch cnstpQuery | acntgQuery with new page, size
END ON
// mutation 성공 시
ON save/update/delete SUCCESS DO
invalidateQueries cnstp-se | acntg-se baseKey
TOAST "저장되었습니다." | "삭제되었습니다."
END ON
END
```
@@ -1,15 +1,52 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { PageResponse } from "@/shared/model";
import type { AcntgSeMngTreeDto, AcntgService } from "../model/acntg-interface";
import type {
AcntgSeMngResponseDto,
AcntgSeMngSaveRequestDto,
AcntgSeMngSearchDto,
AcntgSeMngService,
AcntgSeMngTreeDto,
} from "../model/acntg-interface";
async function findTree(): Promise<AcntgSeMngTreeDto[]> {
const response = await client.get<AcntgSeMngTreeDto[]>(
`${ENDPOINT.AS_COMMON_SERVICE.ACNTG_SE}/list/tree`,
);
const BASE = ENDPOINT.AS_COMMON_SERVICE.ACNTG_SE;
async function findAll(params: AcntgSeMngSearchDto): Promise<PageResponse<AcntgSeMngResponseDto>> {
const response = await client.get<PageResponse<AcntgSeMngResponseDto>>(BASE, { params });
return response.data;
}
export const acntgService = {
async function findTree(): Promise<AcntgSeMngTreeDto[]> {
const response = await client.get<AcntgSeMngTreeDto[]>(`${BASE}/list/tree`);
return response.data;
}
async function findById(acntgSeCd: string): Promise<AcntgSeMngResponseDto> {
const response = await client.get<AcntgSeMngResponseDto>(`${BASE}/${acntgSeCd}`);
return response.data;
}
async function save(body: AcntgSeMngSaveRequestDto): Promise<string> {
const response = await client.post<string>(BASE, body);
return response.data;
}
async function update(acntgSeCd: string, body: AcntgSeMngSaveRequestDto): Promise<string> {
const response = await client.put<string>(`${BASE}/${acntgSeCd}`, body);
return response.data;
}
async function deleteAcntgSeCd(acntgSeCd: string): Promise<string> {
const response = await client.delete<string>(`${BASE}/${acntgSeCd}`);
return response.data;
}
export const acntgSeMngService = {
findAll,
findTree,
} satisfies AcntgService;
findById,
save,
update,
delete: deleteAcntgSeCd,
} satisfies AcntgSeMngService;
+69
View File
@@ -0,0 +1,69 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import type { AcntgSeMngSaveRequestDto, AcntgSeMngSearchDto } from "../model/acntg-interface";
import { acntgSeMngService } from "./acntg-service";
const acntgSeMngBaseKey = [ENDPOINT.AS_COMMON_SERVICE.ACNTG_SE] as const;
export function useAcntgSeMngPage(params: AcntgSeMngSearchDto) {
return useQuery({
queryKey: [...acntgSeMngBaseKey, "findAll", params],
queryFn: () => acntgSeMngService.findAll(params),
});
}
export function useAcntgSeMngTree() {
return useQuery({
queryKey: [...acntgSeMngBaseKey, "findTree"],
queryFn: () => acntgSeMngService.findTree(),
staleTime: Infinity,
});
}
export function useAcntgSeMng(acntgSeCd: string | null | undefined) {
return useQuery({
queryKey: [...acntgSeMngBaseKey, "findById", acntgSeCd],
queryFn: () => acntgSeMngService.findById(acntgSeCd!),
enabled: Boolean(acntgSeCd),
});
}
export function useSaveAcntgSeMng() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: AcntgSeMngSaveRequestDto) => acntgSeMngService.save(body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: acntgSeMngBaseKey });
},
});
}
export function useUpdateAcntgSeMng() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ acntgSeCd, body }: { acntgSeCd: string; body: AcntgSeMngSaveRequestDto }) =>
acntgSeMngService.update(acntgSeCd, body),
onSuccess: (_data, { acntgSeCd }) => {
queryClient.invalidateQueries({ queryKey: acntgSeMngBaseKey });
queryClient.invalidateQueries({
queryKey: [...acntgSeMngBaseKey, "findById", acntgSeCd],
});
},
});
}
export function useDeleteAcntgSeMng() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (acntgSeCd: string) => acntgSeMngService.delete(acntgSeCd),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: acntgSeMngBaseKey });
},
});
}
+17 -2
View File
@@ -1,2 +1,17 @@
export type { AcntgSeMngTreeDto, AcntgService } from "./model/acntg-interface";
export { acntgService } from "./api/acntg-service";
export { acntgSeMngService } from "./api/acntg-service";
export {
useAcntgSeMng,
useAcntgSeMngPage,
useAcntgSeMngTree,
useDeleteAcntgSeMng,
useSaveAcntgSeMng,
useUpdateAcntgSeMng,
} from "./api/queries";
export type {
AcntgSeMngResponseDto,
AcntgSeMngSaveRequestDto,
AcntgSeMngSearchDto,
AcntgSeMngService,
AcntgSeMngTreeDto,
BizCstTreeDto,
} from "./model/acntg-interface";
@@ -1,23 +1,67 @@
/** 회계구분 트리 노드 DTO */
export interface AcntgSeMngTreeDto {
/** 들여쓰기 포함 표시명 (앞 공백 수 = 레벨 인디케이터) */
indAcntgSeCdNm: string;
/** 회계구분코드 */
import type { PageableParams, PageResponse } from "@/shared/model";
/** 백엔드 AcntgSeMngResponseDto */
export interface AcntgSeMngResponseDto {
acntgSeCd: string;
/** 회계구분코드명 */
acntgSeCdNm: string;
/** 상위 회계구분코드 (null = 최상위) */
upAcntgSeCd: string | null;
/** 사용여부 */
upAcntgSeCd?: string;
cdLvNo: number;
useYn: string;
expln?: string;
rgtr?: string;
rgtrNm?: string;
mdfr?: string;
regDt?: string;
mdfcnDt?: string;
}
/** 백엔드 AcntgSeMngSearchDto + Pageable */
export interface AcntgSeMngSearchDto extends PageableParams {
acntgSeCd?: string;
acntgSeCdNm?: string;
useYn?: string;
}
/** 백엔드 AcntgSeMngSaveRequestDto */
export interface AcntgSeMngSaveRequestDto {
acntgSeCd: string;
acntgSeCdNm: string;
upAcntgSeCd?: string;
cdLvNo: number;
useYn?: string;
expln?: string;
}
/** 백엔드 AcntgSeMngTreeDto */
export interface AcntgSeMngTreeDto {
indAcntgSeCdNm: string;
acntgSeCd: string;
acntgSeCdNm: string;
upAcntgSeCd?: string;
useYn: string;
/** 트리 레벨 (1 = 총사업비, 2 = 1.공사비/2.보상비/3.부대경비, …) */
treeLevel: number;
/** 최상위~현재 노드까지 "|" 구분 경로 */
treePath: string;
}
/** 회계구분 서비스 인터페이스 */
export interface AcntgService {
/** 회계구분 트리 전체 조회 */
findTree(): Promise<AcntgSeMngTreeDto[]>;
/** 백엔드 BizCstTreeDto (InstlSncnMngDetResponseDto 등에서 참조) */
export interface BizCstTreeDto {
indAcntgSeCdNm: string;
acntgSeCd: string;
acntgSeCdNm: string;
useYn: string;
treeLevel: number;
treePath: string;
bscPlanAmt?: number;
bgtRfltAmt?: number;
tstmDesignAmt?: number;
}
/** Controller public 메서드명·시그니처와 동일 */
export interface AcntgSeMngService {
findAll(params: AcntgSeMngSearchDto): Promise<PageResponse<AcntgSeMngResponseDto>>;
findTree(): Promise<AcntgSeMngTreeDto[]>;
findById(acntgSeCd: string): Promise<AcntgSeMngResponseDto>;
save(body: AcntgSeMngSaveRequestDto): Promise<string>;
update(acntgSeCd: string, body: AcntgSeMngSaveRequestDto): Promise<string>;
delete(acntgSeCd: string): Promise<string>;
}
@@ -1,15 +1,52 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { PageResponse } from "@/shared/model";
import type { CnstpSeMngTreeDto, CnstpService } from "../model/cnstp-interface";
import type {
CnstpSeMngResponseDto,
CnstpSeMngSaveRequestDto,
CnstpSeMngSearchDto,
CnstpSeMngService,
CnstpSeMngTreeDto,
} from "../model/cnstp-interface";
async function findTree(): Promise<CnstpSeMngTreeDto[]> {
const response = await client.get<CnstpSeMngTreeDto[]>(
`${ENDPOINT.AS_COMMON_SERVICE.CNSTP_SE}/list/tree`,
);
const BASE = ENDPOINT.AS_COMMON_SERVICE.CNSTP_SE;
async function findAll(params: CnstpSeMngSearchDto): Promise<PageResponse<CnstpSeMngResponseDto>> {
const response = await client.get<PageResponse<CnstpSeMngResponseDto>>(BASE, { params });
return response.data;
}
export const cnstpService = {
async function findTree(): Promise<CnstpSeMngTreeDto[]> {
const response = await client.get<CnstpSeMngTreeDto[]>(`${BASE}/list/tree`);
return response.data;
}
async function findById(cnstpSeCd: string): Promise<CnstpSeMngResponseDto> {
const response = await client.get<CnstpSeMngResponseDto>(`${BASE}/${cnstpSeCd}`);
return response.data;
}
async function save(body: CnstpSeMngSaveRequestDto): Promise<string> {
const response = await client.post<string>(BASE, body);
return response.data;
}
async function update(cnstpSeCd: string, body: CnstpSeMngSaveRequestDto): Promise<string> {
const response = await client.put<string>(`${BASE}/${cnstpSeCd}`, body);
return response.data;
}
async function deleteCnstpSeCd(cnstpSeCd: string): Promise<string> {
const response = await client.delete<string>(`${BASE}/${cnstpSeCd}`);
return response.data;
}
export const cnstpSeMngService = {
findAll,
findTree,
} satisfies CnstpService;
findById,
save,
update,
delete: deleteCnstpSeCd,
} satisfies CnstpSeMngService;
+69
View File
@@ -0,0 +1,69 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import type { CnstpSeMngSaveRequestDto, CnstpSeMngSearchDto } from "../model/cnstp-interface";
import { cnstpSeMngService } from "./cnstp-service";
const cnstpSeMngBaseKey = [ENDPOINT.AS_COMMON_SERVICE.CNSTP_SE] as const;
export function useCnstpSeMngPage(params: CnstpSeMngSearchDto) {
return useQuery({
queryKey: [...cnstpSeMngBaseKey, "findAll", params],
queryFn: () => cnstpSeMngService.findAll(params),
});
}
export function useCnstpSeMngTree() {
return useQuery({
queryKey: [...cnstpSeMngBaseKey, "findTree"],
queryFn: () => cnstpSeMngService.findTree(),
staleTime: Infinity,
});
}
export function useCnstpSeMng(cnstpSeCd: string | null | undefined) {
return useQuery({
queryKey: [...cnstpSeMngBaseKey, "findById", cnstpSeCd],
queryFn: () => cnstpSeMngService.findById(cnstpSeCd!),
enabled: Boolean(cnstpSeCd),
});
}
export function useSaveCnstpSeMng() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: CnstpSeMngSaveRequestDto) => cnstpSeMngService.save(body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: cnstpSeMngBaseKey });
},
});
}
export function useUpdateCnstpSeMng() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ cnstpSeCd, body }: { cnstpSeCd: string; body: CnstpSeMngSaveRequestDto }) =>
cnstpSeMngService.update(cnstpSeCd, body),
onSuccess: (_data, { cnstpSeCd }) => {
queryClient.invalidateQueries({ queryKey: cnstpSeMngBaseKey });
queryClient.invalidateQueries({
queryKey: [...cnstpSeMngBaseKey, "findById", cnstpSeCd],
});
},
});
}
export function useDeleteCnstpSeMng() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (cnstpSeCd: string) => cnstpSeMngService.delete(cnstpSeCd),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: cnstpSeMngBaseKey });
},
});
}
+17 -2
View File
@@ -1,2 +1,17 @@
export type { CnstpSeMngTreeDto, CnstpService } from "./model/cnstp-interface";
export { cnstpService } from "./api/cnstp-service";
export { cnstpSeMngService } from "./api/cnstp-service";
export {
useCnstpSeMng,
useCnstpSeMngPage,
useCnstpSeMngTree,
useDeleteCnstpSeMng,
useSaveCnstpSeMng,
useUpdateCnstpSeMng,
} from "./api/queries";
export type {
BizSclTreeDto,
CnstpSeMngResponseDto,
CnstpSeMngSaveRequestDto,
CnstpSeMngSearchDto,
CnstpSeMngService,
CnstpSeMngTreeDto,
} from "./model/cnstp-interface";
@@ -1,23 +1,67 @@
/** 공종구분 트리 노드 DTO */
export interface CnstpSeMngTreeDto {
/** 들여쓰기 포함 표시명 */
indCnstpSeCdNm: string;
/** 공종구분코드 */
import type { PageableParams, PageResponse } from "@/shared/model";
/** 백엔드 CnstpSeMngResponseDto */
export interface CnstpSeMngResponseDto {
cnstpSeCd: string;
/** 공종구분코드명 */
cnstpSeCdNm: string;
/** 상위 공종구분코드 (null = 최상위) */
upCnstpCd: string | null;
/** 사용여부 */
upCnstpCd?: string;
cdLvNo: number;
useYn: string;
expln?: string;
rgtr?: string;
rgtrNm?: string;
mdfr?: string;
regDt?: string;
mdfcnDt?: string;
}
/** 백엔드 CnstpSeMngSearchDto + Pageable */
export interface CnstpSeMngSearchDto extends PageableParams {
cnstpSeCd?: string;
cnstpSeCdNm?: string;
useYn?: string;
}
/** 백엔드 CnstpSeMngSaveRequestDto */
export interface CnstpSeMngSaveRequestDto {
cnstpSeCd: string;
cnstpSeCdNm?: string;
upCnstpCd?: string;
cdLvNo?: number;
useYn?: string;
expln?: string;
}
/** 백엔드 CnstpSeMngTreeDto */
export interface CnstpSeMngTreeDto {
indCnstpSeCdNm: string;
cnstpSeCd: string;
cnstpSeCdNm: string;
upCnstpCd?: string;
useYn: string;
/** 트리 레벨 */
treeLevel: number;
/** 최상위~현재 노드까지 "|" 구분 경로 */
treePath: string;
}
/** 공종구분 서비스 인터페이스 */
export interface CnstpService {
/** 공종구분 트리 전체 조회 */
findTree(): Promise<CnstpSeMngTreeDto[]>;
/** 백엔드 BizSclTreeDto */
export interface BizSclTreeDto {
indCnstpSeCdNm: string;
cnstpSeCd: string;
cnstpSeCdNm: string;
useYn: string;
treeLevel: number;
treePath: string;
bscPlanQty?: number;
bgtRfltQty?: number;
tstmDesignQty?: number;
}
/** Controller public 메서드명·시그니처와 동일 */
export interface CnstpSeMngService {
findAll(params: CnstpSeMngSearchDto): Promise<PageResponse<CnstpSeMngResponseDto>>;
findTree(): Promise<CnstpSeMngTreeDto[]>;
findById(cnstpSeCd: string): Promise<CnstpSeMngResponseDto>;
save(body: CnstpSeMngSaveRequestDto): Promise<string>;
update(cnstpSeCd: string, body: CnstpSeMngSaveRequestDto): Promise<string>;
delete(cnstpSeCd: string): Promise<string>;
}
@@ -10,18 +10,13 @@ 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 { deleteAttachment } from "@/shared/lib/attach";
import { formatFileSize } from "@/shared/lib/file-utils";
import type { AcntgSeMngTreeDto } from "@/entities/acntg";
import { acntgService } from "@/entities/acntg";
import { type AcntgSeMngTreeDto, useAcntgSeMngTree } from "@/entities/acntg";
import type { CklstTreeNode } from "@/entities/cklst";
import { cklstMngService } from "@/entities/cklst";
import type { CnstpSeMngTreeDto } from "@/entities/cnstp";
import { cnstpService } from "@/entities/cnstp";
import {
esntlAtchFileMngService,
type EsntlAtchFileMngTreeResponseDto,
} from "@/entities/esntl";
import { type CnstpSeMngTreeDto, useCnstpSeMngTree } from "@/entities/cnstp";
import { esntlAtchFileMngService, type EsntlAtchFileMngTreeResponseDto } from "@/entities/esntl";
import {
INSTL_SNCN_REGISTER_SELECTION_KEY,
InstlSncnApi,
@@ -30,7 +25,7 @@ import {
type InstlSncnBzScaleSaveDto,
type InstlSncnMng,
} 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";
@@ -125,7 +120,7 @@ const normalizeChecklistResultRows = (payload: any): any[] => {
/* 메인 컴포넌트 */
/* ------------------------------------------------------------------ */
/**
* initialData 매핑 안내
* initialData 매핑 안내
* - initialData 데이터는 cnfm-modify-page에서 서버로부터 받아온 상세 데이터를 그대로 전달받거나, cnfm-regist-page에서 수정 모드로 진입할 때 전달받을 수 있습니다.
* - 이 컴포넌트는 수정 모드로 호출될 때 `initialData`를 받습니다.
* - 서버 응답은 두 가지 형태 중 하나일 수 있습니다:
@@ -237,7 +232,9 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
staleTime: Infinity,
});
const [requiredFiles, setRequiredFiles] = useState<Record<RequiredAttachKey, RequiredAttachItem>>({});
const [requiredFiles, setRequiredFiles] = useState<Record<RequiredAttachKey, RequiredAttachItem>>(
{},
);
const requiredFileInputRefs = useRef<Record<RequiredAttachKey, HTMLInputElement | null>>({});
useEffect(() => {
@@ -321,22 +318,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(() => {
@@ -351,7 +348,6 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
//const cklstResultList = initialData.cklstResultList ?? [];
const esntlFileList = initialData.esntlFileList ?? [];
const etcFileList = initialData.etcFileList ?? [];
// 기본 정보 및 설계자 정보
setBzNm(det.bizNm ?? "");
@@ -381,9 +377,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,
@@ -397,13 +396,14 @@ 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);
// 매핑 완료 플래그 설정 — 이후 동일 컴포넌트 인스턴스에서 재매핑 방지
initialMappedRef.current = true;
@@ -465,14 +465,14 @@ 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);
@@ -493,18 +493,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,
@@ -534,7 +537,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,
@@ -702,7 +705,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;
@@ -750,25 +753,24 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
return { total, completed, percent };
}, [cklstRows, checkResults, selectedReviewStep]);
const toCklstSaveDtoList = () =>
{
const persistedStepCodeSet = new Set<ReviewStepCode>();
normalizeChecklistResultRows(initialData).forEach((r: any) => {
const code = r?.rvwStpComCd as ReviewStepCode | undefined;
if (code && REVIEW_STEP_CODES.includes(code)) persistedStepCodeSet.add(code);
});
visibleReviewStepCodes.forEach((code) => persistedStepCodeSet.add(code));
const toCklstSaveDtoList = () => {
const persistedStepCodeSet = new Set<ReviewStepCode>();
normalizeChecklistResultRows(initialData).forEach((r: any) => {
const code = r?.rvwStpComCd as ReviewStepCode | undefined;
if (code && REVIEW_STEP_CODES.includes(code)) persistedStepCodeSet.add(code);
});
visibleReviewStepCodes.forEach((code) => persistedStepCodeSet.add(code));
return Array.from(persistedStepCodeSet).flatMap((stepCode) =>
cklstRows.map((row) => ({
cklstSn: row.cklstSn,
cklstArtclSn: row.cklstArtclSn,
cklstRvwSn: row.cklstRvwSn,
rvwStpComCd: stepCode,
selected: checkResults[cklstKey(row, stepCode)] ?? null,
})),
);
};
return Array.from(persistedStepCodeSet).flatMap((stepCode) =>
cklstRows.map((row) => ({
cklstSn: row.cklstSn,
cklstArtclSn: row.cklstArtclSn,
cklstRvwSn: row.cklstRvwSn,
rvwStpComCd: stepCode,
selected: checkResults[cklstKey(row, stepCode)] ?? null,
})),
);
};
// 맵 형식으로 저장할 경우(업서트 처리용) - 키는 상세 코드(EX: LOCAL.지자체) 포맷
const REVIEW_STEP_FULL: Record<ReviewStepCode, string> = {
@@ -813,11 +815,7 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
const [bzScaleMap, setBzScaleMap] = useState<Record<string, BzScaleRow>>({});
/* ── 공종구분 트리 로드 ── */
const { data: cnstpTree = [] } = useQuery<CnstpSeMngTreeDto[]>({
queryKey: ["cnstpTree"],
queryFn: cnstpService.findTree,
staleTime: Infinity,
});
const { data: cnstpTree = [] } = useCnstpSeMngTree();
/* ── 공종구분 리프 노드 집합 ── */
const cnstpLeafSet = useMemo(() => {
@@ -827,7 +825,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 =>
@@ -865,11 +865,7 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
const [amtMap, setAmtMap] = useState<Record<string, BzAmtRow>>({});
/* ── 회계구분 트리 로드 ── */
const { data: acntgTree = [] } = useQuery<AcntgSeMngTreeDto[]>({
queryKey: ["acntgTree"],
queryFn: acntgService.findTree,
staleTime: Infinity, // 코드 데이터 — 앱 수명 동안 재요청 불필요
});
const { data: acntgTree = [] } = useAcntgSeMngTree();
/* ── 리프 노드 집합 (자식이 없는 노드 = 직접 입력 가능) ── */
const leafSet = useMemo(() => {
@@ -943,7 +939,6 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
/* ── 검증 ── */
function fnValidation(mode: "SAVE" | "PRESENTN") {
if (!bzNm.trim()) {
alert("사업명을 입력하세요.");
return false;
@@ -960,8 +955,7 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
alert("개인정보 제공에 동의하셔야 제출이 가능합니다.");
return false;
}
if (!editableReviewStep) {
alert("현재 사용자 권한/진행상태 조합에서 입력 가능한 체크리스트 단계가 없습니다.");
return false;
@@ -974,7 +968,7 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
if (hasUncheckedChecklist) {
alert("체크리스트를 모두 체크하세요.");
return false;
}
}
// 저장 시: 필수 첨부파일(esntlAtchYn=Y) 업로드 여부 검증
if (mode === "SAVE") {
const missingRequiredFile = requiredAttachmentDefs.find((item) => {
@@ -1070,7 +1064,7 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
function handleSubmit() {
if (!fnValidation("PRESENTN")) return;
const payload = {
instlSncnMng: toInstlSncnMngPayload("ST"),
instlSncnMng: toInstlSncnMngPayload("ST"),
bzScaleList: toBzScaleSaveDtoList(),
bizCstList: toBizCstSaveDtoList(),
cklstRvwRsltList: toCklstSaveDtoList(),
@@ -1134,7 +1128,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>
@@ -1156,7 +1150,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}>
@@ -1169,12 +1163,12 @@ 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"}
@@ -1182,7 +1176,9 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
</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]">
{[
@@ -1214,31 +1210,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="설계자 성명"
@@ -1247,7 +1259,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="설계자 전화번호"
@@ -1267,8 +1279,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="설계자 이메일"
@@ -1286,29 +1298,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>
@@ -1482,7 +1499,8 @@ function CnfmRegistPage({ initialData }: { initialData?: any } = {}) {
<div className="mb-3 flex items-center justify-between">
<h3 className="text-[1.5rem] font-bold text-gray-700">체크리스트</h3>
<button className="cnfm-btn-search text-[1.2rem]">
{(cklstRows[0]?.cklstNm ?? taskSeComCdNm) + `(${cklstStats.completed}/${cklstStats.total})`}
{(cklstRows[0]?.cklstNm ?? taskSeComCdNm) +
`(${cklstStats.completed}/${cklstStats.total})`}
</button>
</div>
<div className="mb-3 flex flex-wrap gap-2">
@@ -1513,8 +1531,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">
@@ -1583,7 +1602,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>
@@ -1650,7 +1672,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>
@@ -1660,7 +1684,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