diff --git a/apps/sn/app/(with-base-layout)/oper/trtdst/page.tsx b/apps/sn/app/(with-base-layout)/oper/trtdst/page.tsx new file mode 100644 index 000000000..57b0fc935 --- /dev/null +++ b/apps/sn/app/(with-base-layout)/oper/trtdst/page.tsx @@ -0,0 +1,6 @@ +import { TrtdstManagementPage } from "@/features/exmn/ui/TrtdstManagementPage"; + +/** 처리분구관리 업무 화면 라우트다. */ +export default function Page() { + return ; +} diff --git a/apps/sn/app/components/lib/api/index.ts b/apps/sn/app/components/lib/api/index.ts index ed59ba687..250a2b4cb 100644 --- a/apps/sn/app/components/lib/api/index.ts +++ b/apps/sn/app/components/lib/api/index.ts @@ -174,6 +174,43 @@ export const postRawJson = async (url: string, data: object): Promise => { return body; }; +/** 공통 응답 래퍼를 사용하는 JSON PUT 요청을 전송하고 data 필드만 반환한다. */ +export const putData = async (url: string, data: object): Promise => { + 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; diff --git a/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/001-qa-SFR-010-004~007-조사사업-통합검증계획.md b/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/001-qa-SFR-010-004~007-조사사업-통합검증계획.md new file mode 100644 index 000000000..53ce2d632 --- /dev/null +++ b/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/001-qa-SFR-010-004~007-조사사업-통합검증계획.md @@ -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. 흡수한 기존 문서 + +- 없음 diff --git a/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md b/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md new file mode 100644 index 000000000..18687bc4e --- /dev/null +++ b/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md @@ -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. 흡수한 기존 문서 + +- 없음 diff --git a/apps/sn/docs/작업계획/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-작업카드.md b/apps/sn/docs/작업계획/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-작업카드.md new file mode 100644 index 000000000..b053bd50a --- /dev/null +++ b/apps/sn/docs/작업계획/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-작업카드.md @@ -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` 추가분만 되돌린다. +- 기존 화면·공통 비즈니스 컴포넌트에는 롤백 대상 변경이 없다. diff --git a/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md b/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md new file mode 100644 index 000000000..ec16b3117 --- /dev/null +++ b/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md @@ -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. 흡수한 기존 문서 + +- 없음 diff --git a/apps/sn/src/features/exmn/api/trtdst-api.ts b/apps/sn/src/features/exmn/api/trtdst-api.ts new file mode 100644 index 000000000..3088cdefd --- /dev/null +++ b/apps/sn/src/features/exmn/api/trtdst-api.ts @@ -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 => { + 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 => + putData(`${TRTDST_API_PATH}/batch`, request) as Promise; + +/** 삭제 전 처리분구를 참조하는 조사사업과 시설 건수를 확인한다. */ +export const getTrtdstReferences = async ( + trtdstId: string, +): Promise => + getData(`${TRTDST_API_PATH}/${encodeURIComponent(trtdstId)}/references`) as Promise; diff --git a/apps/sn/src/features/exmn/index.ts b/apps/sn/src/features/exmn/index.ts new file mode 100644 index 000000000..2c9afb145 --- /dev/null +++ b/apps/sn/src/features/exmn/index.ts @@ -0,0 +1,4 @@ +export * from "./api/trtdst-api"; +export * from "./lib/trtdst-validation"; +export * from "./model/trtdst-types"; +export * from "./ui/TrtdstManagementPage"; diff --git a/apps/sn/src/features/exmn/lib/trtdst-validation.ts b/apps/sn/src/features/exmn/lib/trtdst-validation.ts new file mode 100644 index 000000000..f78d8e426 --- /dev/null +++ b/apps/sn/src/features/exmn/lib/trtdst-validation.ts @@ -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(); + + 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", + })); diff --git a/apps/sn/src/features/exmn/model/trtdst-types.ts b/apps/sn/src/features/exmn/model/trtdst-types.ts new file mode 100644 index 000000000..434adfdd2 --- /dev/null +++ b/apps/sn/src/features/exmn/model/trtdst-types.ts @@ -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 = { + clean: "", + added: "추가", + modified: "수정", + deletePending: "삭제대기", +}; diff --git a/apps/sn/src/features/exmn/ui/TrtdstManagementPage.tsx b/apps/sn/src/features/exmn/ui/TrtdstManagementPage.tsx new file mode 100644 index 000000000..c50585cc3 --- /dev/null +++ b/apps/sn/src/features/exmn/ui/TrtdstManagementPage.tsx @@ -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([]); + // 공통 UiTable 헤더의 asc·desc·기본 순환 상태를 현재 조회 결과에 적용한다. + const [sort, setSort] = useState(); + // UiTable 선택 행은 저장·재조회 중에도 로컬 키 기준으로 명확히 추적한다. + const [selectedRowKey, setSelectedRowKey] = useState(null); + // 조회·삭제 참조 확인·저장 요청 중 화면의 중복 조작을 막는다. + const [isLoading, setIsLoading] = useState(false); + // API 실패와 저장 성공 결과를 화면 안에서 유지해 사용자가 원인을 확인할 수 있게 한다. + const [notice, setNotice] = useState(null); + // 처리분구명 추가·수정을 동일한 모달로 제공하기 위한 편집 상태다. + const [editorMode, setEditorMode] = useState(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 = ( +
+ + +
+ ); + + return ( + <> + + + +
+
+ { + setCtpvCd(String(value)); + setSggCd("0"); + }} + /> +
+
+ setSggCd(String(value))} + /> +
+
+
+ + + +
+ + {notice && ( +
+ {notice.text} +
+ )} + + +
+

처리분구 목록 총 {rows.length.toLocaleString("ko-KR")}건

+
+ + + + +
+
+ + 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" + : ""} + /> +
+ + { + if (!open) setEditorMode(null); + }} + bodyClass="max-w-lg" + headerTitle={editorMode === "add" ? "처리분구 추가" : "처리분구명 수정"} + headerTitleClass="text-xl font-semibold text-gray-800" + footerContent={editorFooter} + body={( +
+
+ {editorMode === "edit" && selectedRow + ? `${selectedRow.ctpvNm} ${selectedRow.sggNm}` + : `${selectedRegion.ctpvNm} ${selectedRegion.sggNm}`} +
+
+ + setEditorName(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + applyEditor(); + } + }} + /> +
+
+ )} + /> + + ); +}