처리분구관리 화면 구현

This commit is contained in:
kiminchan
2026-07-22 13:15:06 +09:00
parent 5e0693e057
commit ec60a05b01
11 changed files with 1003 additions and 0 deletions
@@ -0,0 +1,6 @@
import { TrtdstManagementPage } from "@/features/exmn/ui/TrtdstManagementPage";
/** 처리분구관리 업무 화면 라우트다. */
export default function Page() {
return <TrtdstManagementPage />;
}
+37
View File
@@ -174,6 +174,43 @@ export const postRawJson = async (url: string, data: object): Promise<any> => {
return body;
};
/** 공통 응답 래퍼를 사용하는 JSON PUT 요청을 전송하고 data 필드만 반환한다. */
export const putData = async (url: string, data: object): Promise<any> => {
const res = await fetch(resolveApiUrl(url), {
method: "PUT",
headers: withSiteIdHeaders({
"Content-Type": "application/json",
}),
body: JSON.stringify(data),
});
if (res.status === 401) {
handleUnauthorizedResponse();
}
const text = await res.text();
let body: unknown = null;
if (text.trim()) {
try {
body = JSON.parse(text);
} catch {
throw buildInvalidJsonResponseError(url, text, res.headers.get("content-type"));
}
}
if (!res.ok) {
throw buildRequestError(
`Failed to update data: ${res.status} ${res.statusText}`,
res.status,
body,
);
}
if (typeof body === "object" && body !== null && "data" in body) {
return (body as { data?: unknown }).data ?? null;
}
return body;
};
const parseDownloadFileName = (contentDisposition: string | null) => {
if (!contentDisposition) return undefined;
@@ -0,0 +1,56 @@
# SFR-010-004~SFR-010-007 조사사업 통합 검증 계획
## 1. 문서 목적
- 조사사업 프론트엔드의 단계별 완료 기준과 최종 FE/BE 통합 검증 순서를 관리한다.
## 2. 검증 범위
- FE-0: 처리분구 조회·추가·수정·삭제대기·일괄저장
- FE-1: `SFR-010-004` 사업등록·파일검토·PPL 집계/상세
- FE-2: `SFR-010-005` 조사현황·지도 진입
- FE-3: `SFR-010-006` MNHL 집계·상세
- FE-4: `SFR-010-007` GIS 레이어·등급 필터·시설 상세
## 3. 공통 검증 기준
- 변경 파일 TypeScript 오류가 없어야 한다.
- 개발 서버 실행 여부 확인 후 안전한 경우에만 프로덕션 빌드를 수행한다.
- API 요청은 설계한 URL·query·body와 일치해야 한다.
- 401, 403, 404, 409, 422, 500 오류가 성공처럼 처리되지 않아야 한다.
- 다음 단계는 현재 단계 구현과 검증 결과를 기록한 뒤 시작한다.
## 4. FE-0 검증 계획
| 구분 | 시나리오 | 기대 결과 |
| --- | --- | --- |
| 조회 | 전체/시도/시군구 조건 조회 | `useYn=Y`와 선택된 이름만 query에 포함 |
| 추가 | 시도·시군구 미선택/선택 후 추가 | 미선택 차단, 선택 시 행정구역 자동 입력 |
| 수정 | 미선택/삭제대기/정상 행 수정 | 잘못된 대상 차단, 처리분구명만 변경 |
| 중복 | 같은 행정구역에 동일 이름 입력 | 저장 전 오류 표시 |
| 삭제 | 신규/미참조/참조 중 기존 행 삭제 | 로컬 제거/삭제대기/참조 오류 표시 |
| 저장 | 변경 없음/변경 있음/서버 실패 | 차단/변경만 전송/상태와 오류 유지 |
| 재조회 | 미저장 변경이 있는 상태 | 폐기 확인 후 조회 |
| 정렬 | 각 헤더를 세 번 선택 | 오름차순 → 내림차순 → 기본 순서로 순환하고 선택 행 유지 |
| 검색조건 | 시도·시군구 필터 표시 | 두 셀렉트가 겹치거나 눌리지 않고 일정 간격 유지 |
| 버튼 배치 | 조회와 저장 버튼 우측 끝 비교 | 두 버튼의 우측 여백이 같은 기준선에 위치 |
| 버튼 색상 | 조회·수정·추가·삭제·저장 확인 | 모든 주요 동작 버튼 글자가 흰색으로 표시 |
## 5. 자동·수동 검증 경계
- 자동: TypeScript, `git diff --check`, Next 빌드
- 수동: `/oper/trtdst` 진입, 헤더 정렬, 선택 행 유지, 검색조건 간격, 버튼 우측 정렬·글자색, 모달 입력, 행 상태 색, 메시지 표시
- 통합: 실제 처리분구 API와 409 참조 제약, 저장 후 사업등록 후보 즉시 갱신
- 백엔드 미구현 동안 통합 항목은 보류로 기록하고 성공 데이터 목업으로 대체하지 않는다.
## 6. 다음 검증 순서
1. FE-0 화면·UI 사용자 검증 완료 상태 유지
2. FE-1 구현 후 사업등록 후보 연계의 FE 계약 확인
3. 프론트 전체 완료 후 백엔드 단계 진행
4. 백엔드 구현 후 FE-0 실 API와 각 SFR API·브라우저·네트워크 통합 검증
5. 프로덕션 빌드는 개발 서버 종료와 실행 허용을 확인한 시점에 수행
## 7. 흡수한 기존 문서
- 없음
@@ -0,0 +1,61 @@
# SFR-010-004~SFR-010-007 조사사업 통합 검증 결과
## 1. 문서 목적
- 구현된 단계의 실제 검증 결과와 차단 사유를 기능 완료와 구분해 누적한다.
## 2. 수행한 검증
| 날짜 | 검증 | 결과 |
| --- | --- | --- |
| 2026-07-22 | `git diff --check` | 통과, 공백 오류 없음 |
| 2026-07-22 | `tsc --noEmit` 기본 실행 | 저장소 기존 `ignoreDeprecations=6.0` 값 오류 확인 |
| 2026-07-22 | `tsc --noEmit --ignoreDeprecations 5.0` | 신규 `trtdst` 파일 오류 0건, 저장소 선행 오류 존재 |
| 2026-07-22 | Next 프로덕션 빌드 | 실행 권한 승인 거절로 미수행 |
| 2026-07-22 | 브라우저/API 통합 | 백엔드·메뉴 원천 미구현으로 미수행 |
| 2026-07-22 | FE-0 import 경계 정적 확인 | 라우트가 client UI 파일을 직접 import하고 대상 파일 존재·`git diff --check` 통과 |
| 2026-07-22 | FE-0 Next 개발 서버 재컴파일 | 실행 권한이 허용되지 않아 미수행, 사용자 재진입 확인 대기 |
| 2026-07-22 | FE-0 메뉴 진입 | 사용자 화면 확인으로 Next Hook 경계 오류 해소와 화면 렌더링 통과 |
| 2026-07-22 | FE-0 UI 마무리 정적 확인 | 공통 정렬 props·정렬 표시 목록 기반 선택·12열 필터 폭·버튼 공통 클래스 확인, UTF-8·`git diff --check` 통과 |
| 2026-07-22 | FE-0 UI 마무리 브라우저 확인 | 3002 개발 서버 미실행으로 사용자 재확인 대기 |
| 2026-07-22 | FE-0 UI 1차 사용자 재확인 | 시도·시군구 간격 미적용, 조회 버튼이 저장 버튼보다 약 `0.6rem` 왼쪽임을 캡처로 확인 |
| 2026-07-22 | FE-0 UI 2차 정적 확인 | `.next` 생성 CSS에 `gap-10`·`col-span-12`가 없음을 확인하고 인라인 `2rem` 간격·고정 폭·조회 버튼 `0.6rem` 이동 적용, UTF-8·공백 검사 통과 |
| 2026-07-22 | FE-0 최종 사용자 확인 | 처리분구관리 화면과 2차 UI 보정이 정상임을 사용자가 확인, 화면 단계 완료 판정 |
## 3. FE-0 검증 결과
- 신규 라우트와 feature 파일은 TypeScript 검사 출력에 오류가 없었다.
- 일괄 저장 변환은 `added → CREATE`, `modified → UPDATE`, `deletePending → DELETE`로 구현됐다.
- 활성 행 전체의 빈 이름과 행정구역별 중복 검증이 저장 전에 실행된다.
- 기존 행 삭제는 참조 API 성공과 참조 건수 0을 확인해야 삭제대기로 전환된다.
- API 실패 시 목록 또는 변경 상태를 성공으로 위장하지 않고 오류 메시지를 표시한다.
- 서버 라우트의 feature 배럴 import를 client UI 직접 import로 교체해 Hook 포함 API 유틸이 서버 import 그래프에 노출되지 않게 했다.
- 공통 `UiTable`의 오름차순·내림차순·기본 순환 상태를 사용하고 표시 목록 기준으로 선택 키를 유지하도록 구현했다.
- 생성 CSS 누락 영향을 받지 않도록 검색조건 필터의 전체 열·간격·폭을 인라인 값으로 고정하고, 조회 버튼을 캡처 차이만큼 이동했다.
- 주요 버튼의 흰색 글자 스타일을 통일했다.
## 4. 선행 오류와 차단 사유
- 저장소 전체 TypeScript에는 지도 컴포넌트 타입, 좌표 튜플, `@common/auth`, `uuid`, 잘못된 alias 등 기존 오류가 남아 있다.
- 이번 변경이 있는 `app/components/lib/api/index.ts`도 기존 `@common/auth` 해석 오류 때문에 전체 검사를 완전 통과하지 못한다.
- 프로덕션 빌드는 의존성 실행에 필요한 권한 승인이 거절되어 결과를 확보하지 못했다. 기능 실패로 판정하지 않는다.
- 변경 파일 단독 TSX 변환 검사는 루트에서 `typescript` 모듈을 직접 찾을 수 없어 수행하지 못했으며, 기존 TypeScript 검사 결과와 소스 정적 확인으로 보완했다.
## 5. 현재 검증 상태
- FE-0 코드 정적 검증: 부분 통과
- FE-0 프로덕션 빌드: 미수행
- FE-0 화면 렌더링·최종 UI: 사용자 확인 통과, 화면 단계 완료
- FE-0 실 API: 백엔드 구현 대기
- FE-1~FE-4: 미착수
## 6. 보류/리스크
- 백엔드 응답 필드와 `409` 오류 body를 구현 후 대조해야 한다.
- 메뉴 원천 등록과 화면 진입은 해결됐지만 최종 권한별 노출·직접 URL 접근 제어는 통합 검증에서 확인해야 한다.
- FE-0 화면·UI는 사용자 확인으로 완료됐지만 CRUD 실 API의 성공·오류·409 처리는 백엔드 구현 후 확인해야 한다.
- 프로덕션 빌드는 다음 프론트 단계 시작 전 또는 사용자가 허용한 시점에 다시 실행한다.
## 7. 흡수한 기존 문서
- 없음
@@ -0,0 +1,70 @@
# SFR-010-004~SFR-010-007 조사사업 FE 작업카드
## 1. 작업 개요
- 작업 ID: `SFR-010-004~007-EXMN-NEW-FE-FIRST`
- 작업명: CCTV·맨홀 조사사업 프론트엔드 선행 구현
- 상태: 진행(`FE-0` 사용자 검증 완료, `FE-1` 시작 대기)
- 작성일: 2026-07-22
- 담당 역할: FE
- 관련 역할: BE, QA
- 범위: `apps/sn/**`
- 근거: 공통 작업지시와 통합 설계, 조사사업 요구사항명세서
## 2. 공통 동기화 확인
- 공통 작업현황판·파일점유현황·결정로그 확인 완료
- `FE-0` 대상 파일만 점유 후 구현했다.
- 구현 순서는 `FE-0 → FE-1(004) → FE-2(005) → FE-3(006) → FE-4(007)`이며 프론트 전체 완료 후 백엔드로 이동한다.
## 3. 단계별 범위
| 단계 | 범위 | 현재 상태 | 다음 단계 진입 조건 |
| --- | --- | --- | --- |
| FE-0 | 공통 타입·API, 처리분구관리 `/oper/trtdst` | 완료(사용자 화면·UI 검증 통과) | 충족 |
| FE-1 | `SFR-010-004` 사업등록·파일검토·PPL 집계/상세 | 시작 대기 | 사용자 진행 지시 |
| FE-2 | `SFR-010-005` 조사현황·지도 진입 | 대기 | FE-1 완료 |
| FE-3 | `SFR-010-006` MNHL 집계·상세 | 대기 | FE-2 완료 |
| FE-4 | `SFR-010-007` GIS 레이어·등급 필터·상세 | 대기 | FE-3 완료 |
## 4. FE-0 수정 대상
| 구분 | 경로 | 결과 |
| --- | --- | --- |
| 라우트 | `app/(with-base-layout)/oper/trtdst/page.tsx` | 신규 |
| 화면 | `src/features/exmn/ui/TrtdstManagementPage.tsx` | 신규 |
| 타입 | `src/features/exmn/model/trtdst-types.ts` | 신규 |
| API | `src/features/exmn/api/trtdst-api.ts` | 신규 |
| 검증 | `src/features/exmn/lib/trtdst-validation.ts` | 신규 |
| 공통 API 유틸 | `app/components/lib/api/index.ts` | `putData` 추가 |
| 공통 비즈니스 컴포넌트 | 변경 없음 | 기존 `SearchDiv`, `SelectBox`, `UiTable`, `UiModal` 재사용 |
## 5. FE-0 화면 흐름
1. `/oper/trtdst`에서 시도·시군구 조건을 선택하고 조회한다.
2. 조회 결과에서 행을 선택해 처리분구명만 수정한다.
3. 시도·시군구를 모두 선택한 상태에서 신규 처리분구를 추가한다.
4. 신규 행은 즉시 제거할 수 있고, 기존 행은 참조 확인 후 삭제대기로 전환한다.
5. 저장 시 전체 활성 행의 필수값·행정구역별 중복을 검증한 뒤 변경 행만 일괄 전송한다.
6. 성공하면 서버 목록을 재조회하며 실패하면 미저장 행 상태와 오류 메시지를 유지한다.
## 6. API 계약
| 기능 | Method | URL | 요청/응답 핵심 | 상태 |
| --- | --- | --- | --- | --- |
| 목록 조회 | GET | `/api/v1/trtdstSettings` | `ctpvNm`, `sggNm`, `useYn=Y` / 활성 목록 | FE 구현 |
| 참조 확인 | GET | `/api/v1/trtdstSettings/{id}/references` | 사업·시설·전체 참조 건수 | FE 구현 |
| 일괄 저장 | PUT | `/api/v1/trtdstSettings/batch` | `items[].changeType=CREATE|UPDATE|DELETE` / 반영 건수 | FE 구현 |
## 7. 영향과 경계
- 신규 경로와 `features/exmn` 내부 모듈만 추가해 기존 업무 화면 동작은 변경하지 않는다.
- 기존 API 유틸에 공통 응답 래퍼를 해제하는 JSON PUT 함수가 추가된다.
- 메뉴 원천에 `TRTDST_MGMT`와 `/oper/trtdst`를 등록하는 배포 작업은 외부 메뉴 데이터 범위다.
- 백엔드 미구현 상태이므로 실 API 성공을 흉내 내는 목업은 두지 않았다.
## 8. 롤백
- 신규 `/oper/trtdst` 라우트와 `src/features/exmn`을 제거한다.
- `app/components/lib/api/index.ts`의 `putData` 추가분만 되돌린다.
- 기존 화면·공통 비즈니스 컴포넌트에는 롤백 대상 변경이 없다.
@@ -0,0 +1,106 @@
# SFR-010-004~SFR-010-007 조사사업 통합 구현 기록
## 1. 문서 목적
- 조사사업 프론트엔드 선행 구현을 단계별로 한 문서에 누적한다.
- 프론트 전체 완료와 백엔드 통합 완료를 구분해 기록한다.
## 2. 공통 계약
- 구현 순서: `FE-0 → FE-1 → FE-2 → FE-3 → FE-4 → BE-0 → BE-1 → BE-2 → BE-3 → BE-4`
- 처리분구관리 식별자/라우트: `TRTDST_MGMT` / `/oper/trtdst`
- 처리분구는 사업등록 화면에서 생성하지 않고 이 화면의 활성 후보를 선택한다.
- 런타임 목업과 하드코딩 성공 응답은 사용하지 않는다.
## 3. 구현 라운드 요약
| 날짜 | 단계 | 결과 |
| --- | --- | --- |
| 2026-07-22 | FE-0 | 처리분구 조회·추가·명칭수정·삭제대기·일괄저장 UI와 타입/API 계약 구현 |
| 2026-07-22 | FE-0 후속 | 메뉴 진입 시 발생한 Next 서버/클라이언트 import 경계 오류 수정 |
| 2026-07-22 | FE-0 화면 마무리 | 공통 UiTable 정렬과 검색조건·버튼 배치/색상 보완 |
| 2026-07-22 | FE-0 완료 | 2차 UI 보정 후 사용자 화면 검증 통과, FE-1 시작 대기 |
## 4. FE-0 처리분구관리
### 4.1 화면
- 기존 업무형 레이아웃 안에 `/oper/trtdst` 라우트를 추가했다.
- 공통 시도·시군구 코드와 `SelectBox`를 사용해 조회조건을 구성했다.
- `UiTable`에 NO, 시도, 시군구, 처리분구명, 변경상태를 표시한다.
- `UiModal`에서 신규 처리분구명 또는 선택 행의 처리분구명만 편집한다.
- 추가·수정·삭제대기 행은 색과 상태명으로 구분하며 삭제대기는 취소할 수 있다.
- API 오류와 성공 결과를 화면 안의 메시지 영역에 유지한다.
### 4.2 상태와 검증
- 행 상태는 `clean | added | modified | deletePending`이다.
- 추가는 시도·시군구 선택을 필수로 하며 현재 행정구역 이름을 자동 입력한다.
- 빈 처리분구명과 동일 행정구역 내 공백/대소문자 정규화 중복을 차단한다.
- 미저장 변경이 있는 상태에서 재조회하면 폐기 여부를 확인한다.
- 신규 행 삭제는 로컬 제거, 기존 행 삭제는 참조 확인 후 삭제대기로 처리한다.
### 4.3 API
- `getTrtdstSettings`: 활성 처리분구 조회, 배열과 Spring `content` 응답을 모두 수용한다.
- `getTrtdstReferences`: 조사사업·시설 참조 건수를 조회한다.
- `saveTrtdstSettings`: 변경 행만 `items[]`로 일괄 저장한다.
- 공통 API 유틸에 인증·사이트 헤더와 공통 오류 추출을 재사용하는 `putData`를 추가했다.
### 4.4 저장 요청 구조
```json
{
"items": [
{
"changeType": "CREATE",
"ctpvNm": "경기도",
"sggNm": "수원시",
"trtdstNm": "처리분구 A",
"useYn": "Y"
}
]
}
```
### 4.5 메뉴 진입 import 경계 보완
- 최초 라우트는 `@/features/exmn` 배럴을 import했고, 이 배럴이 UI뿐 아니라 Hook을 사용하는 기존 `app/components/lib/api/index.ts`까지 서버 컴포넌트 import 그래프에 포함했다.
- Next가 `useEffect`, `useState`를 서버 컴포넌트에서 import한 것으로 판단해 `/oper/trtdst` 메뉴 진입 시 빌드 오류가 발생했다.
- 라우트가 `@/features/exmn/ui/TrtdstManagementPage` 클라이언트 컴포넌트를 직접 import하도록 변경해 서버/클라이언트 경계를 명시했다.
- 기존 API 유틸과 feature 배럴의 공개 계약은 변경하지 않았다.
### 4.6 사용자 화면 확인 후 UI 마무리
- NO, 시도, 시군구, 처리분구명, 변경상태 헤더에 공통 `UiTable`의 `sort`·`onSortChange` 계약을 적용했다.
- 백엔드 정렬 파라미터가 확정되지 않은 FE-0 단계이므로 현재 조회된 활성 처리분구 전체 목록을 한글·숫자 자연순으로 클라이언트 정렬한다.
- 정렬 뒤에도 선택 행의 `localKey`를 기준으로 선택 인덱스를 다시 계산해 수정·삭제 대상이 바뀌지 않게 했다.
- 최초 보완의 `col-span-12`, `gap-10`은 신규 `src/features` 경로의 클래스가 생성 CSS에 포함되지 않아 사용자 화면에서 적용되지 않았다.
- 필터 묶음에 `gridColumn: 1 / -1`, `columnGap: 2rem`을 직접 지정하고 시도·시군구 래퍼 폭을 각각 `16rem`, `17rem`으로 고정해 Tailwind 스캔 여부와 무관하게 간격을 확보했다.
- 조회 버튼은 사용자 캡처에서 저장 버튼보다 약 `0.6rem` 왼쪽인 것을 기준으로 `translateX(0.6rem)`을 적용해 같은 우측 기준선으로 이동했다.
- 조회·수정·추가·삭제·저장 버튼의 배경/흰색 글자 규칙은 하나의 상수로 통일했다.
- 사용자가 최종 화면에서 시도·시군구 간격, 조회·저장 기준선과 처리분구관리 화면 구현 완료를 확인했다.
## 5. 기능별 현재 상태
- FE-0: 화면 구현·사용자 UI 검증 완료, 실 API 연동은 백엔드 구현 후 검증
- FE-1~FE-4: 미착수
- 백엔드: 사용자 지시대로 프론트 전체 완료 전 미착수
## 6. BE 공유 사항
- 조회 성공 데이터는 활성 처리분구 배열 또는 Spring `content` 형태를 FE가 수용한다.
- 참조 응답 필드는 `businessCount`, `facilityCount`, 선택적 `totalCount`다.
- 일괄 저장은 전 항목 단일 트랜잭션이어야 하며 참조 중 삭제는 `409`로 거부해야 한다.
- 저장 성공 응답은 `createdCount`, `updatedCount`, `deletedCount`를 제공한다.
## 7. 남은 리스크
- 실제 백엔드가 없어 성공·409·권한 오류의 네트워크 연동을 확인하지 못했다.
- 메뉴 원천·권한 및 import 경계 문제는 사용자 화면 진입 확인으로 해결됐다.
- 프로덕션 빌드와 실 API 동작은 아직 확인하지 않았으며 백엔드 구현·통합 단계에서 검증해야 한다.
- 저장 후 활성 후보가 사업등록 화면에 즉시 반영되는지는 FE-1과 백엔드 구현 후 확인한다.
## 8. 흡수한 기존 문서
- 없음
@@ -0,0 +1,37 @@
import { getData, putData } from "@/components/lib/api";
import type {
TrtdstBatchRequest,
TrtdstBatchResponse,
TrtdstReferenceSummary,
TrtdstSearchParams,
TrtdstSettingDto,
} from "../model/trtdst-types";
/** 조사사업 공통 선행 처리분구 API의 기준 경로다. */
const TRTDST_API_PATH = "v1/trtdstSettings";
/** 선택된 행정구역 조건으로 활성 처리분구 목록을 조회한다. */
export const getTrtdstSettings = async (
params: TrtdstSearchParams,
): Promise<TrtdstSettingDto[]> => {
const query = new URLSearchParams({ useYn: params.useYn });
if (params.ctpvNm?.trim()) query.set("ctpvNm", params.ctpvNm.trim());
if (params.sggNm?.trim()) query.set("sggNm", params.sggNm.trim());
const response = await getData(`${TRTDST_API_PATH}?${query.toString()}`);
if (Array.isArray(response)) return response as TrtdstSettingDto[];
if (Array.isArray(response?.content)) return response.content as TrtdstSettingDto[];
return [];
};
/** 처리분구 추가·명칭수정·삭제대기를 한 트랜잭션으로 저장한다. */
export const saveTrtdstSettings = async (
request: TrtdstBatchRequest,
): Promise<TrtdstBatchResponse> =>
putData(`${TRTDST_API_PATH}/batch`, request) as Promise<TrtdstBatchResponse>;
/** 삭제 전 처리분구를 참조하는 조사사업과 시설 건수를 확인한다. */
export const getTrtdstReferences = async (
trtdstId: string,
): Promise<TrtdstReferenceSummary> =>
getData(`${TRTDST_API_PATH}/${encodeURIComponent(trtdstId)}/references`) as Promise<TrtdstReferenceSummary>;
+4
View File
@@ -0,0 +1,4 @@
export * from "./api/trtdst-api";
export * from "./lib/trtdst-validation";
export * from "./model/trtdst-types";
export * from "./ui/TrtdstManagementPage";
@@ -0,0 +1,42 @@
import type {
TrtdstBatchItem,
TrtdstTableRow,
} from "../model/trtdst-types";
/** 처리분구명 비교 시 공백과 영문 대소문자 차이를 제거한다. */
const normalizeName = (value: string) => value.trim().toLocaleLowerCase("ko-KR");
/** 화면의 활성 행 전체에서 행정구역별 처리분구명 중복을 검사한다. */
export const validateTrtdstRows = (rows: TrtdstTableRow[]): string | null => {
const activeRows = rows.filter((row) => row.rowStatus !== "deletePending");
const seen = new Set<string>();
for (const row of activeRows) {
if (!row.trtdstNm.trim()) return "처리분구명을 입력해주세요.";
const duplicateKey = [row.ctpvNm.trim(), row.sggNm.trim(), normalizeName(row.trtdstNm)].join("|");
if (seen.has(duplicateKey)) {
return `${row.ctpvNm} ${row.sggNm}의 처리분구명 '${row.trtdstNm.trim()}'이 중복됩니다.`;
}
seen.add(duplicateKey);
}
return null;
};
/** 화면 변경 상태를 백엔드 일괄 저장 명령 배열로 변환한다. */
export const buildTrtdstBatchItems = (rows: TrtdstTableRow[]): TrtdstBatchItem[] =>
rows
.filter((row) => row.rowStatus !== "clean")
.map((row) => ({
changeType:
row.rowStatus === "added"
? "CREATE"
: row.rowStatus === "deletePending"
? "DELETE"
: "UPDATE",
trtdstId: row.rowStatus === "added" ? undefined : row.trtdstId,
ctpvNm: row.ctpvNm.trim(),
sggNm: row.sggNm.trim(),
trtdstNm: row.trtdstNm.trim(),
useYn: "Y",
}));
@@ -0,0 +1,68 @@
/** 처리분구 행이 화면에서 가진 변경 상태다. */
export type TrtdstRowStatus = "clean" | "added" | "modified" | "deletePending";
/** 서버가 반환하는 활성 처리분구 한 건의 계약이다. */
export interface TrtdstSettingDto {
trtdstId: string;
ctpvNm: string;
sggNm: string;
trtdstNm: string;
useYn?: "Y" | "N";
}
/** UiTable 선택과 변경 추적에 필요한 화면 전용 처리분구 행이다. */
export interface TrtdstTableRow extends TrtdstSettingDto {
localKey: string;
rowNumber: number;
rowStatus: TrtdstRowStatus;
rowStatusLabel: string;
originalTrtdstNm: string;
}
/** 처리분구 목록 조회 조건이다. 행정구역 이름은 선택된 코드에서 해석한다. */
export interface TrtdstSearchParams {
ctpvNm?: string;
sggNm?: string;
useYn: "Y";
}
/** 일괄 저장에서 처리할 변경 종류다. */
export type TrtdstChangeType = "CREATE" | "UPDATE" | "DELETE";
/** 처리분구 일괄 저장의 단일 변경 명령이다. */
export interface TrtdstBatchItem {
changeType: TrtdstChangeType;
trtdstId?: string;
ctpvNm: string;
sggNm: string;
trtdstNm: string;
useYn: "Y";
}
/** 처리분구 일괄 저장 요청 계약이다. */
export interface TrtdstBatchRequest {
items: TrtdstBatchItem[];
}
/** 처리분구 일괄 저장 결과의 반영 건수 계약이다. */
export interface TrtdstBatchResponse {
createdCount: number;
updatedCount: number;
deletedCount: number;
items?: TrtdstSettingDto[];
}
/** 처리분구를 참조하는 사업·시설 건수 계약이다. */
export interface TrtdstReferenceSummary {
businessCount: number;
facilityCount: number;
totalCount?: number;
}
/** 화면에서 일관되게 표시하는 행 상태 이름이다. */
export const TRTDST_ROW_STATUS_LABEL: Record<TrtdstRowStatus, string> = {
clean: "",
added: "추가",
modified: "수정",
deletePending: "삭제대기",
};
@@ -0,0 +1,516 @@
"use client";
import LoadingOverlay from "@/components/ui/LoadingBar";
import useStore from "@/store/useStore";
import { Button, Input, Label } from "@common/components/ui";
import {
SearchDiv,
SearchFormLeft,
SearchFormRight,
SelectBox,
UiModal,
UiTable,
} from "@common/business_components/ui";
import type { UiTableSort } from "@common/business_components/ui";
import ListDiv from "@common/business_components/ui/list_div";
import { useMemo, useState } from "react";
import {
getTrtdstReferences,
getTrtdstSettings,
saveTrtdstSettings,
} from "../api/trtdst-api";
import { buildTrtdstBatchItems, validateTrtdstRows } from "../lib/trtdst-validation";
import {
TRTDST_ROW_STATUS_LABEL,
type TrtdstSettingDto,
type TrtdstTableRow,
} from "../model/trtdst-types";
/** 처리분구 목록 표에 표시할 공통 열 정의다. */
const TRTDST_TABLE_HEAD = [
{ id: "rowNumber", title: "NO", widthPercent: 8, sortable: true },
{ id: "ctpvNm", title: "시도", widthPercent: 20, sortable: true },
{ id: "sggNm", title: "시군구", widthPercent: 20, sortable: true },
{ id: "trtdstNm", title: "처리분구명", widthPercent: 37, sortable: true },
{ id: "rowStatusLabel", title: "변경상태", widthPercent: 15, sortable: true },
];
/** 시도·시군구 선택박스에서 공통으로 사용하는 전체 옵션이다. */
const ALL_OPTION = { val: "0", text: "전체" };
/** 처리분구 한글·숫자 값을 화면 목록에서 자연스럽게 비교하는 정렬 기준이다. */
const TRTDST_COLLATOR = new Intl.Collator("ko", { numeric: true, sensitivity: "base" });
/** 처리분구 화면의 주요 동작 버튼에 공통으로 적용하는 시각 규칙이다. */
const PRIMARY_ACTION_BUTTON_CLASS =
"rounded-sm bg-blue-600 px-4 py-2 text-sm font-medium !text-white hover:bg-blue-700 disabled:!text-white";
/** 추가 또는 수정 모달의 현재 동작을 구분한다. */
type EditorMode = "add" | "edit";
/** 화면 상단에 노출하는 성공·오류 메시지 상태다. */
type Notice = { type: "success" | "error"; text: string } | null;
/** 브라우저와 SSR 양쪽에서 충돌 가능성이 낮은 로컬 행 키를 만든다. */
const createLocalKey = () =>
typeof crypto !== "undefined" && "randomUUID" in crypto
? `client:${crypto.randomUUID()}`
: `client:${Date.now()}-${Math.random().toString(36).slice(2)}`;
/** 서버 처리분구 목록을 UiTable과 변경 추적에 필요한 행 구조로 변환한다. */
const toTableRows = (items: TrtdstSettingDto[]): TrtdstTableRow[] =>
items.map((item, index) => ({
...item,
trtdstId: String(item.trtdstId),
ctpvNm: String(item.ctpvNm ?? "").trim(),
sggNm: String(item.sggNm ?? "").trim(),
trtdstNm: String(item.trtdstNm ?? "").trim(),
useYn: item.useYn ?? "Y",
localKey: `server:${item.trtdstId}`,
rowNumber: index + 1,
rowStatus: "clean",
rowStatusLabel: TRTDST_ROW_STATUS_LABEL.clean,
originalTrtdstNm: String(item.trtdstNm ?? "").trim(),
}));
/** 알 수 없는 API 오류에서도 서버 메시지 또는 안전한 기본 문구를 반환한다. */
const getErrorMessage = (error: unknown, fallback: string) =>
error instanceof Error && error.message.trim() ? error.message : fallback;
/** 사업등록 후보로 사용할 처리분구 기준정보를 직접 관리하는 FE-0 화면이다. */
export function TrtdstManagementPage() {
const { ctpv, sgg } = useStore();
// 조회와 추가 대상 행정구역 코드 상태다. 이름은 공통 코드 목록에서 해석해 API에 전달한다.
const [ctpvCd, setCtpvCd] = useState("0");
const [sggCd, setSggCd] = useState("0");
// 서버 조회 결과와 사용자의 미저장 변경을 함께 보관한다.
const [rows, setRows] = useState<TrtdstTableRow[]>([]);
// 공통 UiTable 헤더의 asc·desc·기본 순환 상태를 현재 조회 결과에 적용한다.
const [sort, setSort] = useState<UiTableSort>();
// UiTable 선택 행은 저장·재조회 중에도 로컬 키 기준으로 명확히 추적한다.
const [selectedRowKey, setSelectedRowKey] = useState<string | null>(null);
// 조회·삭제 참조 확인·저장 요청 중 화면의 중복 조작을 막는다.
const [isLoading, setIsLoading] = useState(false);
// API 실패와 저장 성공 결과를 화면 안에서 유지해 사용자가 원인을 확인할 수 있게 한다.
const [notice, setNotice] = useState<Notice>(null);
// 처리분구명 추가·수정을 동일한 모달로 제공하기 위한 편집 상태다.
const [editorMode, setEditorMode] = useState<EditorMode | null>(null);
const [editorName, setEditorName] = useState("");
/** 현재 시도에 속한 시군구만 선택 후보로 제공한다. */
const availableSgg = useMemo(() => {
if (ctpvCd === "0") return sgg;
return sgg.filter((option) => String(option.val).startsWith(ctpvCd.slice(0, 2)));
}, [ctpvCd, sgg]);
/** 현재 선택 코드에 해당하는 행정구역 이름을 API 계약용으로 해석한다. */
const selectedRegion = useMemo(() => ({
ctpvNm: ctpv.find((option) => String(option.val) === ctpvCd)?.text ?? "",
sggNm: sgg.find((option) => String(option.val) === sggCd)?.text ?? "",
}), [ctpv, ctpvCd, sgg, sggCd]);
/** 선택된 로컬 키와 일치하는 화면 행을 반환한다. */
const selectedRow = useMemo(
() => rows.find((row) => row.localKey === selectedRowKey) ?? null,
[rows, selectedRowKey],
);
/** 백엔드 정렬 계약 전까지 현재 조회된 전체 처리분구 목록을 공통 UiTable 상태대로 정렬한다. */
const displayedRows = useMemo(() => {
if (!sort) return rows;
return [...rows].sort((left, right) => {
const leftValue = left[sort.key as keyof TrtdstTableRow];
const rightValue = right[sort.key as keyof TrtdstTableRow];
const comparison = sort.key === "rowNumber"
? Number(leftValue ?? 0) - Number(rightValue ?? 0)
: TRTDST_COLLATOR.compare(String(leftValue ?? ""), String(rightValue ?? ""));
if (comparison === 0) return left.rowNumber - right.rowNumber;
return sort.direction === "asc" ? comparison : -comparison;
});
}, [rows, sort]);
/** UiTable의 제어 선택 인덱스를 로컬 키에서 계산한다. */
const selectedRowIndex = useMemo(
() => displayedRows.findIndex((row) => row.localKey === selectedRowKey),
[displayedRows, selectedRowKey],
);
/** 미저장 추가·수정·삭제대기가 있는지 계산한다. */
const hasChanges = useMemo(
() => rows.some((row) => row.rowStatus !== "clean"),
[rows],
);
/** 현재 조회조건으로 서버 목록을 읽고 화면 변경 상태를 초기화한다. */
const fetchRows = async () => {
setIsLoading(true);
setNotice(null);
try {
const items = await getTrtdstSettings({
ctpvNm: selectedRegion.ctpvNm || undefined,
sggNm: selectedRegion.sggNm || undefined,
useYn: "Y",
});
setRows(toTableRows(items));
setSelectedRowKey(null);
} catch (error) {
setRows([]);
setSelectedRowKey(null);
setNotice({
type: "error",
text: getErrorMessage(error, "처리분구 목록 조회 중 오류가 발생했습니다."),
});
} finally {
setIsLoading(false);
}
};
/** 조회 전 미저장 변경 유실 여부를 확인한 뒤 목록을 다시 읽는다. */
const handleSearch = async () => {
if (hasChanges && !window.confirm("저장하지 않은 변경사항이 있습니다. 버리고 다시 조회하시겠습니까?")) {
return;
}
await fetchRows();
};
/** 선택 행 또는 현재 행정구역을 기준으로 처리분구명 편집 모달을 연다. */
const openEditor = (mode: EditorMode) => {
setNotice(null);
if (mode === "add") {
if (!selectedRegion.ctpvNm || !selectedRegion.sggNm) {
setNotice({ type: "error", text: "추가할 시도와 시군구를 먼저 선택해주세요." });
return;
}
setEditorName("");
setEditorMode("add");
return;
}
if (!selectedRow) {
setNotice({ type: "error", text: "수정할 처리분구를 선택해주세요." });
return;
}
if (selectedRow.rowStatus === "deletePending") {
setNotice({ type: "error", text: "삭제대기 행은 삭제취소 후 수정할 수 있습니다." });
return;
}
setEditorName(selectedRow.trtdstNm);
setEditorMode("edit");
};
/** 모달의 처리분구명을 화면 행에 반영하고 중복 입력을 차단한다. */
const applyEditor = () => {
const nextName = editorName.trim();
if (!nextName) {
setNotice({ type: "error", text: "처리분구명을 입력해주세요." });
return;
}
const targetRegion = editorMode === "edit" && selectedRow
? { ctpvNm: selectedRow.ctpvNm, sggNm: selectedRow.sggNm }
: selectedRegion;
const hasDuplicate = rows.some((row) =>
row.rowStatus !== "deletePending"
&& row.localKey !== selectedRow?.localKey
&& row.ctpvNm === targetRegion.ctpvNm
&& row.sggNm === targetRegion.sggNm
&& row.trtdstNm.trim().toLocaleLowerCase("ko-KR") === nextName.toLocaleLowerCase("ko-KR"));
if (hasDuplicate) {
setNotice({ type: "error", text: "같은 행정구역에 동일한 처리분구명이 있습니다." });
return;
}
if (editorMode === "add") {
const localKey = createLocalKey();
setRows((current) => [
...current,
{
trtdstId: "",
ctpvNm: targetRegion.ctpvNm,
sggNm: targetRegion.sggNm,
trtdstNm: nextName,
useYn: "Y",
localKey,
rowNumber: current.length + 1,
rowStatus: "added",
rowStatusLabel: TRTDST_ROW_STATUS_LABEL.added,
originalTrtdstNm: "",
},
]);
setSelectedRowKey(localKey);
} else if (selectedRow) {
setRows((current) => current.map((row) => {
if (row.localKey !== selectedRow.localKey) return row;
const rowStatus = row.rowStatus === "added"
? "added"
: nextName === row.originalTrtdstNm
? "clean"
: "modified";
return {
...row,
trtdstNm: nextName,
rowStatus,
rowStatusLabel: TRTDST_ROW_STATUS_LABEL[rowStatus],
};
}));
}
setEditorMode(null);
setEditorName("");
setNotice(null);
};
/** 신규 행은 즉시 제거하고 기존 행은 참조 확인 후 삭제대기 또는 삭제취소로 전환한다. */
const handleDelete = async () => {
setNotice(null);
if (!selectedRow) {
setNotice({ type: "error", text: "삭제할 처리분구를 선택해주세요." });
return;
}
if (selectedRow.rowStatus === "added") {
setRows((current) => current
.filter((row) => row.localKey !== selectedRow.localKey)
.map((row, index) => ({ ...row, rowNumber: index + 1 })));
setSelectedRowKey(null);
return;
}
if (selectedRow.rowStatus === "deletePending") {
const restoredStatus = selectedRow.trtdstNm === selectedRow.originalTrtdstNm ? "clean" : "modified";
setRows((current) => current.map((row) => row.localKey === selectedRow.localKey
? {
...row,
rowStatus: restoredStatus,
rowStatusLabel: TRTDST_ROW_STATUS_LABEL[restoredStatus],
}
: row));
return;
}
setIsLoading(true);
try {
const references = await getTrtdstReferences(selectedRow.trtdstId);
const businessCount = Number(references?.businessCount ?? 0);
const facilityCount = Number(references?.facilityCount ?? 0);
const totalCount = Number(references?.totalCount ?? businessCount + facilityCount);
if (totalCount > 0) {
setNotice({
type: "error",
text: `조사사업 ${businessCount}건, 조사시설 ${facilityCount}건이 참조 중이어서 삭제할 수 없습니다.`,
});
return;
}
setRows((current) => current.map((row) => row.localKey === selectedRow.localKey
? { ...row, rowStatus: "deletePending", rowStatusLabel: TRTDST_ROW_STATUS_LABEL.deletePending }
: row));
} catch (error) {
setNotice({
type: "error",
text: getErrorMessage(error, "처리분구 참조 여부 확인 중 오류가 발생했습니다."),
});
} finally {
setIsLoading(false);
}
};
/** 화면 전체를 검증한 뒤 변경 행만 일괄 저장하고 성공 시 서버 목록으로 동기화한다. */
const handleSave = async () => {
setNotice(null);
const validationMessage = validateTrtdstRows(rows);
if (validationMessage) {
setNotice({ type: "error", text: validationMessage });
return;
}
const items = buildTrtdstBatchItems(rows);
if (items.length === 0) {
setNotice({ type: "error", text: "저장할 변경사항이 없습니다." });
return;
}
setIsLoading(true);
try {
const result = await saveTrtdstSettings({ items });
const summary = `추가 ${Number(result?.createdCount ?? 0)}건, 수정 ${Number(result?.updatedCount ?? 0)}건, 삭제 ${Number(result?.deletedCount ?? 0)}건이 반영되었습니다.`;
try {
const refreshed = await getTrtdstSettings({
ctpvNm: selectedRegion.ctpvNm || undefined,
sggNm: selectedRegion.sggNm || undefined,
useYn: "Y",
});
setRows(toTableRows(refreshed));
setSelectedRowKey(null);
setNotice({ type: "success", text: summary });
} catch (refreshError) {
setNotice({
type: "error",
text: `${summary} 목록 재조회는 실패했습니다: ${getErrorMessage(refreshError, "재조회 오류")}`,
});
}
} catch (error) {
setNotice({
type: "error",
text: getErrorMessage(error, "처리분구 일괄 저장 중 오류가 발생했습니다."),
});
} finally {
setIsLoading(false);
}
};
/** 모달 하단에서 저장 또는 취소를 선택하는 공통 버튼 영역이다. */
const editorFooter = (
<div className="flex w-full justify-end gap-2">
<Button variant="outline" onClick={() => setEditorMode(null)}>취소</Button>
<Button className="bg-blue-600 text-white hover:bg-blue-700" onClick={applyEditor}>
확인
</Button>
</div>
);
return (
<>
<LoadingOverlay isLoading={isLoading} />
<SearchDiv>
<SearchFormLeft>
<div
style={{
gridColumn: "1 / -1",
display: "flex",
alignItems: "center",
columnGap: "2rem",
}}
>
<div style={{ width: "16rem", flex: "0 0 16rem" }}>
<SelectBox
label="시도"
labelClass="w-16 whitespace-nowrap text-xl text-gray-600"
className=""
selectClass="w-48 rounded-lg bg-white"
selectArray={[ALL_OPTION, ...ctpv]}
value={ctpvCd}
onSelectValue={(value) => {
setCtpvCd(String(value));
setSggCd("0");
}}
/>
</div>
<div style={{ width: "17rem", flex: "0 0 17rem" }}>
<SelectBox
label="시군구"
labelClass="w-20 whitespace-nowrap text-xl text-gray-600"
className=""
selectClass="w-48 rounded-lg bg-white"
selectArray={[ALL_OPTION, ...availableSgg]}
value={sggCd}
onSelectValue={(value) => setSggCd(String(value))}
/>
</div>
</div>
</SearchFormLeft>
<SearchFormRight className="ml-auto flex shrink-0 items-center justify-end px-4">
<Button
className={PRIMARY_ACTION_BUTTON_CLASS}
style={{ transform: "translateX(0.6rem)" }}
size="sm"
disabled={isLoading}
onClick={() => void handleSearch()}
>
조회
</Button>
</SearchFormRight>
</SearchDiv>
{notice && (
<div
role="alert"
className={`mx-[1.2rem] mt-4 rounded border px-4 py-3 text-sm ${
notice.type === "error"
? "border-red-200 bg-red-50 text-red-700"
: "border-emerald-200 bg-emerald-50 text-emerald-700"
}`}
>
{notice.text}
</div>
)}
<ListDiv className="mt-4">
<div className="card-header flex items-center justify-between">
<h3 className="text-2xl font-medium">처리분구 목록 <span className="text-base text-gray-500">총 {rows.length.toLocaleString("ko-KR")}건</span></h3>
<div className="flex items-center gap-2">
<Button className={PRIMARY_ACTION_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={() => openEditor("edit")}>수정</Button>
<Button className={PRIMARY_ACTION_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={() => openEditor("add")}>추가</Button>
<Button className={PRIMARY_ACTION_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={() => void handleDelete()}>
{selectedRow?.rowStatus === "deletePending" ? "삭제취소" : "삭제"}
</Button>
<Button
className={PRIMARY_ACTION_BUTTON_CLASS}
size="sm"
disabled={isLoading || !hasChanges}
onClick={() => void handleSave()}
>
저장
</Button>
</div>
</div>
<UiTable<TrtdstTableRow>
headName=""
headlist={TRTDST_TABLE_HEAD}
tableData={displayedRows}
total={rows.length}
pageSize={Math.max(displayedRows.length, 1)}
curPage={1}
index={selectedRowIndex}
widthFixed
cellClick={(index) => setSelectedRowKey(index === undefined ? null : displayedRows[index]?.localKey ?? null)}
onPageChange={() => undefined}
sort={sort}
onSortChange={setSort}
rowClassName={(row) => row.rowStatus === "deletePending"
? "!bg-red-50 text-gray-400 line-through"
: row.rowStatus === "added"
? "!bg-blue-50"
: row.rowStatus === "modified"
? "!bg-amber-50"
: ""}
/>
</ListDiv>
<UiModal
open={editorMode !== null}
onOpenChange={(open) => {
if (!open) setEditorMode(null);
}}
bodyClass="max-w-lg"
headerTitle={editorMode === "add" ? "처리분구 추가" : "처리분구명 수정"}
headerTitleClass="text-xl font-semibold text-gray-800"
footerContent={editorFooter}
body={(
<div className="space-y-4 py-3">
<div className="rounded bg-gray-50 px-4 py-3 text-sm text-gray-600">
{editorMode === "edit" && selectedRow
? `${selectedRow.ctpvNm} ${selectedRow.sggNm}`
: `${selectedRegion.ctpvNm} ${selectedRegion.sggNm}`}
</div>
<div className="flex items-center gap-3">
<Label htmlFor="trtdst-name" className="w-24 text-base">처리분구명</Label>
<Input
id="trtdst-name"
value={editorName}
maxLength={100}
autoFocus
onChange={(event) => setEditorName(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
applyEditor();
}
}}
/>
</div>
</div>
)}
/>
</>
);
}