처리분구관리 화면 구현
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
import { TrtdstManagementPage } from "@/features/exmn/ui/TrtdstManagementPage";
|
||||
|
||||
/** 처리분구관리 업무 화면 라우트다. */
|
||||
export default function Page() {
|
||||
return <TrtdstManagementPage />;
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user