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

This commit is contained in:
2026-07-24 15:23:41 +09:00
36 changed files with 5969 additions and 3 deletions
+1 -1
View File
@@ -58,4 +58,4 @@ export default function WithBaseLayout({
<PageLayout>{children}</PageLayout>
</BaseLayout>
);
}
}
@@ -0,0 +1,6 @@
import { ExmnRegistrationPage } from "@/features/exmn/ui/ExmnRegistrationPage";
/** SFR-010-004 사업정보·조사결과 등록 화면 라우트다. */
export default function Page() {
return <ExmnRegistrationPage />;
}
@@ -0,0 +1,6 @@
import { ExmnStatusPage } from "@/features/exmn/ui/ExmnStatusPage";
/** SFR-010-005 조사현황과 처리분구별 조사 상세현황 라우트다. */
export default function Page() {
return <ExmnStatusPage />;
}
@@ -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;
@@ -21,6 +21,7 @@ type UploadOptions = {
handleProgress?: (percentage: string) => void;
handleSuccess?: (upload: Upload) => void;
handleResponse?: (res: HttpResponse) => void;
handleError?: (error: Error) => void;
};
export const handleUpload = (files: FileList, options?: UploadOptions) => {
@@ -43,6 +44,7 @@ export const handleUpload = (files: FileList, options?: UploadOptions) => {
metadata,
onError(error) {
console.error(`Failed because: ${error}`);
options?.handleError?.(error);
},
onProgress(bytesUploaded, bytesTotal) {
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2);
@@ -0,0 +1,182 @@
# 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 참조 제약, 저장 후 사업등록 후보 즉시 갱신
- 백엔드 미구현 동안 실 API 통합 항목은 보류로 기록한다. 사용자 승인 샘플은 `sample=Y`에만 격리하고 일반 화면 성공 데이터로 대체하지 않는다.
## 6. 다음 검증 순서
1. FE-0 화면·UI 사용자 검증 완료 상태 유지
2. FE-1 구현 후 사업등록 후보 연계의 FE 계약 확인
3. 프론트 전체 완료 후 백엔드 단계 진행
4. 백엔드 구현 후 FE-0 실 API와 각 SFR API·브라우저·네트워크 통합 검증
5. 프로덕션 빌드는 개발 서버 종료와 실행 허용을 확인한 시점에 수행
## 7. 흡수한 기존 문서
- 없음
## 8. FE-1 검증 계획
| 구분 | 시나리오 | 기대 결과 |
| --- | --- | --- |
| 목록 | 조건 조회·페이지 이동·헤더 정렬 | 적용 조건 유지, 0-base page와 단일 sort 전달 |
| 선택 | 미선택 상태에서 결과등록/PPL 상세 | 선택 안내 후 popup 진입 차단 |
| 팝업 체계 | 사업등록·결과등록 클릭 | 공통 `/popup/[popupKey]` 윈도우가 열리고 차단 시 안내 |
| 사업등록 | 필수값·연도·음수·처리분구 미선택 | 저장 전 항목별 오류 표시 |
| 처리분구 | 행정구역 선택/변경 | 같은 지역 `useYn=Y` 후보 조회, 이전 선택 초기화 |
| 양식 | 저장 전/후 `양식 받기` | 저장 전 안내, 저장 후 생성과 다운로드를 한 번에 수행 |
| 사업등록 입력 | 사업등록 popup 확인 | 첫 줄에 사업년도·시도·시군구·정밀조사연장이 표시되고 연장 라벨은 한 줄 유지 |
| 처리분구 목록 | 처리분구 후보가 많은 사업등록 popup | 3열 7행 21개까지 표시하고 22개부터 목록 내부 세로 스크롤 |
| 정밀조사연장 | 입력칸 확인·숫자 입력 | 증감 스피너 없음, 숫자·소수점만 입력, 최대 10자 폭 |
| 결과등록 요약 | 결과등록 popup 진입 | 사업차수·사업년도·행정구역·사업명 순서로 적정 간격을 두고 한 줄 표시 |
| 결과등록 파일 목록 | 파일 5종 영역 확인 | 연한 외곽선·헤더·행 구분선만 표시되고 강한 세로 격자 없음 |
| 결과등록 스크롤 | 최초 popup 진입 | 가로·창 세로 스크롤 없음, 검토결과 초과 시 본문 내부 세로 스크롤만 표시 |
| 결과등록 행 높이 | 파일 5종 최초 표시 | 파일 선택 영역이 남는 세로 공간을 활용하되 창 스크롤은 만들지 않음 |
| 결과등록 초기 크기 | 결과등록 버튼으로 신규 popup 진입 | 콘텐츠 설정 `980×340`, Chrome 외곽 포함 약 `980×410` |
| 파일 열 정렬 | 헤더와 5개 파일 행 비교 | 찾기·데이터 검토 헤더와 버튼 중심선 일치 |
| 파일 선택 표시 | 파일 선택 전후 비교 | 선택 파일명은 파란색 굵은 글씨와 옅은 파란 배경으로 변경 |
| Hydration | 사업등록 popup 최초 진입 | Select `aria-controls` 불일치 경고 없이 client mount 후 표시 |
| 파일 1차 검사 | 빈 파일·200MB 초과·확장자 오류 | 서버 전송 전 차단 |
| 파일 검토 | PPL/MNHL 미선택·정상·업무 오류 | 최소 1개 요구, 정상 요약 또는 위치 포함 오류 표시 |
| 최종 저장 | 검토 전/후·Tus 실패·commit 실패 | 검토 전 비활성, 실패를 성공 처리하지 않고 원인 유지 |
| PPL 집계 | 결함 건수 클릭 | 처리분구·결함 분류로 시설 목록 연결 |
| PPL 상세 | 시설·단위 선택 | 정규화 unit/detail 표시, STATUS/DEFECT 구분 |
| 보고서 | 시설 상세 보고서 버튼 | 매핑 미확정 안내와 비활성 유지 |
| 스타일 | 신규 route·모달 Tailwind | `src/features` 클래스가 생성돼 레이아웃 적용 |
| 상단 구성 | 검색조건·조회·사업등록·결과등록 | 한 카드에 표시되고 사업명 입력 폭이 과도하지 않음 |
| 공통 선택 | 사업년도·시도·시군구 | 연도 선택과 I/I 지역 선택 재사용, 지역 기본값 고정 없음 |
| 목록 열 | 사용자 확정 열 순서·단위 | NO부터 보고서까지 9개 열과 km·개소 단위 일치 |
| 버튼 | 상단 업무 버튼 | `PPL 집계·상세` 직접 버튼 미표시 |
| 샘플 격리 | 일반 URL / `?sample=Y` | 일반 URL은 API만 사용, sample URL만 샘플 사업·처리분구·검토결과 표시 |
## 9. FE-1 수동 확인 경로
1. 메뉴 연결 URL을 `/oper/exmn/register`로 등록하거나 주소로 직접 진입한다.
2. 조회조건과 빈 사업 목록 레이아웃을 확인한다.
3. 백엔드 데이터가 없으면 개발 환경의 `샘플 데이터 보기`를 눌러 `sample=Y` 샘플 사업목록을 확인한다.
4. `사업등록` WindowPopup을 열어 행정구역·가나다순 처리분구 체크박스·필수값·양식 버튼을 확인한다.
5. 사업 행 선택 후 `결과등록` WindowPopup의 파일 5종, 찾기, 데이터 검토, 저장 활성 조건을 확인한다.
6. 상단에 `PPL 집계·상세` 직접 버튼이 없고 목록 열 순서·단위가 확정안과 일치하는지 확인한다.
7. 일반 URL의 백엔드 미구현 오류는 화면 오류 메시지로 표시되는지 확인하고 샘플 성공으로 오해하지 않는다.
## 10. 조사현황·집계 1차 검증 계획
| 구분 | 시나리오 | 기대 결과 |
| --- | --- | --- |
| 진입 | `/oper/exmn/register`, `?sample=Y` | 기존 통합 화면 유지, 일반 URL은 실 API, sample URL만 샘플 데이터 사용 |
| 목록 | 조건 조회·페이지·정렬·행 선택 | 등록 화면과 같은 조건 계약, 선택 행 강조 |
| 상세현황 | 사업 행의 어느 데이터 셀 선택 | 하단 조사 상세현황 표시, 상단과 중복되는 선택 사업 요약 띠는 표시하지 않음 |
| 선택 전 상태 | 최초 진입·재조회로 선택 해제 | 조사 상세현황 영역 자체가 표시되지 않음 |
| 명칭 | 조사유형 탭 확인 | `관로조사`, `맨홀조사`로 표시 |
| 관로 집계 | 관로조사 탭 | 대상연장·주행거리·전체·구조·운영·기타와 합계 표시 |
| 맨홀 집계 | 맨홀조사 탭 | 전체·구조·운영·기타와 합계 표시 |
| 버튼 상태 | 미선택/선택 상태 | 미선택 시 상세 패널 미표시, 선택 후 세 버튼 표시·미연결 안내 |
| 1차 보류 경계 | 상세보기·지도보기 클릭 | 1차 집계 검증에서는 상세 화면·지도 이동 없이 후속/보류 안내 |
- 원본 PPT의 시각 배치 확인 후 실제 브라우저에서 상·하단 카드 높이와 열 줄바꿈을 사용자 재확인한다.
- 상세보기 실제 화면, 엑셀 파일, 지도 컨텍스트 전달은 이 1차 완료 기준에서 제외했다. 상세보기 검증은 이후 12절에서 수행한다.
- 로그인된 사용자 화면에서 목록 전체 셀 선택, 관로·맨홀 전환, 목록·상세 배치와 집계 표시를 확인해 조사관리 상세현황 1차 수동 검증은 완료했다.
## 11. 조사사업 목록·상세현황 최종 회귀 계획
| 구분 | 확인 방법 | 기대 결과 |
| --- | --- | --- |
| 검색조건 | 사업차수·사업연도·시도·시군구를 차례로 변경 후 조회 | 조건 간격이 자연스럽고 기존 연도·행정구역 연동 동작 유지 |
| 행 선택 | NO부터 보고서까지 각 열의 빈 공간·텍스트 주변 선택 | 어느 위치에서도 같은 행의 상세현황 표시, 선택 행 강조 |
| 정렬 | 각 정렬 헤더를 3회 연속 선택 | 오름차순→내림차순→기본 순환, 공통 아이콘 상태 일치 |
| 데이터 원천 | 일반 URL과 `sample=Y`에서 같은 열 정렬 | 서버 결과와 샘플 결과가 같은 값 비교 규칙 사용 |
| 목록 높이 | 샘플 사업 4건 확인 | 3행은 하단선까지 온전히 표시, 4번째부터 목록 내부 스크롤 |
| 폭·경계 | 목록과 상세 좌우 끝선 및 스크롤 상태 비교 | 같은 가로 폭·여백, 상·하단 구분선 잘림 없음 |
| 조사유형 | 관로·맨홀 버튼의 가장자리와 중앙 선택 | 보이는 button 전체가 클릭되고 해당 유형 집계 재조회 |
| 요청 순서 | 사업·조사유형을 연속으로 빠르게 변경 | 마지막 선택의 응답만 화면에 반영 |
| 합계 | 관로·맨홀 합계 행 DOM·화면 확인 | NO+처리분구 실제 병합, 중앙 정렬, 배경 `#d2deef` |
| 상세 스크롤 | 처리분구 10행을 위아래로 스크롤 | 헤더 고정, 다음 행 일부 노출 없음, 경계선 유지 |
| 페이지 높이 | 상세 표시 전후·Footer 높이 변화·화면 이탈 확인 | 문서 전체 세로 스크롤 없음, 내부 스크롤 사용, 이탈 시 overflow 복원 |
| 공통 회귀 | 다른 BaseLayout 화면 진입 | 공통 Header·Footer와 레이아웃 동작 변화 없음 |
| 팝업 회귀 | 사업등록·결과등록 WindowPopup 재진입 | 확정된 팝업 크기·배치·버튼·처리분구·파일행 디자인 유지 |
- 상세보기·다운로드 실제 동작, 지도 이동, 실 API 성공 응답은 이번 화면 회귀의 완료 범위에 포함하지 않는다.
## 12. 관로·맨홀 상세보기 팝업 검증 계획
| 구분 | 확인 방법 | 기대 결과 |
| --- | --- | --- |
| 진입 조건 | 사업 행 선택 후 관로·맨홀 탭에서 상세보기 | 선택 사업·조사유형의 `exmnSurveyDetail` WindowPopup 표시 |
| 팝업 구조 | 헤더·상단 조건·본문 확인 | 파란 헤더·흰 제목, 별도 사업요약 띠 없음, 조회조건과 버튼이 본문 상단에 표시 |
| 표 구조 | 관로·맨홀 팝업 비교 | 같은 고정 정보 열과 대·중·소 3열 구조, 조사유형별 이상항목 코드·명칭 적용 |
| 스크롤 | 표를 좌우·상하로 이동 | 팝업 문서 전체 스크롤 없음, 표 영역에서만 스크롤 |
| 고정 열 | 표를 오른쪽으로 이동 | 보고서번호·관로/맨홀번호가 고정되고 규격 이후 경계선 유지 |
| 정렬 | 고정 정보 헤더를 3회 클릭 | 보고서번호~주행거리만 오름차순→내림차순→기본 순환 |
| 조회 | 관로/맨홀번호 입력 후 조회 | 번호 포함 행만 표시되고 빈 조건에서 전체 3행 복원 |
| 소계 | 샘플 3행 값 합산 | 연장·주행거리와 코드별 대·중·소 합계 일치 |
| 합계 | 이상항목 합계 행 확인 | 각 항목 대·중·소 3열 병합 후 총합 표시 |
| 요약 라벨 | 가로 스크롤 전·후 비교 | 규격 전에는 병합영역 중앙, 규격 이후에도 고정열 오른쪽에 소계·합계 라벨 표시 |
| 목록 정렬 | 조사사업 `NO` 헤더 3회 클릭 | 정순→역순→기본 순환, 서버에 가상 `rowNumber` 필드 미전달 |
| 상세현황 정렬 | 합계와 일반 처리분구 행 비교 | 합계는 상단 고정, 일반 행만 선택 열 기준 정렬 |
| 액션 | 상세현황 버튼 확인 | 상세보기→지도보기→다운로드 순서, 다운로드 초록색 |
| 보류 기능 | 팝업 다운로드·지도보기 | 미구현 성공 처리 없이 후속/보류 안내 |
- 사용자 화면 확인을 이번 프론트 상세보기 완료 기준으로 사용한다.
- 실 API 응답, JSONB 코드 매핑, 실제 엑셀 다운로드 파일 검증은 백엔드 구현 후 통합 검증으로 이관한다.
## 13. 결함 분류별 상세보기와 팝업 최종 회귀 계획
| 검증 항목 | 조작 | 기대 결과 |
| --- | --- | --- |
| 행 선택 | 상세현황 처리분구 행의 임의 셀 클릭 | 행 전체가 선택 강조되고 상단 상세보기 대상 갱신 |
| 0건 처리 | 결함 건수 0 셀 확인 | 파란 링크가 아니며 상세 팝업을 열지 않음 |
| 구조적 링크 | 구조적 결함 1건 이상 링크 클릭 | 행 선택 후 `STRUCTURAL` 상세 팝업 표시 |
| 운영적 링크 | 운영적 결함 1건 이상 링크 클릭 | 행 선택 후 `OPERATIONAL` 상세 팝업 표시 |
| 전체 상세 | 행 선택 후 상단 상세보기 클릭 | `ALL` 기준 구조·운영 이상항목을 함께 표시 |
| 조사유형 | 관로·맨홀 탭에서 각각 링크 클릭 | 선택 탭에 맞는 관로/맨홀 상세표 표시 |
| 분류 열 필터 | 전체·구조·운영 팝업 비교 | 선택 분류 코드 열만 표시 |
| 분류 행 필터 | 분류별 샘플 데이터 확인 | 선택 분류의 결함이 하나 이상인 시설 행만 표시 |
| 코드 헤더 | 이상항목 헤더 확인 | 모든 헤더가 `명칭 (코드)` 형식 |
| 제거 열 | 고정 정보 열 확인 | 구조적·운영적 내부결함 열 없음 |
| 집계 | 전체·구조·운영 각각 소계·합계 확인 | 현재 표시 행·열의 대·중·소 값과 일치 |
| 사업등록 검색 | 처리분구명 검색·결과 선택·초기화 | 필터된 목록 선택과 선택 건수 갱신 |
| 결과등록 요약 | 사업정보 영역 확인 | 읽기 전용 텍스트가 한 줄에서 잘리지 않음 |
| 회귀 | 사업등록·결과등록·상세보기 팝업 비교 | 파란 헤더와 옅은 파란 본문 배경 일관성 유지 |
- 브라우저 수동 검증은 로그인된 `SN` 세션과 `/oper/exmn/register?sample=Y` 진입이 선행조건이다.
- 개발 서버가 실행 중이면 별도 프로덕션 빌드를 실행하지 않고 정적 검사와 사용자 화면 확인을 우선한다.
@@ -0,0 +1,208 @@
# 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 보정이 정상임을 사용자가 확인, 화면 단계 완료 판정 |
| 2026-07-22 | FE-1 import·Hook·API 경로 정적 검사 | 상대 import 누락과 client 경계 누락 없음, 승인된 SFR-010-004 API 경로 확인 |
| 2026-07-22 | FE-1 공백·한글 검사 | 신규/수정 대상 후행 공백 없음, 한글 본문 UTF-8 재조회 정상 |
| 2026-07-22 | FE-1 `git diff --check` | 추적 파일 통과, 신규 파일은 별도 후행 공백 검사 통과 |
| 2026-07-22 | FE-1 TypeScript 실행 | pnpm 비대화형 의존성 정리 중단 및 compiler 파일 샌드박스 `EPERM`으로 결과 미확보 |
| 2026-07-22 | FE-1 Tailwind 경로 확인 | 기존 content에 `src/features`가 없음을 확인하고 `./src/**/*` 추가 |
| 2026-07-22 | FE-1 브라우저·실 API | 3002 서버 미실행·백엔드 미구현으로 사용자 화면 확인 및 통합 단계 대기 |
| 2026-07-22 | FE-1 사용자 1차 화면 확인 | 연도·지역 선택 컴포넌트, 사업명 폭, 상단 영역 분리, 불필요 PPL 버튼, 목록 열 순서 보완 요청 확인 |
| 2026-07-22 | FE-1 사용자 1차 보정 정적 확인 | 기존 연도 옵션·I/I 지역 선택 재사용, 상단 단일 카드, PPL 버튼 제거, 9개 열 순서·단위 반영 |
| 2026-07-22 | FE-1 WindowPopup 전환 정적 확인 | popup key·registry·content·부모 메시지, 처리분구 가나다순 체크 선택, 결과 파일 5종 확인 |
| 2026-07-22 | FE-1 샘플 경로 Next 개발 서버 확인 | `/oper/exmn/register?sample=Y`, 사업등록 popup, 결과등록 popup 모두 HTTP 200 |
| 2026-07-22 | FE-1 사업등록 2차 보정 | popup `920×620`, 5개 입력, 버튼 구성·green 스타일, client mount hydration 방어 정적 확인 |
| 2026-07-22 | FE-1 샘플 전환·라우트 재확인 | 일반 목록, 샘플 목록, 사업등록 샘플 popup, 결과등록 샘플 popup 모두 HTTP 200 |
| 2026-07-22 | FE-1 사업등록 3차 배치 보정 | 정밀조사연장 첫 줄 이동, 처리분구 높이 `20rem` 확장, 샘플 배지 제거 및 popup HTTP 200 확인 |
| 2026-07-22 | FE-1 사업등록 4차 배치 보정 | 정밀조사연장 한 줄·입력 `7rem`, 처리분구 높이 `21rem`, 샘플 21개 및 초과 스크롤 구조 확인 |
| 2026-07-22 | FE-1 팝업 5차 보정 | 연장 스피너 제거·최대 10자, 결과 popup `980×620`, 4개 사업요약·경계선 table, 샘플 배지 제거 및 두 popup HTTP 200 |
| 2026-07-22 | FE-1 결과 popup 6차 보정 | `980×460`, 닫기 제거, 강한 격자 제거, 창 스크롤 차단·본문 조건부 스크롤 및 HTTP 200 확인 |
| 2026-07-22 | FE-1 결과 popup 7차 보정 | html/body 문서 스크롤 잠금·복원, 업로드 행 높이 확대 및 HTTP 200 정적 확인 |
| 2026-07-22 | FE-1 결과 popup 최종 크기 보정 | 두 캡처 외곽 높이 차이를 반영해 definition `980×340`으로 조정 |
| 2026-07-22 | FE-1 결과 popup 최종 UI 보정 | 헤더·행 `gap-3` 통일, 선택 파일명 파란색·굵게 표시 및 HTTP 200 확인 |
| 2026-07-22 | FE-1 결과 사업요약 순서 보정 | 사업차수→사업년도→행정구역→사업명, 비율 열·`gap-8` 적용 |
| 2026-07-22 | FE-1 사업·결과 WindowPopup 사용자 확인 | 사업등록과 조사결과 업로드 최종 UI 완료 판정 |
| 2026-07-23 | 조사현황 목록·상세 스크롤 보정 정적 확인 | 가로 여백 `1.2rem`, 전체 셀 행 선택, 양쪽 내부 스크롤 및 목록 페이지네이션 제거 확인 |
| 2026-07-23 | 변경 파일 공백·UTF-8 검사 | `git diff --check` 통과, 대상 코드·문서 UTF-8 확인 |
| 2026-07-23 | 조사현황 브라우저 재검증 | 3002 개발 서버 미실행으로 미수행 |
| 2026-07-23 | 조사현황 사용자 재확인 | 목록·상세 실제 가로 경계 불일치, 상세 하단 여백, 일부 열만 클릭되는 문제 확인 |
| 2026-07-23 | 조사현황 2차 구조 보정 | 동일 외부 스크롤 래퍼·stable gutter, 상세 남은 높이 사용, 전체 열 기존 `cellClick` 적용 |
| 2026-07-23 | 조사현황 사용자 3차 재확인 | 목록 3행에서도 스크롤·하단선 잘림, 행·탭의 시각 영역과 실제 클릭 영역 불일치 확인 |
| 2026-07-23 | 조사현황 3차 구조 보정 | 목록 `18rem`, 공통 표 `<tr>` 단위 `rowClick`, 80×32px 실제 button 탭, 상세 샘플 10행 적용 |
| 2026-07-23 | 조사현황 사용자 4차 재확인 | 정밀조사 연장 우측 일부부터 보고서 영역만 선택되고 앞쪽 셀은 클릭되지 않아 공통 `rowClick` 방식 실패 판정 |
| 2026-07-23 | 조사현황 4차 클릭 구조 교체 | 목록을 공통 Table 스타일의 화면 전용 표로 교체하고 모든 데이터 셀을 전체 면적 button으로 구성, 공통 `rowClick` 제거 |
| 2026-07-23 | 조사관리 상세현황 사용자 최종 확인 | 전체 행 선택, 관로·맨홀 탭, 목록·상세 배치와 상세 집계 표시 확인 완료. 조사관리 상세현황 화면 단계 완료 판정 |
| 2026-07-23 | git status·문서 정합성 점검 | 추적 코드 8개·신규 코드 15개와 FE 문서 4개를 대조하고 작업카드·구현·검증 문서의 단계 상태와 실제 파일 목록을 동기화 |
| 2026-07-23 | 사업보고서 다운로드 정적 확인 | 첨부 ID가 있는 보고서 셀의 파란 밑줄 링크, 행 클릭 전파 차단, 기존 첨부서비스 Blob 다운로드, 샘플 미연결 안내 확인 |
## 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 코드: 사업등록·결과등록 WindowPopup과 조사관리 상세현황 UI 구현·개발 서버 컴파일·사용자 확인 완료, 실 API 검증 대기
- 조사현황·관로·맨홀 종합집계 화면: 사용자 확인 통과, 화면 단계 완료
- 상세보기 실제 화면·다운로드·지도보기: 미착수/보류
- 사용자 확인 기준 화면 1~3단계: 완료
- 공식 요구사항 기준 FE-3 상세 기능과 FE-4 GIS: 미착수. 현재 맨홀은 종합집계 UI까지만 완료
## 6. 보류/리스크
- 백엔드 응답 필드와 `409` 오류 body를 구현 후 대조해야 한다.
- 메뉴 원천 등록과 화면 진입은 해결됐지만 최종 권한별 노출·직접 URL 접근 제어는 통합 검증에서 확인해야 한다.
- FE-0 화면·UI는 사용자 확인으로 완료됐지만 CRUD 실 API의 성공·오류·409 처리는 백엔드 구현 후 확인해야 한다.
- 프로덕션 빌드는 다음 프론트 단계 시작 전 또는 사용자가 허용한 시점에 다시 실행한다.
- FE-1 multipart 필드명과 응답 DTO는 백엔드 구현 시 최종 대조해야 한다.
- TypeScript 실행 결과를 확보하지 못했으므로 사용자 개발 서버의 Next 컴파일 결과를 FE-1 단계 종료 전에 확인해야 한다.
## 8. FE-1 정적 검증 결과
- `/oper/exmn/register/page.tsx`는 client UI를 직접 import해 FE-0의 서버/클라이언트 배럴 오류를 반복하지 않는다.
- 사업 조회조건은 draft/applied로 분리되고 페이지·정렬 요청에도 적용 조건을 유지한다.
- 사업등록은 활성 처리분구 조회와 필수값·연도·음수·선택 검증을 저장 전에 수행한다.
- 파일 변경 시 기존 검토 결과를 폐기하고, 정상 검토 전 최종 저장 버튼을 비활성화한다.
- Tus 업로드 실패 콜백이 Promise reject와 화면 오류로 연결된다.
- PPL 상세는 `PPL_UNIT_V1` 정규화 계약의 단위·상태·결함 필드를 렌더링하고 보고서 미확정 기능은 활성화하지 않는다.
- 일반 URL의 런타임 성공 목업과 자동 fallback은 없다. 사용자 승인 샘플은 `sample=Y`에서만 명시적으로 격리한다.
- 사용자 1차 보정으로 직접 연도 입력과 독립 지역 셀렉트를 제거하고 기존 `getStartYearArray`·`CtpvSggPrcsFcltSelectBox`를 재사용했다.
- 상단 검색조건과 3개 액션을 하나의 `SearchDiv`에 두고 `PPL 집계·상세` 직접 버튼을 제거했다.
- 목록은 사용자 확정 9개 열로 재배치했으며 총사업비는 등록 필드로만 유지한다.
- 사업등록·결과등록은 공통 `windowPopup`의 definition→registry→content 흐름으로 열리고 완료 메시지는 동일 origin 부모창에만 반영한다.
- 사업등록 처리분구는 한글 가나다순 체크박스이며, 결과등록은 캡처 기준 파일 5종과 행별 찾기·데이터 검토 구조를 사용한다.
- 샘플 URL 세 경로는 개발 서버에서 HTTP 200을 확인했고, 사업등록·결과등록의 클릭·선택·팝업 크기·실제 시각 배치는 사용자 확인을 통과했다.
- 일반 목록의 개발 전용 `샘플 데이터 보기`는 `sample=Y`로 재진입하며, 자동 성공 fallback 없이 샘플 사업 선택과 결과등록 진입을 가능하게 한다.
- popup content의 첫 서버 출력에는 Radix Select를 렌더링하지 않고 client mount 후 화면을 구성해 보고된 hydration ID 불일치를 차단했다.
## 9. 조사현황·집계 1차 정적 검증 결과
- 원본 화면설계서 슬라이드 3·4를 이미지로 렌더링해 상단 사업목록, 행 선택, 하단 조사 상세현황, 두 조사유형 탭과 세 버튼 위치를 대조했다.
- 신규 route는 client UI를 직접 import해 Hook 포함 feature 배럴이 서버 컴포넌트 import 경계에 들어가지 않게 했다.
- 일반 모드와 `sample=Y`를 분리했고 샘플은 사업별 처리분구 수에 맞춘 관로·맨홀 집계만 반환한다.
- `CCTV조사` 사용자 표출은 신규 조사현황 화면에 사용하지 않고 `관로조사`, `맨홀조사`로 구성했다.
- 세 버튼은 실제 상세·파일·지도 동작을 성공으로 가장하지 않고 미연결 또는 보류 안내만 제공한다.
- 첫 구현의 별도 조사현황 목록을 제거하고 `ExmnStatusDetailPanel`을 기존 `ExmnRegistrationPage` 목록 아래에 결합해 PPT 3·4의 동일 화면 선택 전·후 구조로 수정했다.
- 선택 전에는 패널을 렌더링하지 않고 사업 행 선택 후에만 같은 화면 하단에 패널을 추가한다.
- 사용자 캡처에서 불필요한 선택 사업 요약 띠를 제거하고 조사유형을 공통 `UITab`으로 교체했다. 후속 보정에서는 분석도구의 활성 메뉴 느낌과 맞도록 기본 파란 활성 탭을 컴팩트하게 적용했다.
- 합계 행은 앞의 두 열을 `colSpan=2`로 병합하고 연한 배경·굵은 글씨를 적용해 일반 처리분구 행과 구분했다.
- 기본 `Table`의 외부 스크롤과 sticky `thead` 조합에서 스크롤 중 행 일부가 잘려 보이는 브라우저 문제가 사용자 캡처로 확인됐다.
- 집계표를 공통 `UiTable`로 교체하고 내부 표 래퍼를 유일한 스크롤 컨테이너로 지정했다. 사용자 캡처에서 `16.2rem`은 데이터 3번째 행의 하단선을 자르는 것이 확인되어 `16.7rem`으로 보정했다.
- 실행 중인 개발 서버의 `/oper/exmn/register?sample=Y` 요청은 변경 후 HTTP 200을 반환했다. 페이지 전체 스크롤 제거와 그리드 내부 스크롤의 최종 시각 확인은 사용자 재확인 대상으로 남긴다.
- 후속 캡처에서 남은 페이지 스크롤의 원인을 `BaseLayout`의 `calc(100vh - 50px)`와 실제 `6rem` 헤더 높이 차이로 확인했다. 최초 경로 전용 CSS는 실제 SSR DOM에 존재하는 `group/sidebar-wrapper` 중간 래퍼를 건너뛰어 매칭되지 않았고, 선택자를 `.sn-exmn-base-layout > div > .flex-1 > .flex > .flex-1`로 정정했다.
- 합계 표시는 관로 열 비율 `6%+18%`, 맨홀 열 비율 `8%+28%`를 기준으로 첫 두 열 전체 중앙에 오도록 표시 영역을 확장했다.
- 보정 후 샘플 경로 HTTP 200, 초기 HTML의 `sn-exmn-base-layout` 및 `group/sidebar-wrapper` DOM 연결, 개발 CSS의 정확한 전용 선택자와 `max-height: 16.7rem` 생성을 확인했다. 실제 브라우저의 우측 스크롤 제거와 3번째 행 하단선 표시는 사용자 최종 확인 대상으로 남긴다.
- 상세현황 표시 여부와 무관하게 조사사업 본문·Footer 합계가 뷰포트 높이를 넘지 않도록 route 전용 고정 높이와 flex 축소 구조를 적용했다. Next 개발 서버 생성 CSS에서 `height: calc(100dvh - 6rem)`을 확인했다.
- 공통 `UiTable`의 `rowClick` 코드와 선택 상태가 Next 서버 번들에 생성된 것은 확인했으나 사용자 재확인에서 전체 열 클릭을 보장하지 못했다. 해당 방식은 검증 실패로 판정하고 기존 `cellClick` 전체 열 연결로 교체했다.
- 3002 포트에 개발 서버 listener가 없어 신규 route의 HTTP 렌더 검증은 확보하지 못했다.
- 저장소 로컬 TypeScript compiler는 샌드박스 `EPERM` 후 권한 요청이 허용되지 않아 실행 결과를 확보하지 못했다. 대상 소스의 필수 `UiTable` props와 import·client 경계는 정적으로 확인했다.
- 목록과 상세현황의 동일 폭 보정 과정에서 전역 `sn-exmn-grid-scroll`을 사용했으나, 팝업 디자인 회귀 보고 후 해당 전역 클래스를 제거했다. 최종안은 각 조사 그리드의 인라인 `scrollbarGutter: "stable"`만 사용한다.
- 목록은 기존 `cellClick`을 전달하고 `clickableHeadIds`를 지정하지 않으므로 공통 `UiTable` 계약상 모든 데이터 셀이 클릭 가능 대상이다. 선택 함수는 표시 행 인덱스로 원본 사업을 찾아 `selectedBusinessId`를 갱신한다.
- 조사사업 목록은 `page=0`, `size=1000`으로 조회하고 `total={0}`으로 페이지 이동 UI를 숨긴다. 목록만 `16.7rem` 상한을 두고 상세 집계 래퍼는 `flex-1`로 남은 세로 공간을 사용한다.
- `git diff --check`와 UTF-8 검사는 통과했고 개발 서버 산출물에 신규 스크롤 class와 전체 셀 callback이 포함된 것을 확인했다. 로그인 세션이 없는 검증 브라우저는 `/auth/login`으로 이동하므로 실제 선택 동작과 최종 픽셀 정렬은 사용자 세션에서 재확인이 필요하다.
- 3차 `UiTable.rowClick` 보정도 사용자 화면에서 전체 행 선택을 보장하지 못해 실패로 판정했다. 4차 보정에서는 목록만 화면 전용 공통 Table 스타일로 교체하고, 생성 번들에서 각 셀의 `min-h-[4rem] w-full` button, `<tr>` 보조 클릭, `18rem` 높이와 기존 정렬 순환을 확인했다.
- 탭은 생성 번들에서 `tablist`, `aria-selected`, 각 `w-[8rem] h-[3.2rem]` button과 직접 `setJobType` 호출을 확인했다.
- 당시 샘플 번들의 세 사업 `trtdstCnt: 10`을 확인했다. 이후 목록 내부 스크롤 검증을 위해 네 번째 사업 샘플을 추가했다. 개발 서버 라우트는 빌드 오류 없이 렌더링됐으나 검증 브라우저에 로그인 세션이 없어 실제 샘플 10행과 클릭 동작의 사용자 화면 확인은 남아 있었다.
- 이후 사용자가 로그인된 실제 화면에서 전체 행 선택, 관로·맨홀 탭, 목록·상세 배치와 집계 표시를 최종 확인해 앞선 브라우저 세션 제한을 보완했다.
## 10. 조사사업 목록·상세현황 최종 검증 결과
- `ExmnRegistrationPage`에서 검색조건 간격과 사업차수·사업연도·시도·시군구 선택 연결을 확인했다.
- 모든 데이터 셀을 채우는 실제 button과 `<tr>` 보조 선택 함수, `data-state=selected`를 확인해 전체 행 클릭과 선택 행 강조 계약을 확인했다.
- 정렬 상태 전이가 `asc → desc → undefined`이며 서버 요청과 샘플 배열이 같은 열 키·비교 함수를 사용하는 것을 확인했다.
- `EXMN_SAMPLE_BUSINESSES`에 `SAMPLE-BIZ-004`가 추가됐고 목록 인라인 높이 제한과 내부 스크롤로 3행 이후 데이터를 확인하는 구조를 확인했다.
- 목록과 상세에 동일한 인라인 스크롤바 예약과 좌우 기준이 적용됐고 sticky 헤더·하단 보정선이 있는 것을 확인했다.
- `ExmnStatusDetailPanel`의 관로·맨홀 선택은 각각 전체 `80×32px`가 실제 button이며 선택 변경 시 집계를 재조회한다.
- `summaryRequestIdRef` 비교로 과거 성공·오류·loading 응답이 최신 사업·탭 선택을 덮어쓰지 않는 것을 확인했다.
- 합계 행은 실제 `colSpan=2`, 중앙 정렬, 전 셀 `#d2deef` 배경을 사용한다.
- 상세 헤더는 sticky이고 상세 영역은 인라인 `maxHeight: 23.8rem`과 내부 세로 스크롤을 사용한다. 다음 행 일부가 하단에 노출되지 않도록 목록과 같은 높이 제한 방식을 적용했다.
- 공통 `layout.tsx`는 `BaseLayout → PageLayout` 구조를 유지한다. 페이지 높이 계산, Footer `ResizeObserver`, 문서 overflow 잠금·복원은 `ExmnRegistrationPage` 내부에만 존재하며 공통 Header·Footer 컴포넌트는 수정하지 않았다.
- 공통 `UiTable`의 `noOuterMargin`과 선택 행 상태는 소스에 남아 있으나 조사사업 목록·상세현황 최종 표는 병합·클릭·스크롤 제어를 위해 직접 구성한 테이블임을 확인했다.
- `components.css`의 의미 있는 diff가 제거됐고 조사 그리드 gutter는 두 화면 컴포넌트에만 인라인으로 적용된다.
- 완료 화면 캡처와 대조해 `tailwind.config.ts`의 `./src/**/*` 누락이 결과등록 grid와 사업등록 임의 폭 클래스 미생성 원인임을 확인했다. 스캔 경로를 복구하고 핵심 grid·폭을 인라인 스타일로 이중 고정했다.
- 사업등록은 `920×620`, 처리분구 `21rem`, 사업명 최대 `480px`; 결과등록은 `980×340`, 사업요약 4열과 파일행 `180px / 가변 / 80px / 112px` 계약으로 복구했다.
- 사용자의 로그인된 실제 화면 최종 확인으로 행 전체 선택, 관로·맨홀 전환, 목록·상세 배치와 상세 집계 표시를 완료 판정했다.
- 남은 검증은 실 API/백엔드 연동과 후속 상세보기·다운로드 구현이다. 지도보기는 보류다.
## 11. 조사사업 목록 사업보고서 다운로드 검증 결과
- `bizRptpAtchFileId`가 있는 행만 보고서 텍스트에 파란색·밑줄·hover 스타일을 적용했다.
- 보고서 링크 클릭 시 `stopPropagation()`으로 조사사업 행 선택 동작과 파일 다운로드 동작을 분리했다.
- 기존 첨부서비스 경로 `/attach-service/api/v1/download/{atflId}`와 공통 `fileDownload()`를 재사용했다.
- 실제 파일명 원천이 목록 응답에 없으므로 현재 다운로드명은 `${사업명}_사업보고서.pdf`다.
- `sample=Y`는 실제 첨부서비스를 호출하지 않고 안내를 표시한다.
- 실제 첨부 ID를 사용한 브라우저 다운로드와 PDF 열림 확인은 백엔드·첨부 데이터 준비 후 수행해야 한다.
## 12. 관로·맨홀 상세보기 팝업 검증 결과
- 사용자가 조사사업 행 선택과 관로·맨홀 탭 선택 후 `상세보기` WindowPopup 진입을 확인했다.
- 공통 팝업 디자인에 맞춘 파란 헤더·흰색 제목, 본문 상단 조회조건·조회·다운로드 배치와 사업정보 요약 띠 제거를 확인했다.
- 관로·맨홀 화면은 같은 고정 정보 열과 대·중·소 3열 구조를 사용하며 이상항목 내용만 코드 정의에 따라 달라지는 것을 확인했다.
- 상세표 헤더 배경·글꼴을 공통 그리드와 맞추고 팝업 문서 대신 표 영역에서만 가로·세로 스크롤하는 구조를 확인했다.
- 관로/맨홀번호 입력 폭을 축소하고 번호 포함 검색으로 샘플 행을 필터링하는 동작을 확인했다.
- 보고서번호부터 주행거리까지만 정렬 버튼이 표시되고 `asc → desc → undefined`로 순환하는 소스를 확인했다.
- 소계는 연장·주행거리와 이상항목별 대·중·소 합산값을 표시한다.
- 합계는 각 이상항목의 대·중·소 3열을 병합하고 총합을 표시한다.
- 소계·합계 라벨을 보고서번호부터 규격까지 가운데 배치하고, 가로 스크롤로 규격이 사라진 뒤에도 고정열 오른쪽에서 라벨과 경계선이 유지되도록 보정했다.
- 조사사업 목록 `NO` 정렬은 가상 필드를 서버에 전달하지 않고 조회 원본 정·역순으로 동작한다.
- 조사 상세현황은 합계 행을 정렬 배열 밖에 유지하고 처리분구 데이터 행만 모든 표시 열에서 정렬한다.
- 상세현황 버튼 순서를 `상세보기 → 지도보기 → 다운로드`로 변경하고 다운로드 버튼에 기존 초록색을 적용했다.
- 사용자가 최종 화면을 확인하고 상세보기 프론트 화면을 완료 판정했다.
- `git diff --check`는 통과했다. 실행 중 개발 서버 보호와 로컬 TypeScript compiler의 샌드박스 `EPERM` 이력 때문에 별도 프로덕션 빌드·타입 검사는 수행하지 않았다.
- 실 상세 API, JSONB 코드 매핑, 실제 엑셀 다운로드와 지도보기는 검증되지 않았으며 후속 범위다.
## 13. 결함 분류별 상세보기와 팝업 최종 보완 검증 결과
- 구조적·운영적 결함 건수가 1개 이상일 때만 파란색 밑줄 링크와 팝업 클릭 동작이 활성화되는 조건을 소스에서 확인했다.
- 링크 클릭이 처리분구 선택과 `defectType=STRUCTURAL|OPERATIONAL` 상세 팝업 열기를 함께 수행하는 것을 확인했다.
- 상단 상세보기는 기본값 `ALL`을 전달하며 잘못된 `defectType`은 팝업 content에서 `ALL`로 정규화한다.
- 관로·맨홀 상세표 모두 선택 분류의 코드 열과 해당 결함이 존재하는 시설 행만 남기는 필터를 확인했다.
- 구조적·운영적 내부결함 고정 열과 관련 샘플 필드가 제거됐고 이상항목 헤더가 `명칭 (코드)`로 표시되는 것을 확인했다.
- 소계·합계가 필터 후 표시 행·열을 기준으로 다시 계산되는 것을 소스와 샘플 데이터로 확인했다.
- 사업등록의 처리분구 검색·검색결과 선택·초기화와 결과등록의 읽기 전용 사업정보 요약 구현을 확인했다.
- 사업등록·결과등록·상세보기 팝업이 공통 파란 헤더와 옅은 파란 본문 배경을 사용하는 것을 확인했다.
- `git diff --check` 정적 검증을 수행 대상으로 유지한다.
- 이번 문서 반영 시점에는 로그인 세션이 없어 브라우저 자동 재검증을 수행하지 못했다. 기존 사용자 화면 완료 판정을 유지하되 이번 결함 분류 링크는 로그인된 화면에서 최종 클릭 확인이 필요하다.
- 로컬 TypeScript compiler는 샌드박스 `EPERM` 이력으로 별도 실행하지 않았다. 이는 기능 실패가 아니라 실행 환경 차단으로 분류한다.
## 14. 흡수한 기존 문서
- 없음
@@ -0,0 +1,208 @@
# SFR-010-004~SFR-010-007 조사사업 FE 작업카드
## 1. 작업 개요
- 작업 ID: `SFR-010-004~007-EXMN-NEW-FE-FIRST`
- 작업명: CCTV·맨홀 조사사업 프론트엔드 선행 구현
- 상태: 화면 1~3단계와 관로·맨홀 상세보기 팝업 완료·커밋 대기(실 API·엑셀 다운로드 후속, 지도보기 보류)
- 작성일: 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 집계/상세 | 화면 완료 | 사업등록·결과등록·종합집계·관로 상세보기 UI 확인 완료, 실 API·다운로드 후속 |
| FE-2 | `SFR-010-005` 조사현황·지도 진입 | 부분 완료 | 같은 화면 조사현황 완료, 지도 진입 보류 |
| FE-3 | `SFR-010-006` MNHL 집계·상세 | 화면 완료 | 맨홀 종합집계·상세보기 UI 확인 완료, 실 API·다운로드 후속 |
| FE-4 | `SFR-010-007` GIS 레이어·등급 필터·상세 | 대기 | FE-3 완료 |
### 3.1 사용자 확인 기준 화면 단계
| 화면 단계 | 완료 내용 | 판정 |
| --- | --- | --- |
| 1단계 | 처리분구관리 조회·추가·수정·삭제, 시도·시군구 조건, 그리드 UI | 완료 |
| 2단계 | 조사사업 검색·정렬·내부 스크롤, 행 선택·강조, 사업등록·결과등록 WindowPopup, `sample=Y` 데이터 | 완료 |
| 3단계 | 같은 화면 하단의 관로·맨홀 조사 전환, 처리분구별 종합집계·합계, 상세 내부 스크롤, 요청 순서 보호 | 완료 |
- 위 1~3단계는 화면 구현 체크포인트다. 공식 요구사항 단계는 실제 상세보기·다운로드·지도 및 백엔드 연동 범위를 포함하므로 `FE-1~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` 추가분만 되돌린다.
- 기존 화면·공통 비즈니스 컴포넌트에는 롤백 대상 변경이 없다.
## 9. FE-1 수정 대상
| 구분 | 경로 | 결과 |
| --- | --- | --- |
| 라우트 | `app/(with-base-layout)/oper/exmn/register/page.tsx` | 신규 |
| 화면 | `src/features/exmn/ui/ExmnRegistrationPage.tsx` | 사업 목록·공통 WindowPopup 진입 |
| 사업등록 | `src/features/exmn/ui/ExmnBusinessRegisterPopupPage.tsx` | 처리분구 체크 선택·가나다순·저장·양식 |
| 파일검토 | `src/features/exmn/ui/ExmnResultRegisterPopupPage.tsx` | 파일 5종·validate·Tus 첨부·commit |
| 팝업 공통 | `src/features/windowPopup/{model,ui,registry.tsx}` | popup key·크기·메시지·content 등록 |
| 샘플 검증 | `src/features/exmn/model/exmn-sample-data.ts` | `sample=Y` 전용 사업·처리분구·검토결과 |
| PPL | `src/features/exmn/ui/PplSummaryModal.tsx`, `PplFacilityDetailModal.tsx` | 집계·시설·단위구간 상세 |
| 타입/API/검증 | `src/features/exmn/{model,api,lib}/exmn-business-*` | 승인 계약 구현 |
| 첨부 오류 | `app/components/lib/utils/fileUploadUtil.tsx` | 실패 콜백 추가 |
| 스타일 스캔 | `tailwind.config.ts` | `./src/**/*` 추가 |
## 10. FE-1 완료 게이트
- `/oper/exmn/register`의 검색조건과 조회·사업등록·결과등록이 하나의 상단 카드에 표시되는지 확인한다.
- 사업년도는 단일 연도 선택, 시도·시군구는 I/I 공통 지역 선택으로 동작하며 양평에 고정되지 않는지 확인한다.
- 사업명 입력 폭과 목록 열 순서·단위가 사용자 확정안과 일치하는지 확인한다.
- 사업등록 WindowPopup의 행정구역 연동 처리분구 체크 선택·가나다순과 저장 전 검증을 확인한다.
- 사업등록 WindowPopup은 콘텐츠 창 `920×620` 안에서 5개 입력만 순서대로 표시하고, 별도 닫기·양식 생성 버튼 없이 저장·양식 받기·조사보고서 양식을 제공하는지 확인한다.
- 결과등록 WindowPopup의 파일 5종, 행별 찾기·데이터 검토, 검토 정상 전 저장 비활성을 확인한다.
- `/oper/exmn/register?sample=Y`에서만 샘플 사업과 팝업 샘플 상태가 보이고 일반 URL은 실제 API 계약을 유지하는지 확인한다.
- 상단에 설계되지 않은 `PPL 집계·상세` 직접 버튼이 표시되지 않는지 확인한다.
- 사업·결과 popup 확인 후 PPT 3·4의 동일 목록 화면에 조사 상세현황을 결합하고 사용자 확인을 받는다.
## 11. 조사현황·집계 1차 구현 범위
| 구분 | 경로 | 결과 |
| --- | --- | --- |
| 기존 통합 화면 | `src/features/exmn/ui/ExmnRegistrationPage.tsx` | 사업 목록 행 선택 아래 상세 패널 결합 |
| 상세 패널 | `src/features/exmn/ui/ExmnStatusDetailPanel.tsx` | 관로·맨홀 조사 상세현황 골격 |
| 호환 라우트 | `app/(with-base-layout)/oper/exmn/status/page.tsx` | 기존 통합 화면을 그대로 렌더링하는 별칭 |
| 타입 | `src/features/exmn/model/exmn-business-types.ts` | `ExmnJobType`, `ExmnSectionSummaryRow` 추가 |
| API | `src/features/exmn/api/exmn-business-api.ts` | `GET /api/v1/exmnBusinesses/{id}/sections?jobType=PPL|MNHL` 추가 |
| 샘플 | `src/features/exmn/model/exmn-sample-data.ts` | `sample=Y` 전용 관로·맨홀 처리분구 집계 |
- 원본 화면설계서 슬라이드 3·4처럼 기존 사업등록·결과등록 버튼과 사업목록이 있는 화면에서 행 선택 시 바로 아래에 조사 상세현황을 펼친다. 별도 상세 페이지로 이동하지 않는다.
- 사용자 표출 명칭은 `CCTV조사` 대신 `관로조사`, `맨홀조사`를 사용하고 서버 구분값은 `PPL`, `MNHL`을 유지한다.
- 사업 행 선택 시 기본 `관로조사` 탭을 열고, 탭별 처리분구 합계와 상세 행을 표시한다.
- 이 1차 구현에서는 `상세보기`, `다운로드`, `지도보기`를 버튼 진입점과 미연결 안내로 시작했으며, 이후 상세보기는 15절 범위로 완료했다.
- 엑셀 다운로드와 지도보기는 명시적 후속·보류 상태를 유지한다.
## 12. 조사현황·집계 1차 확인 게이트
- `/oper/exmn/register?sample=Y`의 기존 사업목록에서 샘플 사업을 선택하면 같은 화면 하단 상세현황이 표시되는지 확인한다.
- 관로조사 탭은 대상연장·주행거리·전체·구조·운영·기타 열, 맨홀조사 탭은 전체·구조·운영·기타 열을 표시하는지 확인한다.
- 탭 이름에 `CCTV조사`가 남지 않고 `관로조사`, `맨홀조사`가 표시되는지 확인한다.
- 세 기능 버튼은 선택 사업이 없을 때 비활성이고 선택 후 후속 연결 안내를 표시하는지 확인한다.
- 실제 상세보기·다운로드·지도 페이지가 구현 완료된 것으로 판정하지 않는다.
- 사용자 최종 확인 결과 목록 전체 셀 선택, 관로조사·맨홀조사 전환, 목록·상세 배치와 10행 샘플 집계는 통과했다.
- 조사관리 상세현황 화면 단계 완료 후 상세보기는 15절에서 추가 완료했다. 다운로드, 지도보기와 실 API는 후속 범위로 남긴다.
## 13. 2026-07-23 git status 기준 실제 변경 범위
- 프론트 저장소의 현재 코드 변경은 추적 파일 9개, 신규 파일 15개로 총 24개다.
- 추적 변경: `app/(with-base-layout)/layout.tsx`, `app/components/lib/utils/fileUploadUtil.tsx`, `src/app/styles/components.css`, `src/features/exmn/index.ts`, `src/features/windowPopup/model/{popupDefinitions,types}.ts`, `src/features/windowPopup/registry.tsx`, `tailwind.config.ts`, `packages/common/business_components/src/ui/table/index.tsx`.
- 신규 라우트: `app/(with-base-layout)/oper/exmn/{register,status}/page.tsx`.
- 신규 조사사업 모듈: `src/features/exmn/{api,lib,model,ui}/**`의 사업 API·검증·타입·샘플·등록/결과 팝업·상세현황·PPL 상세 파일 13개.
- 신규 WindowPopup content: `src/features/windowPopup/ui/{ExmnBusinessRegisterPopupContent,ExmnResultRegisterPopupContent}.tsx`.
- 현재 `layout.tsx` diff는 파일 끝 줄바꿈 정규화뿐이며 공통 `BaseLayout → PageLayout` 구조와 Header·Footer 컴포넌트는 변경하지 않는다. 조사관리 화면 높이·문서 overflow 제어는 `ExmnRegistrationPage`가 담당한다.
- `components.css`에 추가했던 조사사업 전용 `sn-exmn-grid-scroll`은 팝업 디자인 회귀 차단을 위해 제거했다. 현재 파일의 의미 있는 CSS diff는 없으며 조사 목록·상세 gutter는 각 컴포넌트 인라인 스타일로 유지한다.
- `tailwind.config.ts`의 `./src/**/*` 스캔 경로를 복구해 `src/features` 팝업의 grid·임의 폭 클래스가 CSS에 생성되도록 했다.
## 14. 조사사업 목록·상세현황 최종 완료 범위
### 14.1 조사사업 목록
- 검색조건 간격을 공통 조회영역에 맞추고 사업차수·사업연도·시도·시군구의 선택 동작을 확인했다.
- 목록 전체 행 영역에서 사업을 선택할 수 있으며 선택 행을 강조한다.
- 정렬 헤더는 공통 디자인을 따르고 `오름차순 → 내림차순 → 기본`으로 순환한다. 서버 응답과 `sample=Y` 데이터에 같은 정렬 규칙을 적용한다.
- 검증용 사업 샘플을 4건으로 늘렸다. 목록에는 3행이 하단선까지 온전히 보이고 4번째부터 내부 세로 스크롤로 확인한다.
- 상세현황 표와 동일한 좌우 여백·가로 폭·스크롤바 공간을 사용하고 스크롤 중 상·하단선이 잘리지 않게 보정했다. 스크롤바 공간 예약은 두 조사 그리드의 인라인 스타일로 격리해 WindowPopup 디자인에 영향을 주지 않는다.
### 14.2 조사 상세현황
- 관로조사·맨홀조사는 보이는 전체 영역이 실제 클릭 가능한 독립 button이다.
- 사업이나 조사유형이 바뀌면 해당 집계를 다시 조회하며, 요청 ID로 이전 응답이 최신 선택을 덮어쓰는 것을 차단한다.
- 합계는 `NO+처리분구` 두 셀을 실제 병합하고 문구를 중앙 정렬했다. 합계 행 배경은 `#d2deef`다.
- 헤더는 상세 그리드 상단에 고정하고, 다음 행 일부가 하단에 노출되지 않는 인라인 `maxHeight`와 내부 세로 스크롤을 적용했다.
- 목록과 상세현황은 정교한 셀 전체 클릭·병합·높이 처리를 위해 공통 Table primitive로 직접 구성한 화면 전용 표를 사용한다.
### 14.3 페이지 높이
- 임시 조사관리 전용 공통 레이아웃 래퍼·헤더 CSS는 최종안에서 사용하지 않는다.
- 페이지 시작 위치와 실제 Footer 높이로 사용 가능 높이를 계산하고 Footer 변화는 `ResizeObserver`로 재계산한다.
- 조사관리 화면이 열려 있는 동안 문서 전체 스크롤을 잠그고 목록·상세 그리드 내부 스크롤을 사용하며, 화면을 벗어나면 기존 문서 overflow 값을 복원한다.
## 15. 관로·맨홀 상세보기 팝업 완료 범위
| 구분 | 경로 | 완료 내용 |
| --- | --- | --- |
| 화면 정의 | `src/features/exmn/model/exmn-detail-table.ts` | 관로·맨홀 고정 열, 이상항목 코드·명칭, 구조·운영 구분 |
| 샘플 | `src/features/exmn/model/exmn-sample-data.ts` | 조사유형별 3개 상세 행과 코드별 대·중·소 건수 |
| 상세 팝업 | `src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx` | 조회조건, 다단표, 정렬, 고정열, 소계·합계, 표 내부 스크롤 |
| WindowPopup content | `src/features/windowPopup/ui/ExmnSurveyDetailPopupContent.tsx` | query 파라미터를 화면 사업·조사유형으로 변환 |
| 팝업 등록 | `src/features/windowPopup/{model/popupDefinitions.ts,registry.tsx}` | `exmnSurveyDetail`, `1500×720`, 필수 파라미터 등록 |
| 상세 진입 | `src/features/exmn/ui/ExmnStatusDetailPanel.tsx` | 선택 사업·탭 컨텍스트 전달, 버튼 순서·다운로드 색상 보정 |
- 공통 WindowPopup의 파란 헤더와 공통 그리드 헤더 스타일을 사용한다.
- 사업정보 요약 띠를 제거하고 처리분구·조사대상·번호 조건과 조회·다운로드를 본문 상단에 배치한다.
- 구조적·운영적 내부결함 고정 열을 제거하고 보고서번호부터 주행거리까지 정렬을 지원한다. 보고서번호·관로/맨홀번호는 가로 스크롤 고정 열로 사용한다.
- 이상항목은 관로·맨홀 코드값을 열 식별자로 유지하고 화면은 대·중·소 3열로 표시한다.
- 소계는 심각도별 값을, 합계는 항목별 병합 총합을 표시한다. 소계·합계 라벨은 규격까지의 병합영역을 사용하되 가로 스크롤 후에도 고정열 오른쪽에서 보이게 한다.
- 조사사업 목록 `NO`와 조사 상세현황의 모든 데이터 열에 정렬을 제공한다. 상세현황 합계 행은 정렬 대상에서 제외해 상단에 고정한다.
- 상세현황 버튼 순서는 `상세보기 → 지도보기 → 다운로드`이며 다운로드만 기존 초록색 스타일을 사용한다.
- 사용자 화면 확인으로 상세보기 프론트 단계는 완료했다. 실 상세 API, 엑셀 다운로드, 지도보기는 완료 범위가 아니다.
## 16. 결함 분류별 상세보기와 팝업 최종 보완 범위
| 구분 | 완료 내용 |
| --- | --- |
| 상세현황 행 선택 | 처리분구 행 전체 선택·강조와 상세보기 대상을 동기화 |
| 결함 건수 링크 | 1개 이상인 구조적·운영적 결함 건수를 파란색 밑줄 링크로 표시 |
| 팝업 분기 | 상단 상세보기 `ALL`, 건수 링크 `STRUCTURAL|OPERATIONAL` 전달 |
| 분류별 상세표 | 선택 분류의 코드 열과 결함이 있는 시설 행만 표시 |
| 코드 표기 | 관로·맨홀 이상항목 헤더를 `명칭 (코드)`로 표시 |
| 표 정리 | 구조적·운영적 내부결함 고정 열 제거, 표시 열 기준 소계·합계 재계산 |
| 사업등록 팝업 | 공통 팝업 디자인 통일, 처리분구 검색·검색결과 선택·초기화 |
| 결과등록 팝업 | 공통 팝업 디자인 통일, 사업정보를 읽기 전용 요약 텍스트로 정리 |
- 수정 대상은 `src/features/exmn/**`와 `src/features/windowPopup/**` 안으로 제한한다.
- 백엔드 API·DTO·DB는 수정하지 않는다.
- 완료 게이트는 관로·맨홀 각각 `ALL|STRUCTURAL|OPERATIONAL` 화면, 코드 헤더, 필터 결과와 소계·합계가 일치하는 것이다.
@@ -0,0 +1,355 @@
# 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 시작 대기 |
| 2026-07-22 | FE-1 | 사업 목록·등록, 파일 검토/확정, PPL 집계·시설·단위상세 구현 |
| 2026-07-22 | 화면 2단계 완료 | 조사사업 목록과 사업등록·조사결과 업로드 WindowPopup 사용자 확인 완료 |
| 2026-07-23 | FE-1 조사현황 보정 | 목록·상세 가로 기준 통일, 사업 행 전체 선택, 목록·상세 내부 스크롤 통일 |
| 2026-07-23 | 화면 3단계 완료 | 같은 화면의 관로·맨홀 종합집계, 합계, 내부 스크롤, 요청 순서 보호 사용자 확인 완료 |
| 2026-07-23 | 팝업 회귀 복구 | Tailwind `src` 스캔 복구와 핵심 열·폭 인라인 고정 후 완료 디자인 재확인 |
| 2026-07-24 | 상세보기 화면 완료 | 관로·맨홀 WindowPopup 다단표, 조회·정렬, 고정열, 소계·합계 사용자 확인 완료 |
### 3.1 현재 구현 경계
- 화면 1단계 `처리분구관리`, 화면 2단계 `조사사업 관리`, 화면 3단계 `조사 상세현황`까지 프론트 UI와 `sample=Y` 검증 흐름을 완료했다.
- 화면 3단계의 관로·맨홀 처리분구별 종합집계와 후속 관로·맨홀 상세보기 팝업까지 완료했다.
- 상세보기는 `sample=Y` 데이터와 화면 동작 기준으로 완료했으며 실 상세 API와 엑셀 다운로드 연동은 다음 프론트·백엔드 통합 작업이다. `지도보기`는 보류한다.
- 백엔드 API·서비스·Repository·DB와 실데이터 통합 검증은 아직 시작하지 않았다.
## 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: 목록과 사업등록·결과등록 WindowPopup 구현·개발 서버 컴파일·사용자 UI 확인 완료, PPL 집계·상세 확인 및 단계 커밋 대기
- FE-2~FE-4: 미착수
- 백엔드: 사용자 지시대로 프론트 전체 완료 전 미착수
## 6. BE 공유 사항
- 조회 성공 데이터는 활성 처리분구 배열 또는 Spring `content` 형태를 FE가 수용한다.
- 참조 응답 필드는 `businessCount`, `facilityCount`, 선택적 `totalCount`다.
- 일괄 저장은 전 항목 단일 트랜잭션이어야 하며 참조 중 삭제는 `409`로 거부해야 한다.
- 저장 성공 응답은 `createdCount`, `updatedCount`, `deletedCount`를 제공한다.
## 7. 남은 리스크
- 실제 백엔드가 없어 성공·409·권한 오류의 네트워크 연동을 확인하지 못했다.
- 메뉴 원천·권한 및 import 경계 문제는 사용자 화면 진입 확인으로 해결됐다.
- 프로덕션 빌드와 실 API 동작은 아직 확인하지 않았으며 백엔드 구현·통합 단계에서 검증해야 한다.
- 저장 후 활성 후보가 사업등록 화면에 즉시 반영되는지는 FE-1과 백엔드 구현 후 확인한다.
## 8. 흡수한 기존 문서
- 없음
## 9. FE-1 사업정보·조사결과 등록
### 9.1 사업 목록과 등록
- 사업차수, 사업년도, 시도, 시군구, 사업명은 입력 상태와 적용 조회조건을 분리했다.
- 사업년도는 `YearRangeSelectBox` 모듈의 단일 연도 옵션을 사용하는 `SelectBox`로 바꿨다.
- 시도·시군구는 I/I 분석과 같은 `CtpvSggPrcsFcltSelectBox`를 사용하며 양평 고정값과 하수처리시설 선택 props를 두지 않았다.
- 검색조건과 조회·사업등록·결과등록을 한 상단 카드에 배치하고 사업명 입력은 `w-52`로 제한했다.
- 상단의 임의 `PPL 집계·상세` 버튼은 제거했다. PPL 집계·상세 구현은 조사현황의 건수 클릭 등 정식 진입점에 연결할 수 있도록 모듈로 유지한다.
- 목록 열은 NO, 사업차수, 사업년도, 시도, 시군구, 사업명, 처리구역·분구(개소), 정밀조사 연장(km), 보고서 순서다.
- 목록은 0-base page, 20건, 허용 정렬 문자열을 `GET /api/v1/exmnBusinesses`에 전달한다.
- 사업등록은 사업연도·사업명·차수·행정구역·정밀조사연장·총사업비와 활성 처리분구 ID 배열을 저장한다.
- 사업 저장 후에만 처리분구별 PPL/MNHL 시트 생성과 통합 입력양식 다운로드를 활성화한다.
### 9.2 파일 검토와 최종 저장
- 파일 선택 시 첨부서비스로 즉시 전송하지 않고 파일 구분별 확장자, 빈 파일, 200MB 상한을 먼저 검사한다.
- PPL 또는 MNHL 파일 1개 이상을 `imports/validate`에 보내고 오류 위치를 그리드로 표시한다.
- 정상 검토 전에는 최종 저장을 비활성화한다.
- 최종 저장은 선택 파일 Tus 업로드, 첨부키 PUT, 동일 PPL/MNHL 파일·검토 SHA-256·첨부키 multipart commit 순서다.
- 기존 파일 업로드 유틸에 `handleError` 콜백을 추가해 실패가 최종 저장 대기 상태로 남지 않고 화면 오류로 귀결되게 했다.
### 9.3 PPL 집계와 상세
- 처리분구별 대상연장, 주행거리, PPL 전체/구조/운영/기타 수를 서버 페이징·정렬 그리드로 표시한다.
- 결함 건수 클릭은 처리분구와 결함유형을 반영해 `jobType=PPL` 시설 목록을 조회한다.
- 시설 목록은 처리분구, 보고서번호, GIS 관로관리번호, 상·하류 맨홀, 관종/규격, 연장, 구조·운영등급을 표시한다.
- 시설 행을 선택하면 서버 정규화 `unitSections`와 선택 단위의 상태·결함 기록을 표시한다.
- 미확정 보고서 매핑은 비활성 버튼과 안내로 유지한다.
### 9.4 UI 기반 보완
- `sn` Tailwind 설정에 빠져 있던 `./src/**/*`를 content에 추가해 `src/features` 신규 클래스가 생성되게 했다.
- 기존 `SearchDiv`, `ListDiv`, `UiTable`, Button/Input과 공통 `windowPopup` 등록 패턴을 재사용했다.
- 라우트는 FE-0에서 확인한 Next 경계 문제를 반복하지 않도록 client UI를 직접 import한다.
- 사용자 화면 확인에서 분리돼 보이던 상단 액션과 검색조건을 같은 `SearchDiv` 자식 flex 영역으로 합쳤다.
### 9.5 영향과 롤백
- 신규 `/oper/exmn/register`와 `features/exmn` FE-1 파일을 제거하면 화면/API 계약을 롤백할 수 있다.
- Tus 실패 콜백과 Tailwind `src` 스캔은 `sn` 앱 전체에 적용되지만 기존 호출 계약을 깨지 않는다.
- 백엔드, DB, 타 앱, 공통 비즈니스 컴포넌트는 변경하지 않았다.
### 9.6 사업등록·결과등록 WindowPopup 전환과 샘플 검증
- 상단 `사업등록`, `결과등록`은 인라인 `UiModal` 대신 `openWindowPopup()`을 사용한다.
- 공통 popup key는 `exmnBusinessRegister`, `exmnResultRegister`이며 `/popup/[popupKey]` registry에서 각각 업무 화면을 렌더링한다.
- 사업등록 팝업은 자체 브라우저 컨텍스트에서 시도·시군구 store를 다시 로드하고, 활성 처리분구를 `localeCompare(..., "ko")` 기준 가나다순으로 정렬해 체크박스 다중 선택으로 제공한다.
- 사업 저장 완료와 결과 저장 완료는 `window.opener.postMessage`로 부모 목록에 전달한다. 팝업 차단 시 부모 화면에 허용 안내를 표시한다.
- 결과등록 팝업은 조사 결과, CCTV 조사 결과(관로), CCTV 조사 결과(맨홀), 사업보고서, 조사 위치도 5개 행을 표시하며 각 행의 파일명·찾기와 조사 원본의 데이터 검토 진입을 제공한다.
- 정상 검토 전 저장 버튼은 비활성이고 실제 모드의 Tus 첨부→첨부키 연결→commit 계약은 유지한다.
- 백엔드 데이터가 없는 사용자 화면 검증을 위해 `/oper/exmn/register?sample=Y`만 샘플 모드로 사용한다. 일반 URL의 API 빈 결과를 자동 샘플로 대체하지 않는다.
- 샘플 모드는 사업목록, 가나다순 처리분구, 정상 검토 요약을 제공하되 실제 API 저장·첨부·다운로드는 수행하지 않는다.
### 9.7 사업등록 팝업 사용자 확인 보정
- 사업등록 popup definition을 `1180×820`에서 `920×620`으로 축소하고 불필요한 하단 여백을 줄였다.
- 상단 `닫기`, `양식 생성`을 제거했다. `양식 받기`는 사업 저장 후 양식 생성과 다운로드를 한 번에 수행한다.
- `양식 받기`, `조사보고서 양식`은 기존 엑셀 다운로드 버튼의 전역 `green` 스타일과 흰색 글자를 사용한다.
- 화면 입력은 5개만 노출하고 첫 줄에 `사업년도·시도·시군구·정밀조사연장`, 둘째 줄에 `사업명`을 배치한다. API 필수 계약인 사업차수와 총사업비는 신규 기본값 `1`, `0`으로 전달한다.
- Radix Select의 서버·클라이언트 ID 불일치를 막기 위해 사업등록·결과등록 WindowPopup content를 client mount 이후 렌더링한다.
- 개발 환경의 목록 헤더에 `샘플 데이터 보기`를 제공한다. 이 버튼은 URL에 `sample=Y`를 명시해 기존 격리 원칙을 유지하며, 사업목록과 처리분구 샘플을 함께 활성화한다.
- 사용자 재확인에 따라 팝업의 `샘플 화면` 배지를 제거하고 처리분구 목록을 고정 높이 `21rem`으로 확장했다. 3열 7행으로 21개까지 한 번에 표시하고 초과 후보는 목록 내부 세로 스크롤로 확인한다.
- 정밀조사연장은 브라우저 증감 스피너가 없는 `text`+`inputMode=decimal` 입력으로 변경하고 숫자·소수점만 허용한다. 최대 10자, 입력 폭 `9rem`으로 첫 줄 안에서 표시한다.
- 결과등록 popup definition은 Chrome 외곽 프레임을 포함한 사용자 목표 크기 약 `980×410`에 맞춰 콘텐츠 창 설정을 `980×340`으로 확정하고 샘플 배지와 닫기 버튼을 제거했다.
- 결과등록 상단은 `사업차수 → 사업년도 → 행정구역 → 사업명` 순서로 한 줄 표시한다. 짧은 항목은 좁고 사업명은 넓은 `0.7fr/0.8fr/1.2fr/2.3fr` 비율과 `gap-8` 간격을 사용한다.
- 파일 5종 업로드 영역은 강한 셀 격자 대신 기존 화면처럼 연한 외곽선·헤더 배경·가로 행 구분선만 사용하는 4열 폼 목록으로 구성한다.
- popup root는 `h-dvh`·`overflow-hidden`으로 브라우저 가로·세로 스크롤을 차단하고, 검토결과가 추가돼 실제 높이가 넘을 때만 본문 내부를 세로 스크롤한다.
- 상위 popup layout의 `html/body`에서 남는 문서 스크롤을 제거하기 위해 결과 팝업 mount 동안 두 요소의 overflow를 잠그고 종료 시 원래 값을 복원한다.
- 파일 헤더는 `py-2.5`, 각 업로드 행은 `py-3`으로 늘려 남는 세로 공간을 활용한다.
- 파일 헤더와 데이터 행에 동일한 `gap-3`을 적용해 `찾기`·`데이터 검토` 열의 중심선을 일치시켰다.
- 파일 선택 완료 시 파일명 영역을 파란색 굵은 글씨·연한 파란 배경·파란 테두리로 바꿔 미선택 안내와 구분한다.
- 사용자가 사업등록과 조사결과 업로드 WindowPopup의 최종 크기·배치·스크롤·파일 선택 표시를 확인해 두 팝업 UI를 완료 판정했다.
### 9.8 조사현황·처리분구 집계 1차 골격
- 원본 화면설계서 슬라이드 3·4를 렌더링해 사업 목록의 행 선택 후 같은 화면 하단에 조사 상세현황이 표시되는 구조를 확인했다.
- 첫 구현에서 조사 상세현황을 별도 `ExmnStatusPage`의 목록에 배치했으나, 사용자 확인으로 PPT 3·4는 같은 화면의 선택 전·후 상태임을 재확인했다.
- `ExmnStatusDetailPanel`을 분리해 현재 사용 중인 `ExmnRegistrationPage` 사업목록 바로 아래에 결합했다. `/oper/exmn/status`도 별도 화면을 만들지 않고 동일 통합 화면을 렌더링한다.
- 행 선택 시 기본 조사유형을 `PPL`로 초기화하고 `관로조사`, `맨홀조사` 탭에서 처리분구별 집계와 합계 행을 표시한다.
- 관로 집계는 대상연장·주행거리·전체 이상·구조·운영·기타를 표시하고, 맨홀 집계는 전체 이상·구조·운영·기타를 표시한다.
- 사용자 화면 명칭은 `CCTV조사`에서 `관로조사`로 정정하되 API·DB 조사작업 구분은 `PPL|MNHL`을 유지한다.
- 선택 사업의 집계 API 진입은 `/api/v1/exmnBusinesses/{exmnBizId}/sections?jobType=PPL|MNHL` 계약으로 추가했다.
- 백엔드 데이터가 없는 화면 확인은 기존 `/oper/exmn/register?sample=Y` 흐름에서 샘플 사업·처리분구 집계를 사용하며 일반 URL은 실제 API 계약을 유지한다.
- 이 1차 라운드에서는 `상세보기`, `다운로드`, `지도보기`를 버튼과 미연결 안내로 제공했다. 상세보기는 이후 11절에서 구현을 완료했고 다운로드·지도보기는 후속·보류 상태다.
- 선택 사업의 사업명·차수·연도·행정구역을 반복 표시하던 요약 띠는 사용자 요청으로 제거했다.
- 관로조사·맨홀조사는 별도 버튼을 만들지 않고 기존 공통 `UITab`을 재사용한다. 사용자 후속 확인에 따라 underline 대신 분석도구의 활성 메뉴와 가까운 기본 파란 활성 탭을 컴팩트한 크기로 적용했다.
- 합계는 일반 데이터 행에 빈 NO 셀을 두지 않고 `NO+처리분구` 두 열을 병합한 전용 연한 배경 행으로 표시한다.
- 기본 `Table`에 외부 스크롤과 sticky `thead`를 결합했던 1차 보정은 이중 스크롤 컨테이너 때문에 스크롤 중 행 일부가 헤더 위로 잘려 보이는 문제가 확인됐다.
- 종합집계표를 기존 화면에서 사용하는 공통 `UiTable`로 교체하고, `UiTable` 내부 표 래퍼 하나만 스크롤 컨테이너로 사용한다. 행 패딩과 내부 높이를 `16.7rem`으로 조정해 헤더·합계·처리분구 3행의 하단선까지 온전히 표시하고, 초과 행만 그리드 내부에서 스크롤한다.
- 우측 페이지 스크롤은 집계표 높이가 아니라 공통 `BaseLayout`이 실제 `6rem` 헤더를 `50px`로 차감하던 최소 높이 불일치가 원인이었다. 1차 전용 선택자는 실제 DOM의 `SidebarProvider` 중간 래퍼를 누락해 적용되지 않았으며, SSR HTML을 기준으로 `.sn-exmn-base-layout > div > .flex-1 > .flex > .flex-1`로 정정해 `/oper/exmn/**` 경로에만 `min-height: calc(100vh - 6rem)`을 적용했다.
- `UiTable`이 본문 `colSpan`을 지원하지 않는 제약 안에서 합계 첫 두 셀의 경계선을 제거하고 첫 셀의 표시 영역을 `NO+처리분구` 너비만큼 확장해 `합계` 문구를 두 열 중앙에 배치했다.
- 상세현황 표시 시 카드가 일반 문서 흐름에서 Footer를 밀어 페이지 전체 스크롤을 만들던 구조를 보정했다. 조사사업 경로의 본문 높이를 `100dvh - 6rem`으로 고정하고, PageLayout·조사사업 페이지·상세 패널을 `min-height: 0`인 flex 구조로 연결해 남은 공간을 상세 패널에 배분한다. 상세 데이터 초과분은 종합집계 `UiTable` 내부에서만 스크롤한다.
- 조사사업 선택에 공통 `UiTable`의 신규 `rowClick` 계약을 적용했으나 사용자 재확인에서 정밀조사 연장·보고서 외 셀의 클릭이 동작하지 않았다. 신규 공통 계약은 철회하고 기존에 사용 중인 `cellClick`을 전체 열에 적용하는 방식으로 교체했다.
### 9.9 조사사업 목록·상세현황 그리드 동작 통일
- 단순히 `1.2rem` 값을 각각 지정하던 구조는 목록의 `UiTable` margin과 상세의 부모 padding이 달라 실제 표 경계가 일치하지 않았다. `UiTable.noOuterMargin` 옵션을 추가하고 두 표 모두 `mx-[1.2rem]`인 동일한 외부 스크롤 래퍼만 가로 여백을 소유하게 했다.
- 두 스크롤 래퍼에 `scrollbar-gutter: stable`을 적용해 상세 표에 세로 스크롤바가 생겨도 목록과 상세의 표 가로 기준이 달라지지 않게 했다.
- 행 선택은 검증되지 않은 `rowClick` 확장을 제거하고 기존 `cellClick` 계약을 사용한다. `clickableHeadIds`를 제한하지 않아 NO부터 보고서까지 모든 데이터 셀이 같은 사업 선택 함수를 호출한다.
- 조사사업 목록의 화면 페이지네이션을 제거하고 최초 페이지의 최대 1,000건을 조회한다. 목록은 최대 높이 `16.7rem` 이후 내부 스크롤하고, 상세 집계는 고정 최대 높이를 제거해 페이지 전체 스크롤이 생기지 않는 범위의 남은 카드 높이를 모두 사용한 뒤 초과 데이터만 내부 스크롤한다.
- 목록 제목의 전체 건수는 API `totalElements`를 그대로 표시하고, 표 내부 페이지 이동 UI만 숨긴다.
- 현재 1,000건 상한은 프론트 선행 구현용이다. 실제 운영 데이터가 이를 넘을 가능성이 있으면 백엔드 통합 단계에서 무제한 조회 대신 무한 스크롤 또는 커서 기반 조회 계약을 확정한다.
### 9.10 클릭 영역·목록 높이·샘플 행 최종 보정
- 목록 `16.7rem`은 공통 표의 헤더·3개 행·내부 padding 합계보다 작아 3건만 있어도 세로 스크롤과 마지막 행 하단 잘림이 발생했다. 목록 최대 높이를 `18rem`으로 조정해 3건은 스크롤 없이 하단선까지 표시하고 4건 이상부터 목록 내부 스크롤을 사용한다.
- 셀별 `cellClick` 이후 공통 `UiTable`에 선택적 `rowClick` 계약을 추가했지만, 사용자 재확인에서 정밀조사 연장 우측 일부 영역만 선택되고 앞쪽 영역은 반응하지 않는 문제가 남았다. 해당 공통 확장도 검증 실패로 판정한다.
- 관로조사·맨홀조사는 공통 `UITab`의 스타일 선택자에 의존하지 않고 보이는 크기와 실제 클릭 요소가 같은 `80×32px` native button 2개로 구성한다. `role=tablist/tab`, `aria-selected`를 유지한다.
- 당시 화면 검증용 세 조사사업의 처리분구 수를 모두 10개로 맞췄다. 이후 목록 내부 스크롤 검증을 위해 9.13절에서 네 번째 사업 샘플을 추가했으며, 샘플 상세 집계는 합계 행 외에 관로·맨홀 각각 처리분구 10행을 제공한다.
### 9.11 조사사업 목록 클릭 영역 구조 교체
- 공통 `UiTable`의 `cellClick`·`rowClick`을 반복 확장하는 방식을 철회하고, 조사사업 목록만 공통 `Table` 스타일을 사용하는 화면 전용 표로 교체했다. 상세 집계의 `UiTable` 사용은 유지한다.
- 각 데이터 셀은 padding을 제거한 뒤 `min-height: 4rem`, `width: 100%`인 실제 `<button>`으로 채웠다. 따라서 텍스트 주변이 아니라 보이는 셀 면적 전체가 동일한 `selectedBusinessId` 선택 함수를 호출한다.
- `<tr>`에도 동일한 선택 함수를 보조로 연결해 셀 경계 외의 행 영역을 선택할 수 있게 했다.
- 정렬 헤더도 각 헤더 전체 폭의 `<button>`으로 구성하고 기존 오름차순·내림차순·기본 순환과 서버 재조회 계약을 유지한다.
- 실패했던 공통 `UiTable.rowClick` 계약은 제거해 다른 화면에 불필요한 공통 영향을 남기지 않는다.
### 9.12 조사관리 상세현황 사용자 확인 완료
- 사용자가 조사사업 목록의 전체 셀 선택, 관로조사·맨홀조사 전환, 상세 집계 10행과 목록·상세 배치를 최종 확인해 조사관리 상세현황 화면을 완료 판정했다.
- 이번 완료 범위는 조사사업 행 선택 후 같은 화면 하단에 표시되는 관로·맨홀 종합집계까지다.
- 당시 후속이었던 `상세보기` 화면은 11절에서 완료했다. 다운로드 연동과 `지도보기`, 실 API·백엔드·DB 연동은 별도 단계로 유지한다.
### 9.13 최종 소스 상태 동기화
- 검색조건의 사업차수·사업연도·시도·시군구·사업명 간격을 조정하고 기존 연도·I/I 행정구역 선택 동작을 유지했다.
- 조사사업 목록은 공통 Table primitive로 직접 구성한다. 각 데이터 셀 전체와 행 여백이 같은 사업 선택 함수를 호출하고 `data-state=selected`로 선택 행을 강조한다.
- 헤더는 공통 디자인 정렬 표시를 사용하고 `오름차순 → 내림차순 → 기본`으로 순환한다. 서버에는 같은 sort 파라미터를 전달하며 서버 응답과 샘플 배열 모두 동일한 `sortBusinessList` 비교 함수를 거친다.
- 샘플 사업 `SAMPLE-BIZ-004`를 추가했다. 목록 높이는 인라인 `maxHeight: 16rem`으로 제한해 3행을 온전히 표시하고 4번째부터 내부 세로 스크롤한다.
- 목록과 상세는 각각 인라인 `scrollbarGutter: "stable"`로 스크롤바 공간을 예약하고 동일한 좌우 기준을 사용한다. sticky 헤더와 별도 하단 보정선으로 스크롤 중 경계선 잘림을 막는다.
- 관로조사·맨홀조사는 각각 보이는 전체 `80×32px`가 실제 button이다. 탭 변경 시 `jobType`을 바꾸고 집계를 재조회한다.
- 상세 조회는 증가하는 `summaryRequestIdRef`를 사용해 사업·탭을 빠르게 바꿨을 때 과거 응답·오류·loading 종료가 최신 상태를 덮어쓰지 못하게 한다.
- 상세 합계 행은 실제 `colSpan=2`로 `NO+처리분구`를 병합하고 중앙 정렬한다. 합계 전 셀 배경은 `#d2deef`다.
- 상세 헤더는 스크롤 상단에 고정한다. 상세 그리드도 인라인 `maxHeight: 23.8rem`을 적용해 다음 행 일부가 하단에 걸쳐 보이지 않고 초과 행만 내부 스크롤한다.
- 과거 보정 과정에서 사용한 조사관리 전용 BaseLayout 래퍼·헤더 CSS는 제거했다. 공통 `layout.tsx`는 Git 원본의 `BaseLayout → PageLayout` 구조를 유지하며 Header·Footer 컴포넌트는 수정하지 않는다.
- `ExmnRegistrationPage`는 자신의 시작 위치와 실제 Footer 높이를 측정해 본문 높이를 계산한다. Footer 변화는 `ResizeObserver`, 창 크기 변화는 `resize`로 반영하고 화면 이탈 시 이전 `html/body` overflow를 복원한다.
- 공통 `UiTable`에는 선택 행 `data-state`와 `noOuterMargin`을 추가했지만, 현재 조사사업 목록과 상세현황은 실제 병합·셀 전체 클릭·sticky/스크롤 높이를 정밀 제어하기 위해 직접 구성한 테이블을 사용한다.
### 9.14 사업·결과 등록 팝업 디자인 회귀 복구
- 조사사업 목록·상세의 스크롤바 공간을 맞추기 위해 전역 `components.css`에 추가했던 `sn-exmn-grid-scroll`을 제거했다.
- 목록과 상세의 `scrollbar-gutter: stable`은 각 스크롤 컨테이너의 인라인 스타일로 옮겨 조사관리 그리드에만 적용한다.
- 회귀의 직접 원인은 `tailwind.config.ts`에서 `./src/**/*` 스캔 경로가 빠져 `src/features`의 `grid-cols-[...]`, `max-w-[480px]` 등 임의값 클래스가 생성되지 않은 것이다.
- `./src/**/*`를 다시 추가하고, 결과등록의 사업요약·파일행 열은 인라인 `gridTemplateColumns`, 사업등록의 사업명 폭은 인라인 `maxWidth`로도 고정해 스캔 누락 시 레이아웃이 무너지지 않게 했다.
- 사업등록 popup은 확정 크기 `920×620`, 처리분구 영역 `21rem`으로 복구했다. 결과등록은 `980×340`을 유지한다.
- 따라서 조사사업 목록·상세의 폭 정렬은 유지하면서 WindowPopup의 기존 확정 디자인에는 전역 CSS 변경이 전파되지 않는다.
### 9.15 조사사업 목록 사업보고서 직접 다운로드
- 정보검색 보고서 탭의 파란 밑줄 파일 링크와 기존 운영 화면의 첨부서비스 Blob 다운로드 패턴을 재사용했다.
- 조사사업 목록에서 `bizRptpAtchFileId`가 있는 `보고서=있음` 셀만 파란색·밑줄·hover 색상으로 표시한다.
- 링크 클릭은 행 선택 이벤트의 전파를 중단하고 `/attach-service/api/v1/download/{atflId}`에서 원본 Blob을 받은 뒤 `${사업명}_사업보고서.pdf`로 내려받는다.
- `reportYn=Y`여도 첨부파일 ID가 없으면 다운로드 링크로 표시하지 않는다.
- `sample=Y`의 샘플 첨부 ID는 실제 파일로 위장하지 않고 다운로드 미지원 안내만 표시한다.
- 신규 백엔드 API·DTO·DB 변경은 없으며 결과등록에서 저장한 `bizRptpAtchFileId`를 그대로 사용한다.
## 10. 2026-07-23 git status 기준 실제 수정 파일
### 10.1 추적 코드 변경 9개
- `app/(with-base-layout)/layout.tsx`: 현재 diff는 파일 끝 줄바꿈 정규화뿐이며 공통 `BaseLayout → PageLayout` 원본 구조를 유지한다.
- `app/components/lib/utils/fileUploadUtil.tsx`: Tus 업로드 실패를 화면 Promise reject로 전달하는 `handleError` 콜백을 추가했다.
- `src/app/styles/components.css`: 조사사업 전용 `sn-exmn-grid-scroll`을 제거해 Git 기준 전역 스타일 내용으로 복구했다. `git status`에 줄바꿈/작업트리 표시는 남을 수 있으나 의미 있는 CSS diff는 없다.
- `src/features/exmn/index.ts`: 사업 API·검증·타입·등록/현황 화면 공개 export를 추가했다.
- `src/features/windowPopup/model/popupDefinitions.ts`: 사업등록 `920×620`, 결과등록 `980×340` popup 정의를 추가했다.
- `src/features/windowPopup/model/types.ts`: 사업 생성·결과 확정 opener 메시지 타입을 추가했다.
- `src/features/windowPopup/registry.tsx`: 조사사업 popup content 2종을 registry에 등록했다.
- `tailwind.config.ts`: `./src/**/*`를 content에 포함해 조사사업 popup의 grid·임의 폭 클래스 생성을 복구했다.
- `packages/common/business_components/src/ui/table/index.tsx`: 외부 스크롤 래퍼가 여백을 소유할 수 있는 `noOuterMargin`과 선택 행 `data-state` 동기화를 추가했다. 조사사업 최종 표는 직접 구성하므로 이 옵션은 공통 호환 계약으로만 남는다.
### 10.2 신규 코드 15개
- 라우트 2개: `app/(with-base-layout)/oper/exmn/register/page.tsx`, `app/(with-base-layout)/oper/exmn/status/page.tsx`.
- API·검증·모델 4개: `src/features/exmn/api/exmn-business-api.ts`, `lib/exmn-business-validation.ts`, `model/exmn-business-types.ts`, `model/exmn-sample-data.ts`.
- 조사사업 UI 7개: `ExmnBusinessRegisterPopupPage.tsx`, `ExmnRegistrationPage.tsx`, `ExmnResultRegisterPopupPage.tsx`, `ExmnStatusDetailPanel.tsx`, `ExmnStatusPage.tsx`, `PplFacilityDetailModal.tsx`, `PplSummaryModal.tsx`.
- WindowPopup content 2개: `ExmnBusinessRegisterPopupContent.tsx`, `ExmnResultRegisterPopupContent.tsx`.
### 10.3 문서 변경 4개
- FE 작업카드 1개, 통합 구현기록 1개, 통합 검증계획·결과 각 1개를 현재 코드와 사용자 확인 결과에 맞췄다.
- `tailwind.config.ts`는 팝업 디자인 회귀 복구의 실제 변경 파일로 포함한다.
## 11. 2026-07-24 관로·맨홀 상세보기 WindowPopup
- `exmnSurveyDetail` 팝업 키와 `1500×720` definition을 추가하고 선택 사업 ID·조사유형을 필수 query로 전달한다.
- `ExmnSurveyDetailPopupContent`가 query를 `ExmnBusiness`, `PPL|MNHL`, `sample=Y` 화면 속성으로 변환하고 `ExmnSurveyDetailPopupPage`를 렌더링한다.
- `exmn-detail-table.ts`에 관로 24개, 맨홀 23개 이상항목 코드·명칭·구조/운영 구분을 화면 열 정의로 분리했다.
- 관로·맨홀 모두 보고서번호, 관로/맨홀번호, 상·하류맨홀, 배수방식, 관종, 규격, 연장, 주행거리를 공통 고정 정보 열로 사용한다.
- 사용자 제공 엑셀 구조에 맞춰 이상항목마다 `대·중·소` 3열을 만들고 그룹·항목·심각도의 다단 병합 헤더를 직접 구성했다.
- 보고서번호부터 주행거리까지는 `오름차순 → 내림차순 → 기본`으로 정렬한다. 보고서번호와 관로/맨홀번호는 가로 스크롤 고정 열이다.
- 팝업 문서 overflow를 잠그고 표 영역에서만 가로·세로 스크롤한다.
- 현재 조회 결과의 연장·주행거리 및 코드별 대·중·소를 합산해 소계·합계를 표시한다. 소계는 심각도별, 합계는 항목별 3열 병합 총합이다.
- 소계·합계 라벨은 보고서번호부터 규격까지의 병합영역 중앙에서 시작하고, 규격이 스크롤로 사라진 뒤에도 고정열 오른쪽에 라벨과 경계선이 남도록 `scrollLeft` 기반 위치를 보정한다.
- `sample=Y`에서 관로·맨홀 각 3행을 제공하며 일반 URL은 빈 화면과 실제 API 후속 안내를 유지한다.
- 조사사업 목록 `NO` 정렬을 추가했다. `NO`는 서버 엔티티 필드가 아니므로 서버 sort에는 전달하지 않고 현재 기본 조회 결과를 정·역순으로 표시한다.
- 조사 상세현황은 합계 행을 별도 렌더링해 고정하고 나머지 처리분구 행만 전 열 정렬한다.
- 상세현황 액션을 `상세보기 → 지도보기 → 다운로드`로 배치하고 다운로드 버튼에 기존 초록색 스타일을 적용했다.
- 백엔드 API·DTO·DB는 변경하지 않았다. 실 상세조회와 엑셀 다운로드 계약은 후속이다.
## 12. 2026-07-24 결함 분류별 상세보기와 등록 팝업 최종 보완
### 12.1 조사 상세현황 선택과 링크
- `ExmnStatusDetailPanel.tsx`에서 처리분구 행 선택 상태를 상세보기 대상과 연결했다.
- 구조적·운영적 결함 건수가 1개 이상인 셀은 파란색 밑줄 링크로 렌더링한다.
- 링크 클릭 시 해당 행을 선택·강조하고 기존 `exmnSurveyDetail` WindowPopup을 연다.
- 상단 상세보기는 `defectType=ALL`, 구조적 링크는 `STRUCTURAL`, 운영적 링크는 `OPERATIONAL`을 전달한다.
- 건수가 0이면 일반 텍스트로 유지해 잘못된 빈 상세 팝업 진입을 막는다.
### 12.2 상세 팝업 필터
- `ExmnSurveyDetailPopupContent.tsx`가 `defectType`을 파싱하고 누락되거나 잘못된 값은 `ALL`로 정규화한다.
- `ExmnSurveyDetailPopupPage.tsx`는 선택 분류의 이상항목 열만 남기고 해당 열에 결함 건수가 있는 시설 행만 표시한다.
- 관로·맨홀 모두 동일한 필터 규칙을 사용하며 팝업 제목과 집계표 제목에 선택 분류를 표시한다.
- 구조적·운영적 내부결함 고정 열과 샘플 필드를 제거했다.
- 표시된 이상항목 헤더는 `명칭 (코드)` 형식이며 소계·합계는 현재 표시 행과 열 기준으로 재계산한다.
- 소계·합계 라벨 병합 범위는 보고서번호부터 규격까지, 실제 수치 합산 시작은 연장부터 유지한다.
### 12.3 사업등록·결과등록 WindowPopup
- `ExmnBusinessRegisterPopupPage.tsx`와 `ExmnResultRegisterPopupPage.tsx`를 상세보기와 같은 파란 헤더·옅은 파란 본문 배경으로 통일했다.
- 사업등록 처리분구 영역에 이름 검색, 검색결과 선택, 선택 초기화를 추가하고 선택 건수만 노출한다.
- 조사결과 업로드의 사업차수·사업연도·행정구역·사업명은 입력 컴포넌트처럼 보이지 않는 읽기 전용 텍스트로 정리했다.
- 팝업 크기와 본문 배치를 조정해 사업정보와 파일 업로드 표가 잘리지 않게 했다.
### 12.4 수정 파일과 연동 경계
- `src/features/exmn/model/{exmn-detail-table,exmn-sample-data}.ts`
- `src/features/exmn/ui/{ExmnBusinessRegisterPopupPage,ExmnResultRegisterPopupPage,ExmnStatusDetailPanel,ExmnSurveyDetailPopupPage}.tsx`
- `src/features/windowPopup/model/popupDefinitions.ts`
- `src/features/windowPopup/ui/ExmnSurveyDetailPopupContent.tsx`
- 백엔드 코드·API·DTO·DB는 변경하지 않았다. 후속 API는 `jobType`, 처리분구 식별자, `defectType`과 JSONB 코드별 건수 계약을 사용해야 한다.
@@ -0,0 +1,187 @@
import {
getBlobData,
getData,
postMutiPart,
postRawJson,
putData,
} from "@/components/lib/api";
import type {
ExmnAttachmentKey,
ExmnBusiness,
ExmnBusinessDetail,
ExmnBusinessSaveRequest,
ExmnBusinessSearchParams,
ExmnFileType,
ExmnImportCommitResult,
ExmnImportValidationResult,
ExmnJobType,
ExmnPage,
ExmnSectionSummaryRow,
ExmnTemplateResult,
PplFacility,
PplFacilitySearchParams,
PplSummaryRow,
} from "../model/exmn-business-types";
/** 조사사업 API의 공통 기준 경로다. */
const EXMN_BUSINESS_API_PATH = "v1/exmnBusinesses";
/** 공통 응답 원문 또는 data 래퍼 양쪽에서 실제 payload를 해석한다. */
const unwrapData = <T>(response: T | { data?: T }): T =>
typeof response === "object" && response !== null && "data" in response
? (response as { data: T }).data
: (response as T);
/** Spring Page 응답이 비어 있거나 아직 미구현이어도 안전한 빈 Page로 정규화한다. */
const toPage = <T>(value: Partial<ExmnPage<T>> | null | undefined): ExmnPage<T> => ({
content: Array.isArray(value?.content) ? value.content : [],
totalElements: Number(value?.totalElements ?? 0),
number: Number(value?.number ?? 0),
size: Number(value?.size ?? 20),
totalPages: Number(value?.totalPages ?? 0),
});
/** 조사사업 목록을 조회조건·0-base 페이지·서버 정렬 계약으로 요청한다. */
export const getExmnBusinesses = async (
params: ExmnBusinessSearchParams,
): Promise<ExmnPage<ExmnBusiness>> => {
const query = new URLSearchParams({
page: String(params.page),
size: String(params.size),
});
if (params.bizCycl?.trim()) query.set("bizCycl", params.bizCycl.trim());
if (params.bizYr?.trim()) query.set("bizYr", params.bizYr.trim());
if (params.ctpvNm?.trim()) query.set("ctpvNm", params.ctpvNm.trim());
if (params.sggNm?.trim()) query.set("sggNm", params.sggNm.trim());
if (params.bizNm?.trim()) query.set("bizNm", params.bizNm.trim());
if (params.sort) query.set("sort", params.sort);
return toPage<ExmnBusiness>(await getData(`${EXMN_BUSINESS_API_PATH}?${query}`));
};
/** 선택 사업의 처리분구별 관로·맨홀 조사 상세현황을 조회한다. */
export const getExmnSectionSummaries = async (
exmnBizId: string,
jobType: ExmnJobType,
): Promise<ExmnSectionSummaryRow[]> => {
type SectionSummaryPayload = ExmnSectionSummaryRow[] | Partial<ExmnPage<ExmnSectionSummaryRow>>;
const response = await getData(
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/sections?jobType=${jobType}`,
) as SectionSummaryPayload | { data?: SectionSummaryPayload };
const payload = unwrapData<SectionSummaryPayload>(response);
if (Array.isArray(payload)) return payload;
return Array.isArray(payload?.content) ? payload.content : [];
};
/** 선택한 조사사업의 처리분구·첨부를 포함한 상세정보를 조회한다. */
export const getExmnBusiness = async (exmnBizId: string): Promise<ExmnBusinessDetail> =>
getData(`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}`) as Promise<ExmnBusinessDetail>;
/** 조사사업과 선택 처리분구별 기본 요약행을 신규 저장한다. */
export const createExmnBusiness = async (
request: ExmnBusinessSaveRequest,
): Promise<ExmnBusinessDetail> =>
unwrapData<ExmnBusinessDetail>(await postRawJson(EXMN_BUSINESS_API_PATH, request));
/** 기존 조사사업 기본정보와 연결 처리분구를 같은 저장 계약으로 갱신한다. */
export const updateExmnBusiness = async (
exmnBizId: string,
request: ExmnBusinessSaveRequest,
): Promise<ExmnBusinessDetail> =>
putData(
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}`,
request,
) as Promise<ExmnBusinessDetail>;
/** 저장된 사업의 처리분구별 PPL/MNHL 엑셀 시트명을 생성한다. */
export const createExmnTemplates = async (
exmnBizId: string,
trtdstIds: string[],
): Promise<ExmnTemplateResult> =>
unwrapData<ExmnTemplateResult>(await postRawJson(
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/templates`,
{ trtdstIds },
));
/** 생성된 조사사업 입력 양식 파일을 내려받는다. */
export const downloadExmnTemplates = async (exmnBizId: string): Promise<Blob> =>
getBlobData(`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/templates/download`);
/** 사업보고서 첨부파일 ID로 기존 첨부서비스의 원본 파일을 내려받는다. */
export const downloadExmnBusinessReport = async (attachmentId: string): Promise<Blob> =>
getBlobData(`/attach-service/api/v1/download/${encodeURIComponent(attachmentId)}`);
/** 사업 파일별 첨부서비스 키 쌍을 조사사업에 연결한다. */
export const updateExmnAttachments = async (
exmnBizId: string,
attachments: Partial<Record<ExmnFileType, ExmnAttachmentKey>>,
): Promise<Partial<Record<ExmnFileType, ExmnAttachmentKey>>> =>
putData(
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/attachments`,
{ attachments },
) as Promise<Partial<Record<ExmnFileType, ExmnAttachmentKey>>>;
/** 원본 PPL/MNHL 파일의 업무·JSON 정합성을 서버에서 검토한다. */
export const validateExmnImport = async (
exmnBizId: string,
formData: FormData,
): Promise<ExmnImportValidationResult> =>
postMutiPart(
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/imports/validate`,
formData,
) as Promise<ExmnImportValidationResult>;
/** 검토했던 동일 파일과 첨부키를 다시 보내 시설 저장·요약 집계를 확정한다. */
export const commitExmnImport = async (
exmnBizId: string,
formData: FormData,
): Promise<ExmnImportCommitResult> =>
postMutiPart(
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/imports/commit`,
formData,
) as Promise<ExmnImportCommitResult>;
/** 선택 사업의 처리분구별 PPL 집계를 조회한다. */
export const getPplSummaries = async (
exmnBizId: string,
params: { trtdstId?: string; page: number; size: number; sort?: string },
): Promise<ExmnPage<PplSummaryRow>> => {
const query = new URLSearchParams({ page: String(params.page), size: String(params.size) });
if (params.trtdstId) query.set("trtdstId", params.trtdstId);
if (params.sort) query.set("sort", params.sort);
return toPage<PplSummaryRow>(await getData(
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/summaries/ppl?${query}`,
));
};
/** 선택 사업의 PPL 시설을 처리분구·구간·GIS번호·등급으로 조회한다. */
export const getPplFacilities = async (
params: PplFacilitySearchParams,
): Promise<ExmnPage<PplFacility>> => {
const query = new URLSearchParams({
exmnBizId: params.exmnBizId,
jobType: "PPL",
page: String(params.page),
size: String(params.size),
});
if (params.trtdstId) query.set("trtdstId", params.trtdstId);
if (params.sectionType) query.set("sectionType", params.sectionType);
if (params.gisManagementNo?.trim()) query.set("gisManagementNo", params.gisManagementNo.trim());
if (params.structuralGrade) query.set("structuralGrade", params.structuralGrade);
if (params.operationalGrade) query.set("operationalGrade", params.operationalGrade);
if (params.defectType && params.defectType !== "ALL") query.set("defectType", params.defectType);
if (params.sort) query.set("sort", params.sort);
return toPage<PplFacility>(await getData(`v1/exmnFacilities?${query}`));
};
/** 현재 PPL 조회조건을 서버 엑셀로 내려받는다. */
export const downloadPplExport = async (
exmnBizId: string,
trtdstId?: string,
): Promise<Blob> => {
const query = new URLSearchParams();
if (trtdstId) query.set("trtdstId", trtdstId);
const queryString = query.toString();
const suffix = queryString ? `?${queryString}` : "";
return getBlobData(`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/exports/ppl${suffix}`);
};
@@ -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>;
+10
View File
@@ -0,0 +1,10 @@
export * from "./api/exmn-business-api";
export * from "./api/trtdst-api";
export * from "./lib/exmn-business-validation";
export * from "./lib/trtdst-validation";
export * from "./model/exmn-business-types";
export * from "./model/trtdst-types";
export * from "./ui/ExmnRegistrationPage";
export * from "./ui/ExmnStatusPage";
export * from "./ui/ExmnStatusDetailPanel";
export * from "./ui/TrtdstManagementPage";
@@ -0,0 +1,54 @@
import type {
ExmnBusinessSaveRequest,
ExmnFileType,
} from "../model/exmn-business-types";
/** 브라우저 1차 검사에서 허용하는 조사파일 최대 크기(200MB)다. */
export const EXMN_MAX_FILE_SIZE = 200 * 1024 * 1024;
/** 조사사업 파일별 화면 표시명과 허용 확장자다. */
export const EXMN_FILE_RULES: Record<ExmnFileType, { label: string; extensions: string[] }> = {
EXMN_EXCEL: { label: "조사 결과", extensions: ["xlsx", "xls"] },
PPL_EXMN_EXCEL: { label: "CCTV 조사 결과(관로)", extensions: ["xlsx", "xls"] },
MNHL_EXMN_EXCEL: { label: "CCTV 조사 결과(맨홀)", extensions: ["xlsx", "xls"] },
BIZ_REPORT: { label: "사업보고서", extensions: ["pdf"] },
LOCATION_MAP: { label: "조사 위치도", extensions: ["pdf", "png", "jpg", "jpeg"] },
};
/** 조사사업 등록 필수값·숫자 범위·처리분구 선택을 검증한다. */
export const validateExmnBusiness = (request: ExmnBusinessSaveRequest): string | null => {
if (!/^\d{4}$/.test(request.bizYr)) return "사업연도는 4자리 숫자로 입력해주세요.";
if (!request.bizNm.trim()) return "사업명을 입력해주세요.";
if (!Number.isInteger(request.bizCycl) || request.bizCycl < 0) return "사업차수는 0 이상의 정수로 입력해주세요.";
if (!request.ctpvNm || !request.sggNm) return "시도와 시군구를 선택해주세요.";
if (!Number.isFinite(request.prcisExmnPrlg) || request.prcisExmnPrlg < 0) return "정밀조사연장은 0 이상이어야 합니다.";
if (!Number.isFinite(request.tpco) || request.tpco < 0) return "총사업비는 0 이상이어야 합니다.";
if (request.trtdstIds.length === 0) return "처리분구를 1개 이상 선택해주세요.";
return null;
};
/** 선택한 단일 파일의 빈 파일·크기·확장자를 업무 구분별로 확인한다. */
export const validateExmnFile = (fileType: ExmnFileType, file: File): string | null => {
const rule = EXMN_FILE_RULES[fileType];
if (file.size <= 0) return `${rule.label} 파일이 비어 있습니다.`;
if (file.size > EXMN_MAX_FILE_SIZE) return `${rule.label} 파일은 200MB 이하여야 합니다.`;
const extension = file.name.split(".").pop()?.toLowerCase() ?? "";
if (!rule.extensions.includes(extension)) {
return `${rule.label} 파일은 ${rule.extensions.map((item) => `.${item}`).join(", ")} 형식만 가능합니다.`;
}
return null;
};
/** PPL 또는 MNHL 파일이 하나 이상 선택됐고 모든 파일이 1차 규칙을 통과하는지 확인한다. */
export const validateExmnFiles = (
files: Partial<Record<ExmnFileType, File>>,
): string | null => {
if (!files.PPL_EXMN_EXCEL && !files.MNHL_EXMN_EXCEL) {
return "검토할 PPL 또는 MNHL 조사 엑셀을 1개 이상 선택해주세요.";
}
for (const [fileType, file] of Object.entries(files) as [ExmnFileType, File][]) {
const message = validateExmnFile(fileType, file);
if (message) return message;
}
return null;
};
@@ -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,210 @@
/** Spring Page 응답에서 조사사업 화면이 사용하는 공통 페이징 계약이다. */
export interface ExmnPage<T> {
content: T[];
totalElements: number;
number: number;
size: number;
totalPages?: number;
}
/** 조사사업 목록과 선택 컨텍스트에서 사용하는 사업 기본정보다. */
export interface ExmnBusiness {
exmnBizId: string;
bizYr: string;
bizNm: string;
bizCycl: number | null;
ctpvNm: string;
sggNm: string;
trtdstCnt: number;
prcisExmnPrlg: number;
tpco: number;
bizRptpAtchFileCd?: string | null;
bizRptpAtchFileId?: string | null;
reportYn?: "Y" | "N";
}
/** 사업에 연결된 활성 처리분구 정보다. */
export interface ExmnBusinessDistrict {
trtdstId: string;
trtdstNm: string;
pplExmnSheetNm?: string | null;
mnhlExmnSheetNm?: string | null;
}
/** 파일 구분별 첨부서비스 키 쌍이다. */
export interface ExmnAttachmentKey {
atflCd: string;
atflId: string;
fileName?: string;
}
/** 조사사업 상세 응답이다. */
export interface ExmnBusinessDetail extends ExmnBusiness {
trtdstIds: string[];
districts: ExmnBusinessDistrict[];
attachments?: Partial<Record<ExmnFileType, ExmnAttachmentKey>>;
}
/** 사업 목록의 입력 조건과 서버 페이징·정렬 조건이다. */
export interface ExmnBusinessSearchParams {
bizCycl?: string;
bizYr?: string;
ctpvNm?: string;
sggNm?: string;
bizNm?: string;
page: number;
size: number;
sort?: string;
}
/** 사업 신규 등록·수정 요청의 공통 계약이다. */
export interface ExmnBusinessSaveRequest {
bizYr: string;
bizNm: string;
bizCycl: number;
ctpvNm: string;
sggNm: string;
prcisExmnPrlg: number;
tpco: number;
trtdstIds: string[];
}
/** 처리분구별 PPL/MNHL 양식 시트 생성 결과다. */
export interface ExmnTemplateResult {
pplSheetNames: string[];
mnhlSheetNames: string[];
}
/** 조사사업에서 관리하는 파일의 업무 구분이다. */
export type ExmnFileType =
| "EXMN_EXCEL"
| "PPL_EXMN_EXCEL"
| "MNHL_EXMN_EXCEL"
| "BIZ_REPORT"
| "LOCATION_MAP";
/** 파일 검토에서 원본 위치까지 식별하는 단일 오류다. */
export interface ExmnImportValidationError {
sheetName?: string | null;
rowNumber?: number | null;
trtdstId?: string | null;
trtdstNm?: string | null;
gisManagementNo?: string | null;
fieldPath?: string | null;
code: string;
message: string;
}
/** PPL/MNHL 파일 검토 결과다. */
export interface ExmnImportValidationResult {
valid: boolean;
fileHash: string;
summary: {
pplCount: number;
mnhlCount: number;
};
errors: ExmnImportValidationError[];
}
/** 최종 저장 후 시설·미사용·집계 처리 건수다. */
export interface ExmnImportCommitResult {
savedCount: number;
unusedCount: number;
summaryCount: number;
}
/** 처리분구별 PPL 조사 집계 행이다. */
export interface PplSummaryRow {
trtdstId: string;
trtdstNm: string;
trgtPrlg: number;
drvngDstnc: number;
pplExmnWholAnmlyArtclCnt: number;
pplExmnStrctDfcCnt: number;
pplExmnOperDfcCnt: number;
pplExmnEtcCnt: number;
}
/** 조사현황 화면에서 관로(PPL)·맨홀(MNHL)을 구분하는 작업유형이다. */
export type ExmnJobType = "PPL" | "MNHL";
/** 사업 선택 후 처리분구별 관로·맨홀 집계를 표시하는 조사 상세현황 행이다. */
export interface ExmnSectionSummaryRow {
trtdstId: string;
trtdstNm: string;
trgtPrlg: number;
drvngDstnc: number;
pplExmnWholAnmlyArtclCnt: number;
pplExmnStrctDfcCnt: number;
pplExmnOperDfcCnt: number;
pplExmnEtcCnt: number;
mnhlExmnWholAnmlyArtclCnt: number;
mnhlExmnStrctDfcCnt: number;
mnhlExmnOperDfcCnt: number;
mnhlExmnEtcCnt: number;
}
/** PPL 상세 목록 조회 조건이다. */
export interface PplFacilitySearchParams {
exmnBizId: string;
trtdstId?: string;
sectionType?: string;
gisManagementNo?: string;
structuralGrade?: string;
operationalGrade?: string;
defectType?: "ALL" | "STRUCTURAL" | "OPERATIONAL" | "ETC";
page: number;
size: number;
sort?: string;
}
/** 관로 단위 구간의 상태·결함 상세 기록이다. */
export interface PplUnitDetail {
detailSeq: number;
positionM: number;
code: string;
recordType: "STATUS" | "DEFECT";
statusGroup?: string | null;
type?: "STRUCTURAL" | "OPERATIONAL" | null;
severity?: string | null;
occurrencePosition?: string | null;
infiltrationBonus?: number | null;
continuous?: boolean | null;
structuralScore?: number | null;
operationalScore?: number | null;
}
/** 서버가 JSONB를 정규화해 반환하는 PPL 단위 구간이다. */
export interface PplUnitSection {
unitSeq: number;
startPositionM: number;
unitLengthM: number;
structuralScore: number;
structuralGrade: number | string;
operationalScore: number;
operationalGrade: number | string;
surveyStopReason?: string | null;
remark?: string | null;
details: PplUnitDetail[];
}
/** PPL 시설 목록과 상세 모달에서 사용하는 정규화 응답이다. */
export interface PplFacility {
exmnFcltyId: string;
exmnBizId: string;
trtdstId: string;
trtdstNm: string;
rptpNo: string;
exmnFcltyGisMngNo: string;
upstmMnhlNo?: string | null;
dnstmMnhlNo?: string | null;
drgptnCd?: string | null;
pptyCd?: string | null;
spcfctCd?: string | null;
prlg?: number | null;
drvngDstnc?: number | null;
strctDfcGrdCd?: string | null;
operDfcGrdCd?: string | null;
wholAnmlyArtclCnt?: number | null;
unitSections?: PplUnitSection[];
}
@@ -0,0 +1,122 @@
import type { ExmnJobType } from "./exmn-business-types";
/** 엑셀 상세표의 고정 심각도 열 순서다. 모든 이상항목은 대·중·소 3열을 유지한다. */
export const EXMN_DETAIL_SEVERITIES = ["L", "M", "S"] as const;
export type ExmnDetailSeverity = (typeof EXMN_DETAIL_SEVERITIES)[number];
export type ExmnDefectGroup = "STRUCTURAL" | "OPERATIONAL";
export type ExmnDetailDefectType = "ALL" | ExmnDefectGroup;
export type ExmnSeverityCounts = Record<ExmnDetailSeverity, number>;
/** 화면 열과 추후 JSONB 코드 매핑이 같은 식별자를 사용하도록 하는 이상항목 정의다. */
export interface ExmnDetailDefectColumn {
code: string;
label: string;
group: ExmnDefectGroup;
}
/** 관로·맨홀 상세표의 공통 고정 열과 코드별 이상항목 건수다. */
export interface ExmnSurveyDetailRow {
reportNo: string;
pipeNo: string;
upstreamManhole: string;
downstreamManhole: string;
drainageType: string;
pipeType: string;
specification: number;
extensionM: number;
drivingDistanceM: number;
anomalyTotals: ExmnSeverityCounts;
defectCounts: Record<string, ExmnSeverityCounts>;
}
/** 선택 조사유형에 따라 팝업 제목과 이상항목 설정을 함께 반환하는 화면 계약이다. */
export interface ExmnDetailTableDefinition {
jobType: ExmnJobType;
title: string;
defectGroupTitle: string;
defectColumns: ExmnDetailDefectColumn[];
}
const structural = (
code: string,
label: string,
): ExmnDetailDefectColumn => ({ code, label, group: "STRUCTURAL" });
const operational = (
code: string,
label: string,
): ExmnDetailDefectColumn => ({ code, label, group: "OPERATIONAL" });
/** `01. 덕풍` 시트의 관로 이상항목 순서를 코드 식별자와 함께 유지한다. */
export const PIPE_DETAIL_DEFECT_COLUMNS: ExmnDetailDefectColumn[] = [
structural("CL", "균열길이"),
structural("CM", "균열복합"),
structural("CC", "균열원주"),
operational("DE", "내피생성"),
structural("LD", "라이닝결함"),
structural("DF", "변형"),
structural("CX", "붕괴"),
operational("RI", "뿌리침입"),
structural("NS", "역경사"),
structural("LP", "연결관돌출"),
structural("LS", "연결관접합부"),
structural("PO", "영구장애물"),
structural("JD", "이음부단차"),
structural("JF", "이음부손상"),
structural("JS", "이음부이탈"),
operational("TO", "임시장애물"),
structural("BC", "좌굴"),
structural("HL", "천공"),
operational("IF", "침입수"),
structural("SG", "침하"),
operational("DS", "토사퇴적"),
structural("BK", "파손"),
operational("DG", "폐유부착"),
structural("SD", "표면손상"),
];
/** 사용자 제공 코드 체계와 `01. 덕풍 (2)` 시트 순서를 결합한 맨홀 이상항목이다. */
export const MANHOLE_DETAIL_DEFECT_COLUMNS: ExmnDetailDefectColumn[] = [
structural("CHm", "균열-수평"),
structural("CVm", "균열-수직"),
structural("SIm", "표면손상-내부"),
structural("SOm", "표면손상-외부"),
structural("Dm", "변형(연성계열)"),
structural("BIm", "파손-내부"),
structural("BOm", "파손-외부"),
structural("LPm", "하수관로 접속부-돌출"),
structural("LSm", "하수관로 접속부-이상"),
structural("JDm", "블록 이음부-단차"),
structural("JFm", "블록 이음부-손상"),
structural("JSm", "블록 이음부-이탈"),
structural("SGm", "표면단차"),
structural("DBm", "인버트 결함"),
structural("DCm", "맨홀뚜껑/프레임 손상"),
operational("OAm", "악취 발생"),
operational("DEm", "내피생성"),
operational("DGm", "폐유부착"),
operational("TOm", "임시장애물"),
operational("DSm", "사다리손상"),
operational("SCm", "뚜껑밀폐"),
operational("RIm", "뿌리침입"),
operational("IFm", "침입수"),
];
export const EXMN_DETAIL_TABLE_DEFINITIONS: Record<
ExmnJobType,
ExmnDetailTableDefinition
> = {
PPL: {
jobType: "PPL",
title: "관로조사 상세현황",
defectGroupTitle: "관로",
defectColumns: PIPE_DETAIL_DEFECT_COLUMNS,
},
MNHL: {
jobType: "MNHL",
title: "맨홀조사 상세현황",
defectGroupTitle: "맨홀",
defectColumns: MANHOLE_DETAIL_DEFECT_COLUMNS,
},
};
@@ -0,0 +1,245 @@
import type {
ExmnBusiness,
ExmnBusinessDetail,
ExmnImportValidationResult,
ExmnJobType,
ExmnSectionSummaryRow,
} from "./exmn-business-types";
import {
EXMN_DETAIL_SEVERITIES,
EXMN_DETAIL_TABLE_DEFINITIONS,
type ExmnSeverityCounts,
type ExmnSurveyDetailRow,
} from "./exmn-detail-table";
import type { TrtdstSettingDto } from "./trtdst-types";
/** 백엔드 데이터가 준비되기 전 명시적 sample=Y 화면 검증에서만 사용하는 조사사업 목록이다. */
export const EXMN_SAMPLE_BUSINESSES: ExmnBusiness[] = [
{
exmnBizId: "SAMPLE-BIZ-001",
bizYr: "2026",
bizNm: "2026년 양평군 노후 하수관로 정밀조사사업",
bizCycl: 3,
ctpvNm: "경기도",
sggNm: "양평군",
trtdstCnt: 10,
prcisExmnPrlg: 18.42,
tpco: 1_250_000_000,
reportYn: "Y",
bizRptpAtchFileCd: "SAMPLE",
bizRptpAtchFileId: "SAMPLE-REPORT-001",
},
{
exmnBizId: "SAMPLE-BIZ-002",
bizYr: "2025",
bizNm: "2025년 이천시 하수관로 CCTV 조사사업",
bizCycl: 2,
ctpvNm: "경기도",
sggNm: "이천시",
trtdstCnt: 10,
prcisExmnPrlg: 12.75,
tpco: 890_000_000,
reportYn: "N",
},
{
exmnBizId: "SAMPLE-BIZ-003",
bizYr: "2024",
bizNm: "2024년 원주시 관로·맨홀 정밀조사사업",
bizCycl: 1,
ctpvNm: "강원특별자치도",
sggNm: "원주시",
trtdstCnt: 10,
prcisExmnPrlg: 15.1,
tpco: 1_030_000_000,
reportYn: "Y",
bizRptpAtchFileCd: "SAMPLE",
bizRptpAtchFileId: "SAMPLE-REPORT-003",
},
{
exmnBizId: "SAMPLE-BIZ-004",
bizYr: "2023",
bizNm: "2023년 청주시 하수관로 정밀조사사업",
bizCycl: 4,
ctpvNm: "충청북도",
sggNm: "청주시",
trtdstCnt: 10,
prcisExmnPrlg: 9.86,
tpco: 760_000_000,
reportYn: "N",
},
];
/** 사업등록 팝업의 체크박스·가나다순 정렬을 검증하는 활성 처리분구 샘플이다. */
export const EXMN_SAMPLE_TRTDST_SETTINGS: TrtdstSettingDto[] = [
"갈산처리분구",
"개군처리분구",
"공흥처리분구",
"국수처리분구",
"단월처리분구",
"덕평처리분구",
"문호처리분구",
"백안처리분구",
"서종처리분구",
"양근처리분구",
"양동처리분구",
"양서처리분구",
"오빈처리분구",
"용문처리분구",
"용천처리분구",
"지평처리분구",
"청운처리분구",
"회현처리분구",
"강상처리분구",
"강하처리분구",
"옥천처리분구",
].map((trtdstNm, index) => ({
trtdstId: `SAMPLE-TRTDST-${String(index + 1).padStart(2, "0")}`,
ctpvNm: "경기도",
sggNm: "양평군",
trtdstNm,
useYn: "Y",
}));
/** 선택 사업의 처리분구와 첨부 영역을 팝업에서 확인할 수 있도록 상세 샘플을 만든다. */
export const getExmnSampleBusinessDetail = (
exmnBizId: string,
fallbackBusiness?: ExmnBusiness,
): ExmnBusinessDetail => {
const business = EXMN_SAMPLE_BUSINESSES.find((item) => item.exmnBizId === exmnBizId)
?? fallbackBusiness
?? EXMN_SAMPLE_BUSINESSES[0];
const districts = EXMN_SAMPLE_TRTDST_SETTINGS.slice(0, business.trtdstCnt).map((item) => ({
trtdstId: item.trtdstId,
trtdstNm: item.trtdstNm,
pplExmnSheetNm: `${item.trtdstNm}_PPL`,
mnhlExmnSheetNm: `${item.trtdstNm}_MNHL`,
}));
return {
...business,
trtdstIds: districts.map((item) => item.trtdstId),
districts,
};
};
/** 결과등록 팝업의 데이터 검토·저장 활성 상태를 확인하는 정상 샘플 결과다. */
export const EXMN_SAMPLE_IMPORT_VALIDATION: ExmnImportValidationResult = {
valid: true,
fileHash: "SAMPLE-FILE-HASH-PREVIEW",
summary: {
pplCount: 24,
mnhlCount: 12,
},
errors: [],
};
/** 조사현황의 사업 행 선택과 관로·맨홀 탭 전환을 검증하는 처리분구 집계 샘플이다. */
export const getExmnSampleSectionSummaries = (
exmnBizId: string,
jobType: ExmnJobType,
): ExmnSectionSummaryRow[] => {
const business = EXMN_SAMPLE_BUSINESSES.find((item) => item.exmnBizId === exmnBizId)
?? EXMN_SAMPLE_BUSINESSES[0];
const districts = EXMN_SAMPLE_TRTDST_SETTINGS.slice(0, business.trtdstCnt);
return districts.map((district, index) => {
const scale = index + 1;
const pplStructural = 21 * scale;
const pplOperational = 7 * scale;
const pplEtc = 2 * scale;
const mnhlStructural = 5 * scale;
const mnhlOperational = 3 * scale;
const mnhlEtc = scale;
return {
trtdstId: district.trtdstId,
trtdstNm: district.trtdstNm,
trgtPrlg: Number((business.prcisExmnPrlg * 1000 / districts.length).toFixed(1)),
drvngDstnc: Number((business.prcisExmnPrlg * 930 / districts.length).toFixed(1)),
pplExmnWholAnmlyArtclCnt: jobType === "PPL" ? pplStructural + pplOperational + pplEtc : 0,
pplExmnStrctDfcCnt: jobType === "PPL" ? pplStructural : 0,
pplExmnOperDfcCnt: jobType === "PPL" ? pplOperational : 0,
pplExmnEtcCnt: jobType === "PPL" ? pplEtc : 0,
mnhlExmnWholAnmlyArtclCnt: jobType === "MNHL" ? mnhlStructural + mnhlOperational + mnhlEtc : 0,
mnhlExmnStrctDfcCnt: jobType === "MNHL" ? mnhlStructural : 0,
mnhlExmnOperDfcCnt: jobType === "MNHL" ? mnhlOperational : 0,
mnhlExmnEtcCnt: jobType === "MNHL" ? mnhlEtc : 0,
};
});
};
const EMPTY_SEVERITY_COUNTS: ExmnSeverityCounts = { L: 0, M: 0, S: 0 };
/** 열 코드와 행 순번으로 화면 확인용 희소 결함 건수를 일관되게 만든다. */
const buildSampleDefectCounts = (
jobType: ExmnJobType,
rowIndex: number,
): Record<string, ExmnSeverityCounts> => Object.fromEntries(
EXMN_DETAIL_TABLE_DEFINITIONS[jobType].defectColumns.map((column, columnIndex) => {
const counts = { ...EMPTY_SEVERITY_COUNTS };
EXMN_DETAIL_SEVERITIES.forEach((severity, severityIndex) => {
const seed = columnIndex + rowIndex * 3 + severityIndex;
if (seed % 11 === 0) {
counts[severity] = rowIndex + severityIndex + 1;
}
});
return [column.code, counts];
}),
);
/** 코드별 건수를 합산해 엑셀의 이상항목 합계 대·중·소 샘플을 만든다. */
const sumSampleDefects = (
defectCounts: Record<string, ExmnSeverityCounts>,
): ExmnSeverityCounts => EXMN_DETAIL_SEVERITIES.reduce(
(totals, severity) => {
totals[severity] = Object.values(defectCounts).reduce(
(sum, counts) => sum + counts[severity],
0,
);
return totals;
},
{ ...EMPTY_SEVERITY_COUNTS },
);
const SAMPLE_FACILITIES = [
{
reportNo: "118-0026",
pipeNo: "118-0026",
upstreamManhole: "118-0026M",
downstreamManhole: "118-0027M",
extensionM: 25.5,
drivingDistanceM: 25.5,
},
{
reportNo: "118-0027",
pipeNo: "118-0027",
upstreamManhole: "118-0027M",
downstreamManhole: "118-0028M",
extensionM: 27,
drivingDistanceM: 27,
},
{
reportNo: "118-0027-1",
pipeNo: "118-0027-1",
upstreamManhole: "118-0027-1M",
downstreamManhole: "118-0028M",
extensionM: 13.9,
drivingDistanceM: 13.9,
},
] as const;
/** 상세보기 WindowPopup에서 관로·맨홀 엑셀형 표를 확인하는 샘플 3행이다. */
export const getExmnSampleSurveyDetails = (
jobType: ExmnJobType,
): ExmnSurveyDetailRow[] => SAMPLE_FACILITIES.map((facility, rowIndex) => {
const defectCounts = buildSampleDefectCounts(jobType, rowIndex);
const anomalyTotals = sumSampleDefects(defectCounts);
return {
...facility,
drainageType: "합류",
pipeType: "HP",
specification: 600,
anomalyTotals,
defectCounts,
};
});
@@ -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,399 @@
"use client";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
import { getStartYearArray } from "@/components/ui/YearRangeSelectBox";
import useStore from "@/store/useStore";
import { postPopupMessage } from "@/features/windowPopup/lib/popupMessage";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import { Button, Input, Label } from "@common/components/ui";
import { SelectBox } from "@common/business_components/ui";
import { useEffect, useMemo, useState } from "react";
import {
createExmnBusiness,
createExmnTemplates,
downloadExmnTemplates,
} from "../api/exmn-business-api";
import { getTrtdstSettings } from "../api/trtdst-api";
import { validateExmnBusiness } from "../lib/exmn-business-validation";
import { EXMN_SAMPLE_TRTDST_SETTINGS } from "../model/exmn-sample-data";
import type {
ExmnBusinessDetail,
ExmnBusinessSaveRequest,
} from "../model/exmn-business-types";
import type { TrtdstSettingDto } from "../model/trtdst-types";
const DOWNLOAD_BUTTON_CLASS =
"rounded-sm px-4 py-2 text-sm font-medium !text-white disabled:!text-white";
type Notice = { type: "success" | "error" | "info"; text: string } | null;
interface ExmnBusinessRegisterPopupPageProps {
sampleMode: boolean;
}
/** 처리분구 이름을 공백 제거 후 한글 가나다순으로 정렬한다. */
const sortDistricts = (items: TrtdstSettingDto[]) => [...items].sort((left, right) =>
left.trtdstNm.trim().localeCompare(right.trtdstNm.trim(), "ko"));
/** Blob을 브라우저 다운로드로 전달하고 임시 URL을 즉시 정리한다. */
const saveBlob = (blob: Blob, fileName: string) => {
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = fileName;
anchor.click();
URL.revokeObjectURL(objectUrl);
};
/** 공통 WindowPopup 안에서 사업정보·대상 처리분구·조사양식을 등록하는 화면이다. */
export function ExmnBusinessRegisterPopupPage({ sampleMode }: ExmnBusinessRegisterPopupPageProps) {
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const [bizYr, setBizYr] = useState(String(new Date().getFullYear()));
const [bizNm, setBizNm] = useState(sampleMode ? "2026년 양평군 노후 하수관로 정밀조사사업" : "");
const [ctpvCd, setCtpvCd] = useState("0");
const [sggCd, setSggCd] = useState("0");
const [ctpvNm, setCtpvNm] = useState(sampleMode ? "경기도" : "");
const [sggNm, setSggNm] = useState(sampleMode ? "양평군" : "");
const [prcisExmnPrlg, setPrcisExmnPrlg] = useState(sampleMode ? "18.42" : "0");
const [districts, setDistricts] = useState<TrtdstSettingDto[]>([]);
/** 등록 후보가 많을 때 처리분구명으로 화면 목록을 좁히는 검색어다. */
const [districtKeyword, setDistrictKeyword] = useState("");
const [selectedDistrictIds, setSelectedDistrictIds] = useState<string[]>([]);
const [savedBusiness, setSavedBusiness] = useState<ExmnBusinessDetail | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [notice, setNotice] = useState<Notice>(null);
/** 팝업 레이아웃은 BaseLayout 초기화를 거치지 않으므로 행정구역 공통코드를 직접 준비한다. */
useEffect(() => {
if (ctpv.length === 0) void fetchCtpv();
if (sgg.length === 0) void fetchSgg();
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
/** 샘플 팝업은 공통코드가 로드된 뒤 경기도·양평군을 선택 상태로 맞춘다. */
useEffect(() => {
if (!sampleMode) return;
const sampleCtpv = ctpv.find((option) => option.text === "경기도");
const sampleSgg = sgg.find((option) => option.text === "양평군");
if (sampleCtpv) setCtpvCd(String(sampleCtpv.val));
if (sampleSgg) setSggCd(String(sampleSgg.val));
}, [ctpv, sampleMode, sgg]);
/** 선택 행정구역의 활성 처리분구를 읽고 화면 요구사항대로 가나다순으로 제공한다. */
useEffect(() => {
setDistrictKeyword("");
if (!ctpvNm || !sggNm) {
setDistricts([]);
setSelectedDistrictIds([]);
return;
}
if (sampleMode) {
setDistricts(sortDistricts(EXMN_SAMPLE_TRTDST_SETTINGS.map((item) => ({
...item,
ctpvNm,
sggNm,
}))));
setSelectedDistrictIds([]);
return;
}
let active = true;
setIsLoading(true);
getTrtdstSettings({ ctpvNm, sggNm, useYn: "Y" })
.then((items) => {
if (!active) return;
setDistricts(sortDistricts(items));
setSelectedDistrictIds([]);
})
.catch((error: unknown) => {
if (!active) return;
setDistricts([]);
setNotice({
type: "error",
text: error instanceof Error ? error.message : "처리분구 조회 중 오류가 발생했습니다.",
});
})
.finally(() => {
if (active) setIsLoading(false);
});
return () => {
active = false;
};
}, [ctpvNm, sampleMode, sggNm]);
/** 선택 개수와 이름을 저장 요청과 양식 생성에서 동일하게 사용한다. */
const selectedDistricts = useMemo(() => districts.filter((district) =>
selectedDistrictIds.includes(district.trtdstId)), [districts, selectedDistrictIds]);
/** 공백 차이를 무시한 처리분구명 부분일치 검색 결과다. */
const filteredDistricts = useMemo(() => {
const keyword = districtKeyword.replace(/\s+/g, "").toLocaleLowerCase("ko-KR");
if (!keyword) return districts;
return districts.filter((district) =>
district.trtdstNm.replace(/\s+/g, "").toLocaleLowerCase("ko-KR").includes(keyword));
}, [districtKeyword, districts]);
/** 현재 검색 결과가 모두 선택된 상태인지 일괄선택 버튼 문구와 동작에 사용한다. */
const allFilteredDistrictsSelected = filteredDistricts.length > 0
&& filteredDistricts.every((district) => selectedDistrictIds.includes(district.trtdstId));
/** 검색된 처리분구만 한 번에 선택하거나 해제하며 다른 검색에서 고른 값은 유지한다. */
const handleToggleFilteredDistricts = () => {
const filteredIds = new Set(filteredDistricts.map((district) => district.trtdstId));
setSelectedDistrictIds((current) => {
const nextIds = new Set(current);
filteredIds.forEach((id) => {
if (allFilteredDistrictsSelected) nextIds.delete(id);
else nextIds.add(id);
});
return districts
.filter((district) => nextIds.has(district.trtdstId))
.map((district) => district.trtdstId);
});
};
/** 현재 입력값을 조사사업 신규 저장 계약으로 변환한다. */
const buildRequest = (): ExmnBusinessSaveRequest => ({
bizYr: bizYr.trim(),
bizNm: bizNm.trim(),
// 사업차수와 총사업비는 현재 등록 화면 입력 대상이 아니므로 신규 기본값으로 전달한다.
bizCycl: 1,
ctpvNm,
sggNm,
prcisExmnPrlg: Number(prcisExmnPrlg),
tpco: 0,
trtdstIds: selectedDistrictIds,
});
/** 실제 모드에서는 API를 호출하고 샘플 모드에서는 명시적 미리보기 사업만 부모 목록에 전달한다. */
const handleSave = async () => {
const request = buildRequest();
const validationMessage = validateExmnBusiness(request);
if (validationMessage) {
setNotice({ type: "error", text: validationMessage });
return;
}
setIsLoading(true);
setNotice(null);
try {
const created: ExmnBusinessDetail = sampleMode
? {
exmnBizId: `SAMPLE-BIZ-${Date.now()}`,
...request,
trtdstCnt: selectedDistrictIds.length,
reportYn: "N",
districts: selectedDistricts.map((district) => ({
trtdstId: district.trtdstId,
trtdstNm: district.trtdstNm,
})),
}
: await createExmnBusiness(request);
setSavedBusiness(created);
postPopupMessage(WINDOW_POPUP_MESSAGE_TYPE.EXMN_BUSINESS_CREATED, {
exmnBizId: created.exmnBizId,
sample: sampleMode ? "Y" : "N",
business: sampleMode ? JSON.stringify(created) : "",
});
setNotice({
type: "success",
text: sampleMode
? "샘플 사업정보를 저장했습니다. 부모 목록에서 바로 확인할 수 있습니다."
: "사업정보와 처리분구별 기본 요약행을 저장했습니다.",
});
} catch (error) {
setNotice({
type: "error",
text: error instanceof Error ? error.message : "사업정보 저장 중 오류가 발생했습니다.",
});
} finally {
setIsLoading(false);
}
};
/** 양식 받기 한 번으로 서버 양식을 생성한 뒤 내려받는다. */
const handleDownloadTemplates = async () => {
if (!savedBusiness?.exmnBizId) {
setNotice({ type: "info", text: "사업정보를 먼저 저장해주세요." });
return;
}
if (sampleMode) {
setNotice({ type: "info", text: "샘플 모드에서는 실제 양식 파일을 다운로드하지 않습니다." });
return;
}
setIsLoading(true);
setNotice(null);
try {
await createExmnTemplates(savedBusiness.exmnBizId, selectedDistrictIds);
saveBlob(await downloadExmnTemplates(savedBusiness.exmnBizId), `${savedBusiness.bizNm}_조사입력양식.xlsx`);
} catch (error) {
setNotice({
type: "error",
text: error instanceof Error ? error.message : "조사 입력양식 다운로드 중 오류가 발생했습니다.",
});
} finally {
setIsLoading(false);
}
};
/** 조사보고서 양식 API는 미확정 상태임을 화면에서 명시한다. */
const handleReportTemplate = () => {
setNotice({ type: "info", text: "조사보고서 양식 API가 확정되면 다운로드 기능을 연결합니다." });
};
return (
<main className="sn-popup-page">
<LoadingOverlay isLoading={isLoading} />
<header className="sn-popup-header">
<h1 className="sn-popup-title">사업정보 등록</h1>
<div className="flex items-center gap-2">
<Button
type="button"
color="white"
size="xs"
className="sn-popup-header-button"
disabled={isLoading || Boolean(savedBusiness)}
onClick={() => void handleSave()}
>
저장
</Button>
<Button color="green" className={DOWNLOAD_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={() => void handleDownloadTemplates()}>양식 받기</Button>
<Button color="green" className={DOWNLOAD_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={handleReportTemplate}>조사보고서 양식</Button>
</div>
</header>
<section className="sn-popup-section space-y-3 overflow-y-auto">
{notice && (
<div role="alert" className={`rounded border px-4 py-3 text-sm ${
notice.type === "error"
? "border-red-200 bg-red-50 text-red-700"
: notice.type === "success"
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: "border-blue-200 bg-blue-50 text-blue-700"
}`}>{notice.text}</div>
)}
<section className="border border-border">
<div className="border-b border-border bg-[#eaf3fb] px-4 py-3 font-semibold text-title">사업정보 입력</div>
<div className="space-y-4 p-4">
<div className="flex flex-wrap items-center gap-y-4">
<SelectBox
label="사업년도"
labelClass="w-20 shrink-0 text-base"
className="mr-7 flex items-center gap-3"
selectClass="w-36 bg-white"
selectArray={getStartYearArray({ placeholder: "선택" })}
value={bizYr}
readOnly={Boolean(savedBusiness)}
onSelectValue={(value) => setBizYr(String(value))}
/>
<CtpvSggPrcsFcltSelectBox
className="!mr-0 !justify-start"
sido={ctpvCd}
sigungu={sggCd}
onSidoChange={(nextCtpvCd) => {
if (savedBusiness) return;
setCtpvCd(nextCtpvCd);
setSggCd("0");
setSggNm("");
}}
onSigunguChange={(nextSggCd) => {
if (!savedBusiness) setSggCd(nextSggCd);
}}
setCtpvName={(nextCtpvNm) => {
if (!savedBusiness) setCtpvNm(nextCtpvNm === "전체" ? "" : nextCtpvNm);
}}
setSggName={(nextSggNm) => {
if (!savedBusiness) setSggNm(nextSggNm === "전체" ? "" : nextSggNm);
}}
/>
<div className="ml-auto flex items-center gap-3" style={{ width: "280px" }}>
<Label className="shrink-0 text-base" style={{ whiteSpace: "nowrap" }}><span className="text-red-500">* </span>정밀조사 연장</Label>
<Input
style={{ width: "9rem", flex: "none" }}
type="text"
inputMode="decimal"
maxLength={10}
value={prcisExmnPrlg}
disabled={Boolean(savedBusiness)}
onChange={(event) => {
if (/^\d*\.?\d*$/.test(event.target.value)) setPrcisExmnPrlg(event.target.value);
}}
/>
<span className="text-sm text-gray-500">km</span>
</div>
</div>
<div className="flex items-center gap-3">
<Label className="w-20 shrink-0 text-base"><span className="text-red-500">* </span>사업명</Label>
<Input style={{ maxWidth: "480px" }} value={bizNm} maxLength={200} disabled={Boolean(savedBusiness)} onChange={(event) => setBizNm(event.target.value)} />
</div>
</div>
</section>
<section className="min-h-0 border border-border">
<div className="flex flex-wrap items-center gap-2 border-b border-border bg-[#eaf3fb] px-4 py-2">
<h2 className="mr-2 font-semibold text-title">사업대상 처리분구</h2>
<Input
type="search"
value={districtKeyword}
className="sn-popup-input-sm !h-9 w-[22rem] bg-white"
placeholder="처리분구명 검색"
aria-label="처리분구명 검색"
disabled={Boolean(savedBusiness)}
onChange={(event) => setDistrictKeyword(event.target.value)}
/>
<div className="ml-auto flex items-center gap-2">
<Button
type="button"
color="white"
size="xs"
className="sn-popup-secondary-button-sm"
disabled={Boolean(savedBusiness) || filteredDistricts.length === 0}
onClick={handleToggleFilteredDistricts}
>
{allFilteredDistrictsSelected ? "검색결과 해제" : "검색결과 선택"}
</Button>
<Button
type="button"
color="white"
size="xs"
className="sn-popup-secondary-button-sm"
disabled={Boolean(savedBusiness) || selectedDistrictIds.length === 0}
onClick={() => setSelectedDistrictIds([])}
>
선택 초기화
</Button>
<span className="ml-1 whitespace-nowrap text-sm text-label">
{selectedDistrictIds.length}개 선택
</span>
</div>
</div>
<div className="grid grid-cols-3 content-start gap-3 overflow-y-auto p-4" style={{ height: "21rem" }}>
{districts.length === 0 ? (
<p className="col-span-3 py-7 text-center text-sm text-gray-500">시도·시군구를 선택하거나 등록된 활성 처리분구를 확인해주세요.</p>
) : filteredDistricts.length === 0 ? (
<p className="col-span-3 py-7 text-center text-sm text-gray-500">검색 조건에 맞는 처리분구가 없습니다.</p>
) : filteredDistricts.map((district) => (
<label key={district.trtdstId} className="flex cursor-pointer items-center gap-2 rounded border border-gray-200 bg-white px-3 py-2.5 text-sm hover:border-blue-300 hover:bg-blue-50">
<input
type="checkbox"
className="size-4 accent-blue-600"
checked={selectedDistrictIds.includes(district.trtdstId)}
disabled={Boolean(savedBusiness)}
onChange={(event) => setSelectedDistrictIds((current) => event.target.checked
? [...current, district.trtdstId]
: current.filter((id) => id !== district.trtdstId))}
/>
<span>{district.trtdstNm}</span>
</label>
))}
</div>
</section>
</section>
</main>
);
}
@@ -0,0 +1,626 @@
"use client";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { fileDownload } from "@/components/lib/utils";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
import { getStartYearArray } from "@/components/ui/YearRangeSelectBox";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import {
Button,
Input,
Label,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@common/components/ui";
import { SearchDiv, SelectBox } from "@common/business_components/ui";
import type { UiTableSort } from "@common/business_components/ui";
import ListDiv from "@common/business_components/ui/list_div";
import { useEffect, useMemo, useRef, useState } from "react";
import {
downloadExmnBusinessReport,
getExmnBusinesses,
} from "../api/exmn-business-api";
import type {
ExmnBusiness,
ExmnBusinessSearchParams,
} from "../model/exmn-business-types";
import { EXMN_SAMPLE_BUSINESSES } from "../model/exmn-sample-data";
import { ExmnStatusDetailPanel } from "./ExmnStatusDetailPanel";
const 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";
// 목록 페이지네이션을 제거하고 한 번에 받아 내부 스크롤로 표시할 최대 사업 건수다.
const BUSINESS_LIST_SCROLL_FETCH_SIZE = 1000;
const BUSINESS_TABLE_HEAD = [
{ id: "rowNumber", title: "NO", widthPercent: 5, sortable: true },
{ id: "bizCyclText", title: "사업차수", widthPercent: 7, sortable: true, sortKey: "bizCycl" },
{ id: "bizYr", title: "사업년도", widthPercent: 7, sortable: true },
{ id: "ctpvNm", title: "시도", widthPercent: 9, sortable: true },
{ id: "sggNm", title: "시군구", widthPercent: 9, sortable: true },
{ id: "bizNm", title: "사업명", widthPercent: 26, sortable: true },
{ id: "trtdstCntText", title: "처리구역·분구(개소)", widthPercent: 12, sortable: true, sortKey: "trtdstCnt" },
{ id: "prcisExmnPrlgText", title: "정밀조사 연장(km)", widthPercent: 13, sortable: true, sortKey: "prcisExmnPrlg" },
{ id: "reportYnText", title: "보고서", widthPercent: 12, sortable: true, sortKey: "reportYn" },
];
interface BusinessFilters {
bizCycl: string;
bizYr: string;
ctpvCd: string;
sggCd: string;
ctpvNm: string;
sggNm: string;
bizNm: string;
}
const EMPTY_FILTERS: BusinessFilters = {
bizCycl: "",
bizYr: "0",
ctpvCd: "0",
sggCd: "0",
ctpvNm: "",
sggNm: "",
bizNm: "",
};
type Notice = { type: "success" | "error" | "info"; text: string } | null;
/** 서버·샘플 목록에 같은 비교 규칙을 적용해 정렬 헤더와 실제 행 순서를 일치시킨다. */
const sortBusinessList = (
source: ExmnBusiness[],
nextSort: UiTableSort | undefined,
) => {
if (!nextSort) return source;
// NO는 서버 엔티티 필드가 아니라 현재 조회 결과의 기본 순번이므로 원본 순서를 정·역순으로 사용한다.
if (nextSort.key === "rowNumber") {
return nextSort.direction === "desc" ? [...source].reverse() : [...source];
}
const getSortValue = (business: ExmnBusiness) => {
switch (nextSort.key) {
case "bizCycl": return business.bizCycl ?? 0;
case "bizYr": return business.bizYr;
case "ctpvNm": return business.ctpvNm;
case "sggNm": return business.sggNm;
case "bizNm": return business.bizNm;
case "trtdstCnt": return business.trtdstCnt;
case "prcisExmnPrlg": return business.prcisExmnPrlg;
case "reportYn": return business.reportYn ?? "N";
default: return business.bizNm;
}
};
return [...source].sort((left, right) => {
const leftValue = getSortValue(left);
const rightValue = getSortValue(right);
const comparison = typeof leftValue === "number" && typeof rightValue === "number"
? leftValue - rightValue
: String(leftValue).localeCompare(String(rightValue), "ko");
return nextSort.direction === "desc" ? comparison * -1 : comparison;
});
};
/** 사업정보 조회·등록과 선택 사업의 조사결과 등록 진입을 제공하는 SFR-010-004 메인 화면이다. */
export function ExmnRegistrationPage() {
const pageRef = useRef<HTMLDivElement>(null);
// 입력 중 조건과 실제 서버 조회 조건을 분리해 정렬 시 동일 조건을 재사용한다.
const [draftFilters, setDraftFilters] = useState<BusinessFilters>(EMPTY_FILTERS);
const [appliedFilters, setAppliedFilters] = useState<BusinessFilters>(EMPTY_FILTERS);
const [businesses, setBusinesses] = useState<ExmnBusiness[]>([]);
// 명시적 sample=Y 모드에서 신규 샘플 등록까지 유지하는 부모 창 전용 데이터 원본이다.
const [sampleBusinesses, setSampleBusinesses] = useState<ExmnBusiness[]>(EXMN_SAMPLE_BUSINESSES);
const [sampleMode, setSampleMode] = useState(false);
const [total, setTotal] = useState(0);
const [sort, setSort] = useState<UiTableSort>();
// 선택 사업 ID는 서버 재조회 뒤에도 같은 행을 복원할 수 있도록 객체와 별도로 유지한다.
const [selectedBusinessId, setSelectedBusinessId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [notice, setNotice] = useState<Notice>(null);
/**
* 공통 헤더와 레이아웃은 변경하지 않고, 이 화면이 시작되는 지점부터
* 뷰포트 하단까지만 조사관리 영역으로 사용한다.
* 부모 높이가 확정되어야 상세 그리드의 flex-1 / overflow-y-auto가 동작한다.
*/
useEffect(() => {
const pageElement = pageRef.current;
if (!pageElement) return;
const previousHtmlOverflow = document.documentElement.style.overflow;
const previousBodyOverflow = document.body.style.overflow;
const footerElement = document.querySelector<HTMLElement>("footer");
const updatePageHeight = () => {
const pageTop = pageElement.getBoundingClientRect().top;
const footerHeight = footerElement?.getBoundingClientRect().height ?? 0;
const bottomGap = 8;
pageElement.style.height = `${Math.max(
0,
window.innerHeight - pageTop - footerHeight - bottomGap,
)}px`;
};
document.documentElement.style.overflow = "hidden";
document.body.style.overflow = "hidden";
updatePageHeight();
window.addEventListener("resize", updatePageHeight);
const footerResizeObserver = footerElement
? new ResizeObserver(updatePageHeight)
: null;
footerResizeObserver?.observe(footerElement);
return () => {
window.removeEventListener("resize", updatePageHeight);
footerResizeObserver?.disconnect();
document.documentElement.style.overflow = previousHtmlOverflow;
document.body.style.overflow = previousBodyOverflow;
};
}, []);
/** URL에 sample=Y가 명시된 경우에만 실제 API와 분리된 화면 검증용 사업목록을 노출한다. */
useEffect(() => {
const enabled = new URLSearchParams(window.location.search).get("sample") === "Y";
setSampleMode(enabled);
if (!enabled) return;
setBusinesses(sampleBusinesses);
setTotal(sampleBusinesses.length);
}, []);
const selectedBusiness = useMemo(
() => businesses.find((business) => business.exmnBizId === selectedBusinessId) ?? null,
[businesses, selectedBusinessId],
);
/** 조사사업 목록 헤더의 정렬 상태를 오름차순·내림차순·기본 순서로 순환한다. */
const handleBusinessSort = (head: (typeof BUSINESS_TABLE_HEAD)[number]) => {
if (!("sortable" in head) || !head.sortable) return;
const key = "sortKey" in head && head.sortKey ? head.sortKey : head.id;
const nextSort: UiTableSort | undefined = sort?.key !== key
? { key, direction: "asc" }
: sort.direction === "asc"
? { key, direction: "desc" }
: undefined;
setSort(nextSort);
void fetchBusinesses(appliedFilters, nextSort);
};
/** 개발 환경에서만 샘플 데이터 모드를 명시적으로 켜고 끈다. */
const switchSampleMode = (enabled: boolean) => {
const url = new URL(window.location.href);
if (enabled) url.searchParams.set("sample", "Y");
else url.searchParams.delete("sample");
window.location.assign(`${url.pathname}${url.search}`);
};
/** 사업 목록의 숫자·행정구역·보고서 여부를 표시 전용 값으로 변환한다. */
const businessRows = useMemo(() => businesses.map((business, index) => ({
...business,
rowNumber: sort?.key === "rowNumber" && sort.direction === "desc"
? businesses.length - index
: index + 1,
bizCyclText: business.bizCycl == null ? "-" : `${business.bizCycl}차`,
trtdstCntText: Number(business.trtdstCnt ?? 0).toLocaleString("ko-KR"),
prcisExmnPrlgText: Number(business.prcisExmnPrlg ?? 0).toLocaleString("ko-KR", { maximumFractionDigits: 3 }),
reportYnText: business.reportYn === "Y" || business.bizRptpAtchFileId ? "있음" : "없음",
})), [businesses, sort]);
/** 화면 조건을 행정구역 이름과 목록 내부 스크롤용 단일 서버 Page 계약으로 변환한다. */
const buildSearchParams = (
filters: BusinessFilters,
nextSort: UiTableSort | undefined,
): ExmnBusinessSearchParams => ({
bizCycl: filters.bizCycl || undefined,
bizYr: filters.bizYr !== "0" ? filters.bizYr : undefined,
ctpvNm: filters.ctpvNm || undefined,
sggNm: filters.sggNm || undefined,
bizNm: filters.bizNm || undefined,
page: 0,
size: BUSINESS_LIST_SCROLL_FETCH_SIZE,
sort: nextSort && nextSort.key !== "rowNumber"
? `${nextSort.key},${nextSort.direction}`
: undefined,
});
/** 샘플 원본에 실제 조회조건과 조사사업 목록의 공통 비교 규칙을 적용한다. */
const getSampleBusinessPage = (
filters: BusinessFilters,
nextSort: UiTableSort | undefined,
) => {
const filtered = sampleBusinesses.filter((business) => (
(!filters.bizCycl || String(business.bizCycl ?? "") === filters.bizCycl)
&& (filters.bizYr === "0" || business.bizYr === filters.bizYr)
&& (!filters.ctpvNm || business.ctpvNm === filters.ctpvNm)
&& (!filters.sggNm || business.sggNm === filters.sggNm)
&& (!filters.bizNm || business.bizNm.includes(filters.bizNm.trim()))
));
return sortBusinessList(filtered, nextSort);
};
/** 지정 조회조건·정렬로 스크롤 표시용 사업 목록을 읽고 선택 행을 가능한 경우 유지한다. */
const fetchBusinesses = async (
filters: BusinessFilters,
nextSort = sort,
) => {
setIsLoading(true);
setNotice(null);
try {
if (sampleMode) {
const content = getSampleBusinessPage(filters, nextSort);
setBusinesses(content);
setTotal(content.length);
setSelectedBusinessId((current) => content.some((item) => item.exmnBizId === current) ? current : null);
return true;
}
const result = await getExmnBusinesses(buildSearchParams(filters, nextSort));
const content = sortBusinessList(result.content, nextSort);
setBusinesses(content);
setTotal(result.totalElements);
setSelectedBusinessId((current) => content.some((item) => item.exmnBizId === current) ? current : null);
return true;
} catch (error) {
setBusinesses([]);
setTotal(0);
setSelectedBusinessId(null);
setNotice({
type: "error",
text: error instanceof Error ? error.message : "조사사업 목록 조회 중 오류가 발생했습니다.",
});
return false;
} finally {
setIsLoading(false);
}
};
/** 입력 중 조건을 확정하고 첫 페이지를 조회한다. */
const handleSearch = async () => {
setAppliedFilters(draftFilters);
await fetchBusinesses(draftFilters, sort);
};
/** 목록의 보고서 링크에서 첨부서비스 원본 PDF를 내려받는다. */
const handleReportDownload = async (business: ExmnBusiness) => {
const attachmentId = business.bizRptpAtchFileId?.trim();
if (!attachmentId) {
setNotice({ type: "error", text: "다운로드할 사업보고서 첨부파일 정보가 없습니다." });
return;
}
if (sampleMode) {
setNotice({ type: "info", text: "샘플 모드에서는 실제 사업보고서 파일을 다운로드하지 않습니다." });
return;
}
setIsLoading(true);
setNotice(null);
try {
const reportBlob = await downloadExmnBusinessReport(attachmentId);
fileDownload(reportBlob, `${business.bizNm}_사업보고서.pdf`);
} catch (error) {
setNotice({
type: "error",
text: error instanceof Error ? error.message : "사업보고서 다운로드 중 오류가 발생했습니다.",
});
} finally {
setIsLoading(false);
}
};
/** 공통 WindowPopup 체계로 사업정보 등록 화면을 연다. */
const openBusinessRegister = () => {
setNotice(null);
const popup = openWindowPopup("exmnBusinessRegister", {
sample: sampleMode ? "Y" : undefined,
sourcePath: window.location.pathname,
});
if (!popup) {
setNotice({ type: "error", text: "사업등록 팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요." });
}
};
/** 선택 사업 컨텍스트를 전달해 공통 WindowPopup 결과등록 화면을 연다. */
const openResultRegister = () => {
if (!selectedBusiness) {
setNotice({ type: "error", text: "사업 목록에서 대상 사업을 먼저 선택해주세요." });
return;
}
setNotice(null);
const popup = openWindowPopup("exmnResultRegister", {
exmnBizId: selectedBusiness.exmnBizId,
bizYr: selectedBusiness.bizYr,
bizCycl: selectedBusiness.bizCycl ?? 0,
bizNm: selectedBusiness.bizNm,
ctpvNm: selectedBusiness.ctpvNm,
sggNm: selectedBusiness.sggNm,
trtdstCnt: selectedBusiness.trtdstCnt,
prcisExmnPrlg: selectedBusiness.prcisExmnPrlg,
tpco: selectedBusiness.tpco,
reportYn: selectedBusiness.reportYn ?? "N",
sample: sampleMode ? "Y" : undefined,
sourcePath: window.location.pathname,
});
if (!popup) {
setNotice({ type: "error", text: "결과등록 팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요." });
}
};
/** WindowPopup의 등록·저장 완료 메시지를 받아 부모 목록과 안내 상태를 동기화한다. */
useEffect(() => {
const handlePopupMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type === WINDOW_POPUP_MESSAGE_TYPE.EXMN_BUSINESS_CREATED) {
const payload = event.data?.payload as { exmnBizId?: string; sample?: string; business?: string } | undefined;
if (payload?.sample === "Y" && payload.business) {
try {
const created = JSON.parse(payload.business) as ExmnBusiness;
setSampleBusinesses((current) => [created, ...current.filter((item) => item.exmnBizId !== created.exmnBizId)]);
setBusinesses((current) => [created, ...current.filter((item) => item.exmnBizId !== created.exmnBizId)]);
setTotal((current) => current + 1);
setSelectedBusinessId(created.exmnBizId);
setNotice({ type: "success", text: "샘플 조사사업이 등록되었습니다." });
} catch {
setNotice({ type: "error", text: "샘플 사업정보를 부모 목록에 반영하지 못했습니다." });
}
return;
}
setSelectedBusinessId(payload?.exmnBizId ?? null);
void fetchBusinesses(appliedFilters, sort).then((refreshed) => {
if (refreshed) setNotice({ type: "success", text: "조사사업이 등록되었습니다." });
});
return;
}
if (event.data?.type === WINDOW_POPUP_MESSAGE_TYPE.EXMN_RESULT_COMMITTED) {
const payload = event.data?.payload as { sample?: string } | undefined;
if (payload?.sample === "Y") {
setNotice({ type: "success", text: "샘플 조사결과 저장 흐름을 완료했습니다." });
return;
}
void fetchBusinesses(appliedFilters, sort).then((refreshed) => {
if (refreshed) setNotice({ type: "success", text: "조사결과 최종 저장과 PPL/MNHL 집계를 완료했습니다." });
});
}
};
window.addEventListener("message", handlePopupMessage);
return () => window.removeEventListener("message", handlePopupMessage);
}, [appliedFilters, sort]);
return (
<div
ref={pageRef}
className="sn-exmn-registration-page flex min-h-0 flex-col overflow-hidden"
>
<LoadingOverlay isLoading={isLoading} />
<SearchDiv>
<div className="flex w-full flex-wrap items-center gap-y-3 px-5 py-4">
<div className="mr-8 flex items-center gap-2">
<Label className="whitespace-nowrap text-xl font-medium text-gray-600">사업차수</Label>
<Input type="number" min={0} className="h-[3.2rem] w-24 text-base" value={draftFilters.bizCycl} onChange={(event) => setDraftFilters((current) => ({ ...current, bizCycl: event.target.value }))} />
</div>
<SelectBox
label="사업년도"
labelClass="whitespace-nowrap text-xl font-medium text-gray-600"
className="mr-8 flex items-center gap-2"
selectClass="w-28 rounded-lg bg-white"
selectArray={getStartYearArray({ placeholder: "전체" })}
value={draftFilters.bizYr}
onSelectValue={(value) => setDraftFilters((current) => ({ ...current, bizYr: String(value) }))}
/>
<CtpvSggPrcsFcltSelectBox
sido={draftFilters.ctpvCd}
sigungu={draftFilters.sggCd}
onSidoChange={(ctpvCd) => setDraftFilters((current) => ({
...current,
ctpvCd,
sggCd: "0",
sggNm: "",
}))}
onSigunguChange={(sggCd) => setDraftFilters((current) => ({ ...current, sggCd }))}
setCtpvName={(ctpvNm) => setDraftFilters((current) => ({
...current,
ctpvNm: ctpvNm === "전체" ? "" : ctpvNm,
}))}
setSggName={(sggNm) => setDraftFilters((current) => ({
...current,
sggNm: sggNm === "전체" ? "" : sggNm,
}))}
/>
<div className="mr-8 flex items-center gap-2">
<Label className="whitespace-nowrap text-xl font-medium text-gray-600">사업명</Label>
<Input className="h-[3.2rem] w-52 text-base" value={draftFilters.bizNm} onChange={(event) => setDraftFilters((current) => ({ ...current, bizNm: event.target.value }))} onKeyDown={(event) => { if (event.key === "Enter") void handleSearch(); }} />
</div>
<div className="ml-auto flex shrink-0 items-center gap-2">
<Button className={ACTION_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={() => void handleSearch()}>조회</Button>
<Button className={ACTION_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={openBusinessRegister}>사업등록</Button>
<Button className={ACTION_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={openResultRegister}>결과등록</Button>
</div>
</div>
</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"
: notice.type === "info"
? "border-blue-200 bg-blue-50 text-blue-700"
: "border-emerald-200 bg-emerald-50 text-emerald-700"
}`}
>
{notice.text}
</div>
)}
<ListDiv className="mt-4 min-h-0 shrink-0 overflow-hidden">
<div className="card-header flex items-center justify-between">
<h3 className="text-2xl font-medium">조사사업 목록 <span className="text-base text-gray-500">총 {total.toLocaleString("ko-KR")}건</span></h3>
{process.env.NODE_ENV === "development" && (
<Button
variant="outline"
size="sm"
onClick={() => switchSampleMode(!sampleMode)}
>
{sampleMode ? "실데이터 보기" : "샘플 데이터 보기"}
</Button>
)}
</div>
<div className="relative mx-[1.2rem]" style={{ marginTop: "0.6rem" }}>
<div
className="overflow-x-hidden overflow-y-auto"
style={{
maxHeight: "16rem",
scrollbarGutter: "stable",
}}
>
<div
className="card !m-0"
style={{ paddingTop: 0, paddingBottom: 0 }}
>
<Table variant="secondary" className="table-fixed">
<colgroup>
{BUSINESS_TABLE_HEAD.map((head) => (
<col key={head.id} style={{ width: `${head.widthPercent}%` }} />
))}
</colgroup>
<TableHeader
className="font-bold"
style={{ position: "static", zIndex: "auto" }}
>
<TableRow>
{BUSINESS_TABLE_HEAD.map((head) => {
const sortKey = "sortKey" in head && head.sortKey ? head.sortKey : head.id;
const direction = sort?.key === sortKey ? sort.direction : undefined;
const sortable = "sortable" in head && head.sortable;
return (
<TableHead
key={head.id}
className="sticky top-0 z-20 bg-[#eaf3fb] text-center"
style={{
borderTopWidth: 0,
boxShadow: "inset 0 1px 0 var(--border)",
}}
aria-sort={
sortable
? direction === "asc"
? "ascending"
: direction === "desc"
? "descending"
: "none"
: undefined
}
>
{sortable ? (
<button
type="button"
className="inline-flex w-full items-center justify-center gap-1.5"
aria-label={`${head.title} 정렬`}
onClick={() => handleBusinessSort(head)}
>
<span>{head.title}</span>
{direction === "asc" ? (
<span
className="inline-flex min-w-2 justify-center text-[8px] leading-none text-[#1d4ed8]"
aria-hidden="true"
>
▲
</span>
) : direction === "desc" ? (
<span
className="inline-flex min-w-2 justify-center text-[8px] leading-none text-[#1d4ed8]"
aria-hidden="true"
>
▼
</span>
) : (
<span
className="inline-flex flex-col items-center gap-px text-[6px] leading-none text-[#475569]"
aria-hidden="true"
>
<span>▲</span>
<span>▼</span>
</span>
)}
</button>
) : head.title}
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody className="text-center">
{businessRows.length > 0 ? businessRows.map((business) => {
const selected = business.exmnBizId === selectedBusinessId;
return (
<TableRow
key={business.exmnBizId}
data-state={selected ? "selected" : undefined}
className="cursor-pointer"
onClick={() => setSelectedBusinessId(business.exmnBizId)}
>
{BUSINESS_TABLE_HEAD.map((head) => {
const isReportCell = head.id === "reportYnText";
const reportAvailable = isReportCell && Boolean(business.bizRptpAtchFileId?.trim());
return (
<TableCell key={head.id} className="!p-0">
<button
type="button"
className={`flex min-h-[4rem] w-full cursor-pointer items-center justify-center px-2 py-[1.2rem] text-[1.3rem] ${
reportAvailable
? "font-semibold text-blue-600 underline underline-offset-4 hover:text-blue-800"
: selected
? "font-bold"
: "font-normal"
}`}
aria-label={reportAvailable ? `${business.bizNm} 사업보고서 다운로드` : undefined}
title={reportAvailable ? "사업보고서 다운로드" : undefined}
onClick={(event) => {
if (reportAvailable) {
event.stopPropagation();
void handleReportDownload(business);
return;
}
setSelectedBusinessId(business.exmnBizId);
}}
>
{String(business[head.id as keyof typeof business] ?? "")}
</button>
</TableCell>
);
})}
</TableRow>
);
}) : (
<TableRow>
<TableCell colSpan={BUSINESS_TABLE_HEAD.length} className="h-24 text-center text-gray-500">
조회된 결과가 없습니다.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
<span
aria-hidden="true"
className="pointer-events-none absolute z-30"
style={{
right: "calc(0.6rem + 17px)",
bottom: 0,
left: "0.6rem",
borderTop: "1px solid var(--border)",
}}
/>
</div>
</ListDiv>
<ExmnStatusDetailPanel business={selectedBusiness} sampleMode={sampleMode} />
</div>
);
}
@@ -0,0 +1,447 @@
"use client";
import { handleUpload } from "@/components/lib/utils";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { postPopupMessageAndClose } from "@/features/windowPopup/lib/popupMessage";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import { Button } from "@common/components/ui";
import { UiTable } from "@common/business_components/ui";
import type { HttpResponse } from "tus-js-client";
import { useEffect, useMemo, useState } from "react";
import {
commitExmnImport,
getExmnBusiness,
updateExmnAttachments,
validateExmnImport,
} from "../api/exmn-business-api";
import {
EXMN_FILE_RULES,
validateExmnFile,
validateExmnFiles,
} from "../lib/exmn-business-validation";
import {
EXMN_SAMPLE_IMPORT_VALIDATION,
getExmnSampleBusinessDetail,
} from "../model/exmn-sample-data";
import type {
ExmnAttachmentKey,
ExmnBusiness,
ExmnBusinessDetail,
ExmnFileType,
ExmnImportValidationResult,
} from "../model/exmn-business-types";
const ACTION_BUTTON_CLASS =
"rounded-sm bg-blue-600 px-3 py-1.5 text-sm font-medium !text-white hover:bg-blue-700 disabled:!text-white";
const FILE_TYPES: ExmnFileType[] = [
"EXMN_EXCEL",
"PPL_EXMN_EXCEL",
"MNHL_EXMN_EXCEL",
"BIZ_REPORT",
"LOCATION_MAP",
];
const VALIDATION_BUTTON_FILE_TYPES = new Set<ExmnFileType>([
"EXMN_EXCEL",
"PPL_EXMN_EXCEL",
"MNHL_EXMN_EXCEL",
]);
const ERROR_TABLE_HEAD = [
{ id: "rowNumberText", title: "행", widthPercent: 7 },
{ id: "sheetName", title: "시트/처리분구", widthPercent: 17 },
{ id: "gisManagementNo", title: "GIS 관리번호", widthPercent: 16 },
{ id: "fieldPath", title: "필드/JSON 경로", widthPercent: 24 },
{ id: "code", title: "오류코드", widthPercent: 13 },
{ id: "message", title: "오류사유", widthPercent: 23 },
];
interface SelectedFile {
file: File;
fileList: FileList;
}
type SelectedFiles = Partial<Record<ExmnFileType, SelectedFile>>;
type Notice = { type: "success" | "error" | "info"; text: string } | null;
interface ExmnResultRegisterPopupPageProps {
exmnBizId: string;
sampleMode: boolean;
businessSummary: ExmnBusiness;
}
/** 선택한 조사 원본을 validate·commit 요청에 같은 필드명으로 담는다. */
const buildImportFormData = (
files: SelectedFiles,
validation?: ExmnImportValidationResult | null,
attachments?: Partial<Record<ExmnFileType, ExmnAttachmentKey>>,
) => {
const formData = new FormData();
if (files.PPL_EXMN_EXCEL) formData.append("pplFile", files.PPL_EXMN_EXCEL.file);
if (files.MNHL_EXMN_EXCEL) formData.append("mnhlFile", files.MNHL_EXMN_EXCEL.file);
if (validation?.fileHash) formData.append("fileHash", validation.fileHash);
if (attachments) formData.append("attachments", JSON.stringify(attachments));
return formData;
};
/** 기존 Tus 첨부서비스로 파일 한 건을 전송하고 서버 파일키를 반환한다. */
const uploadAttachment = (
selected: SelectedFile,
onProgress: (percentage: string) => void,
): Promise<ExmnAttachmentKey> => new Promise((resolve, reject) => {
let settled = false;
const rejectOnce = (error: Error) => {
if (settled) return;
settled = true;
reject(error);
};
const resolveFromResponse = (response: HttpResponse) => {
if (settled) return;
try {
const fileInfo = response.getHeader("X-File-Info");
if (!fileInfo) throw new Error("첨부서비스가 파일키를 반환하지 않았습니다.");
const parsed = JSON.parse(fileInfo) as { atflCd?: string; atflId?: string };
if (!parsed.atflCd || !parsed.atflId) throw new Error("첨부서비스 파일키 형식이 올바르지 않습니다.");
settled = true;
resolve({ atflCd: parsed.atflCd, atflId: parsed.atflId, fileName: selected.file.name });
} catch (error) {
rejectOnce(error instanceof Error ? error : new Error("첨부파일 응답을 해석할 수 없습니다."));
}
};
handleUpload(selected.fileList, {
handleProgress: onProgress,
handleResponse: resolveFromResponse,
handleError: rejectOnce,
});
});
/** 공통 WindowPopup 안에서 조사 파일 5종을 선택·검토·최종 저장하는 화면이다. */
export function ExmnResultRegisterPopupPage({
exmnBizId,
sampleMode,
businessSummary,
}: ExmnResultRegisterPopupPageProps) {
const [detail, setDetail] = useState<ExmnBusinessDetail | null>(null);
const [files, setFiles] = useState<SelectedFiles>({});
const [validation, setValidation] = useState<ExmnImportValidationResult | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [uploadStatus, setUploadStatus] = useState("");
const [notice, setNotice] = useState<Notice>(null);
/** popup layout 상위 html/body에서 생기는 문서 스크롤바를 팝업이 열린 동안 잠근다. */
useEffect(() => {
const previousHtmlOverflow = document.documentElement.style.overflow;
const previousBodyOverflow = document.body.style.overflow;
const previousBodyWidth = document.body.style.width;
document.documentElement.style.overflow = "hidden";
document.body.style.overflow = "hidden";
document.body.style.width = "100%";
return () => {
document.documentElement.style.overflow = previousHtmlOverflow;
document.body.style.overflow = previousBodyOverflow;
document.body.style.width = previousBodyWidth;
};
}, []);
/** 검토 오류를 UiTable 표시용 문자열 행으로 정규화한다. */
const validationRows = useMemo(() => (validation?.errors ?? []).map((error) => ({
...error,
rowNumberText: error.rowNumber == null ? "-" : String(error.rowNumber),
sheetName: error.sheetName || error.trtdstNm || error.trtdstId || "-",
gisManagementNo: error.gisManagementNo || "-",
fieldPath: error.fieldPath || "-",
})), [validation]);
/** 팝업 진입 시 선택 사업의 처리분구·첨부 상세를 읽는다. */
useEffect(() => {
if (!exmnBizId) {
setNotice({ type: "error", text: "결과를 등록할 조사사업 식별자가 없습니다." });
return;
}
if (sampleMode) {
setDetail(getExmnSampleBusinessDetail(exmnBizId, businessSummary));
return;
}
let active = true;
setIsLoading(true);
getExmnBusiness(exmnBizId)
.then((result) => {
if (active) setDetail(result);
})
.catch((error: unknown) => {
if (!active) return;
setNotice({
type: "error",
text: error instanceof Error ? error.message : "선택 사업 상세 조회 중 오류가 발생했습니다.",
});
})
.finally(() => {
if (active) setIsLoading(false);
});
return () => {
active = false;
};
}, [businessSummary, exmnBizId, sampleMode]);
/** 파일 변경 시 이전 검토결과를 폐기하고 확장자·크기 규칙을 즉시 확인한다. */
const handleFileChange = (fileType: ExmnFileType, fileList: FileList | null) => {
setValidation(null);
setUploadStatus("");
setFiles((current) => {
const next = { ...current };
delete next[fileType];
return next;
});
if (!fileList?.[0]) return;
const file = fileList[0];
const validationMessage = validateExmnFile(fileType, file);
if (validationMessage) {
setNotice({ type: "error", text: validationMessage });
return;
}
setFiles((current) => ({ ...current, [fileType]: { file, fileList } }));
setNotice(null);
};
/** 실제 모드는 서버 검토를 호출하고 샘플 모드는 명시적 정상 검토 결과만 화면에 제공한다. */
const handleValidate = async () => {
if (sampleMode) {
setValidation(EXMN_SAMPLE_IMPORT_VALIDATION);
setNotice({
type: "success",
text: `샘플 데이터 검토 완료: PPL ${EXMN_SAMPLE_IMPORT_VALIDATION.summary.pplCount}건, MNHL ${EXMN_SAMPLE_IMPORT_VALIDATION.summary.mnhlCount}건`,
});
return;
}
const fileMap = Object.fromEntries(
Object.entries(files).map(([type, selected]) => [type, selected?.file]),
) as Partial<Record<ExmnFileType, File>>;
const validationMessage = validateExmnFiles(fileMap);
if (validationMessage) {
setNotice({ type: "error", text: validationMessage });
return;
}
setIsLoading(true);
setNotice(null);
try {
const result = await validateExmnImport(exmnBizId, buildImportFormData(files));
setValidation(result);
setNotice({
type: result.valid ? "success" : "error",
text: result.valid
? `검토가 완료되었습니다. PPL ${result.summary.pplCount}건, MNHL ${result.summary.mnhlCount}건`
: `검토 오류 ${result.errors.length}건을 확인해주세요.`,
});
} catch (error) {
setValidation(null);
setNotice({
type: "error",
text: error instanceof Error ? error.message : "조사파일 검토 중 오류가 발생했습니다.",
});
} finally {
setIsLoading(false);
}
};
/** 모든 파일을 첨부서비스에 올리고 발급 키를 조사사업에 연결한다. */
const uploadAndLinkAttachments = async (): Promise<Partial<Record<ExmnFileType, ExmnAttachmentKey>>> => {
const attachments: Partial<Record<ExmnFileType, ExmnAttachmentKey>> = {};
for (const fileType of FILE_TYPES) {
const selected = files[fileType];
if (!selected) continue;
const label = EXMN_FILE_RULES[fileType].label;
attachments[fileType] = await uploadAttachment(selected, (percentage) => {
setUploadStatus(`${label} 업로드 ${percentage}%`);
});
}
await updateExmnAttachments(exmnBizId, attachments);
return attachments;
};
/** 정상 검토된 파일을 저장하고 부모 화면에 완료 메시지를 보낸 뒤 팝업을 닫는다. */
const handleCommit = async () => {
if (!validation?.valid) return;
if (sampleMode) {
postPopupMessageAndClose(WINDOW_POPUP_MESSAGE_TYPE.EXMN_RESULT_COMMITTED, {
exmnBizId,
sample: "Y",
});
return;
}
setIsLoading(true);
setNotice(null);
try {
const attachments = await uploadAndLinkAttachments();
await commitExmnImport(exmnBizId, buildImportFormData(files, validation, attachments));
postPopupMessageAndClose(WINDOW_POPUP_MESSAGE_TYPE.EXMN_RESULT_COMMITTED, {
exmnBizId,
sample: "N",
});
} catch (error) {
setNotice({
type: "error",
text: error instanceof Error ? error.message : "조사결과 최종 저장 중 오류가 발생했습니다.",
});
} finally {
setIsLoading(false);
}
};
return (
<main className="sn-popup-page">
<LoadingOverlay isLoading={isLoading} />
<header className="sn-popup-header">
<h1 className="sn-popup-title">조사 결과 업로드</h1>
<div>
<Button
type="button"
color="white"
size="xs"
className="sn-popup-header-button"
disabled={isLoading || !validation?.valid}
onClick={() => void handleCommit()}
>
저장
</Button>
</div>
</header>
<section className="flex min-h-0 flex-1 flex-col gap-2 overflow-x-hidden overflow-y-auto px-4 pb-3 pt-3">
{notice && (
<div role="alert" className={`rounded border px-4 py-3 text-sm ${
notice.type === "error"
? "border-red-200 bg-red-50 text-red-700"
: notice.type === "success"
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: "border-blue-200 bg-blue-50 text-blue-700"
}`}>{notice.text}</div>
)}
<section
className="rounded-md border border-border bg-secondary p-3"
aria-label="선택 조사사업 요약"
>
<div className="flex min-w-0 flex-wrap items-center gap-x-7 gap-y-2">
<div className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-[1.05rem] font-medium text-label">사업차수</span>
<span className="whitespace-nowrap text-[1.05rem] font-semibold text-foreground">
{detail?.bizCycl ?? businessSummary.bizCycl ?? "-"}차
</span>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-[1.05rem] font-medium text-label">사업년도</span>
<span className="whitespace-nowrap text-[1.05rem] font-semibold text-foreground">
{detail?.bizYr ?? businessSummary.bizYr ?? "-"}
</span>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-[1.05rem] font-medium text-label">행정구역</span>
<span
className="max-w-[22rem] truncate text-[1.05rem] font-semibold text-foreground"
title={`${detail?.ctpvNm ?? businessSummary.ctpvNm} ${detail?.sggNm ?? businessSummary.sggNm}`}
>
{detail?.ctpvNm ?? businessSummary.ctpvNm} {detail?.sggNm ?? businessSummary.sggNm}
</span>
</div>
<div className="flex min-w-[24rem] flex-1 items-center gap-2">
<span className="shrink-0 whitespace-nowrap text-[1.05rem] font-medium text-label">사업명</span>
<span
className="min-w-0 truncate text-[1.05rem] font-semibold text-foreground"
title={detail?.bizNm ?? businessSummary.bizNm}
>
{detail?.bizNm ?? businessSummary.bizNm ?? "-"}
</span>
</div>
</div>
</section>
<section className="overflow-hidden border border-border">
<div
className="grid items-center gap-3 bg-[#eaf3fb] px-3 py-2.5 text-center text-sm font-semibold text-title"
style={{ gridTemplateColumns: "180px minmax(0, 1fr) 80px 112px" }}
>
<span>파일 구분</span><span>선택 파일</span><span>찾기</span><span>데이터 검토</span>
</div>
{FILE_TYPES.map((fileType) => {
const rule = EXMN_FILE_RULES[fileType];
const selected = files[fileType];
const inputId = `exmn-popup-file-${fileType}`;
const canValidate = VALIDATION_BUTTON_FILE_TYPES.has(fileType);
return (
<div
key={fileType}
className="grid items-center gap-3 border-t border-border px-3 py-3 text-sm"
style={{ gridTemplateColumns: "180px minmax(0, 1fr) 80px 112px" }}
>
<label htmlFor={inputId} className="font-medium text-gray-700">
{(fileType === "PPL_EXMN_EXCEL" || fileType === "MNHL_EXMN_EXCEL") && <span className="text-red-500">* </span>}
{rule.label}
</label>
<div
className={`truncate border px-3 py-1.5 ${selected
? "border-blue-300 bg-blue-50 font-semibold text-blue-700"
: "border-[#9fb2c4] bg-white text-gray-600"
}`}
title={selected?.file.name}
>
{selected?.file.name || `${rule.extensions.map((extension) => `.${extension}`).join(", ")} 파일`}
</div>
<div className="text-center">
<input
id={inputId}
type="file"
className="sr-only"
accept={rule.extensions.map((extension) => `.${extension}`).join(",")}
disabled={isLoading}
onChange={(event) => handleFileChange(fileType, event.target.files)}
/>
<label htmlFor={inputId} className="inline-flex cursor-pointer rounded-sm bg-blue-600 px-3 py-1.5 font-medium text-white hover:bg-blue-700">찾기</label>
</div>
<div className="text-center">
{canValidate ? (
<Button className={ACTION_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={() => void handleValidate()}>데이터 검토</Button>
) : <span className="text-gray-400">-</span>}
</div>
</div>
);
})}
</section>
{validation && (
<section className="border border-border p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<h2 className="font-semibold text-gray-700">데이터 검토 결과</h2>
<p className="mt-1 text-sm text-gray-500">PPL {validation.summary.pplCount}건 · MNHL {validation.summary.mnhlCount}건</p>
</div>
<span style={{ maxWidth: "50%" }} className="truncate text-xs text-gray-500">검토 해시: {validation.fileHash || "-"}</span>
</div>
{validationRows.length > 0 ? (
<UiTable
headName=""
headlist={ERROR_TABLE_HEAD}
tableData={validationRows}
total={validationRows.length}
pageSize={Math.max(validationRows.length, 1)}
curPage={1}
widthFixed
cellClick={undefined}
onPageChange={() => undefined}
/>
) : (
<div className="rounded border border-emerald-200 bg-emerald-50 px-4 py-5 text-center text-sm text-emerald-700">데이터 검토 오류가 없습니다.</div>
)}
</section>
)}
{uploadStatus && <p className="text-right text-sm text-blue-700">{uploadStatus}</p>}
</section>
</main>
);
}
@@ -0,0 +1,553 @@
"use client";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import {
Button,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@common/components/ui";
import type { UiTableSort } from "@common/business_components/ui";
import ListDiv from "@common/business_components/ui/list_div";
import { useEffect, useMemo, useRef, useState } from "react";
import { getExmnSectionSummaries } from "../api/exmn-business-api";
import type {
ExmnBusiness,
ExmnJobType,
ExmnSectionSummaryRow,
} from "../model/exmn-business-types";
import type { ExmnDetailDefectType } from "../model/exmn-detail-table";
import { getExmnSampleSectionSummaries } from "../model/exmn-sample-data";
const 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";
const DOWNLOAD_BUTTON_CLASS =
"rounded-sm px-4 py-2 text-sm font-medium !text-white disabled:!text-white";
// 목록 그리드와 같이 헤더 1행 + 본문 6행만 보이게 해 다음 행 일부가 노출되지 않게 한다.
// 최초 화면의 본문 6행은 합계 1행 + 데이터 5행이며, 스크롤 후에는 데이터 6행이 온전히 보인다.
const SUMMARY_GRID_ROW_HEIGHT_REM = 3.2;
const SUMMARY_GRID_VISIBLE_BODY_ROW_COUNT = 6;
const SUMMARY_GRID_MAX_HEIGHT = `${
SUMMARY_GRID_ROW_HEIGHT_REM * (SUMMARY_GRID_VISIBLE_BODY_ROW_COUNT + 1)
}rem`;
const PIPE_SUMMARY_TABLE_HEAD = [
{ id: "rowNumber", title: "NO", widthPercent: 6, sortable: true },
{ id: "trtdstNm", title: "처리분구", widthPercent: 18, sortable: true },
{ id: "trgtPrlgText", title: "대상연장(m)", widthPercent: 13, sortable: true },
{ id: "drvngDstncText", title: "주행거리(m)", widthPercent: 13, sortable: true },
{ id: "wholeCountText", title: "총 이상항목(개소)", widthPercent: 15, sortable: true },
{ id: "structuralCountText", title: "구조적결함(개소)", widthPercent: 14, sortable: true },
{ id: "operationalCountText", title: "운영적결함(개소)", widthPercent: 14, sortable: true },
{ id: "etcCountText", title: "기타", widthPercent: 7, sortable: true },
];
const MANHOLE_SUMMARY_TABLE_HEAD = [
{ id: "rowNumber", title: "NO", widthPercent: 8, sortable: true },
{ id: "trtdstNm", title: "처리분구", widthPercent: 28, sortable: true },
{ id: "wholeCountText", title: "총 이상항목(개소)", widthPercent: 18, sortable: true },
{ id: "structuralCountText", title: "구조적결함(개소)", widthPercent: 17, sortable: true },
{ id: "operationalCountText", title: "운영적결함(개소)", widthPercent: 17, sortable: true },
{ id: "etcCountText", title: "기타", widthPercent: 12, sortable: true },
];
type Notice = { type: "info" | "error"; text: string } | null;
interface ExmnStatusDetailPanelProps {
business: ExmnBusiness | null;
sampleMode: boolean;
}
const formatNumber = (value: number | null | undefined) =>
Number(value ?? 0).toLocaleString("ko-KR", { maximumFractionDigits: 3 });
const getSummarySortValue = (row: DisplaySummaryRow, key: string) => {
const value = row[key];
if (key !== "trtdstNm") {
const numericValue = Number(String(value ?? "").replaceAll(",", ""));
if (Number.isFinite(numericValue)) return numericValue;
}
return String(value ?? "");
};
/** 합계 행을 제외한 처리분구별 상세 행만 현재 정렬 상태에 맞춰 정렬한다. */
const sortSummaryRows = (
rows: DisplaySummaryRow[],
sort: UiTableSort | undefined,
) => {
if (!sort) return rows;
return [...rows].sort((left, right) => {
const leftValue = getSummarySortValue(left, sort.key);
const rightValue = getSummarySortValue(right, sort.key);
const comparison = typeof leftValue === "number" && typeof rightValue === "number"
? leftValue - rightValue
: String(leftValue).localeCompare(String(rightValue), "ko", {
numeric: true,
sensitivity: "base",
});
return sort.direction === "desc" ? comparison * -1 : comparison;
});
};
interface DisplaySummaryRow extends Record<string, string | number> {
trtdstId: string;
rowNumber: string | number;
trtdstNm: string;
trgtPrlgText: string;
drvngDstncText: string;
wholeCountText: string;
structuralCountText: string;
operationalCountText: string;
etcCountText: string;
}
/** 동일 조사현황 화면에서 선택 사업의 관로·맨홀 처리분구 집계를 펼쳐 표시한다. */
export function ExmnStatusDetailPanel({ business, sampleMode }: ExmnStatusDetailPanelProps) {
const [jobType, setJobType] = useState<ExmnJobType>("PPL");
const [summarySort, setSummarySort] = useState<UiTableSort>();
const [summaryRows, setSummaryRows] = useState<ExmnSectionSummaryRow[]>([]);
/** 상세보기 팝업에 전달할 현재 선택 처리분구다. */
const [selectedTrtdstId, setSelectedTrtdstId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [notice, setNotice] = useState<Notice>(null);
const summaryRequestIdRef = useRef(0);
useEffect(() => {
setJobType("PPL");
setSummarySort(undefined);
setSelectedTrtdstId(null);
setNotice(null);
}, [business?.exmnBizId]);
useEffect(() => {
setSummarySort(undefined);
setSelectedTrtdstId(null);
}, [jobType]);
useEffect(() => {
if (!business) {
summaryRequestIdRef.current += 1;
setSummaryRows([]);
setIsLoading(false);
return;
}
const requestId = summaryRequestIdRef.current + 1;
summaryRequestIdRef.current = requestId;
setIsLoading(true);
setNotice(null);
const request = sampleMode
? Promise.resolve(getExmnSampleSectionSummaries(business.exmnBizId, jobType))
: getExmnSectionSummaries(business.exmnBizId, jobType);
request
.then((rows) => {
if (summaryRequestIdRef.current === requestId) setSummaryRows(rows);
})
.catch((error: unknown) => {
if (summaryRequestIdRef.current !== requestId) return;
setSummaryRows([]);
setNotice({ type: "error", text: error instanceof Error ? error.message : "조사 상세현황 조회 중 오류가 발생했습니다." });
})
.finally(() => {
if (summaryRequestIdRef.current === requestId) setIsLoading(false);
});
return () => {
if (summaryRequestIdRef.current === requestId) summaryRequestIdRef.current += 1;
};
}, [business, jobType, sampleMode]);
const summaryTable = useMemo(() => {
const getCounts = (row: ExmnSectionSummaryRow) => jobType === "PPL"
? {
whole: row.pplExmnWholAnmlyArtclCnt,
structural: row.pplExmnStrctDfcCnt,
operational: row.pplExmnOperDfcCnt,
etc: row.pplExmnEtcCnt,
}
: {
whole: row.mnhlExmnWholAnmlyArtclCnt,
structural: row.mnhlExmnStrctDfcCnt,
operational: row.mnhlExmnOperDfcCnt,
etc: row.mnhlExmnEtcCnt,
};
const totals = summaryRows.reduce((sum, row) => {
const counts = getCounts(row);
return {
trgtPrlg: sum.trgtPrlg + row.trgtPrlg,
drvngDstnc: sum.drvngDstnc + row.drvngDstnc,
whole: sum.whole + counts.whole,
structural: sum.structural + counts.structural,
operational: sum.operational + counts.operational,
etc: sum.etc + counts.etc,
};
}, { trgtPrlg: 0, drvngDstnc: 0, whole: 0, structural: 0, operational: 0, etc: 0 });
const rows: DisplaySummaryRow[] = summaryRows.map((row, index) => {
const counts = getCounts(row);
return {
trtdstId: row.trtdstId,
rowNumber: index + 1,
trtdstNm: row.trtdstNm,
trgtPrlgText: formatNumber(row.trgtPrlg),
drvngDstncText: formatNumber(row.drvngDstnc),
wholeCountText: formatNumber(counts.whole),
structuralCountText: formatNumber(counts.structural),
operationalCountText: formatNumber(counts.operational),
etcCountText: formatNumber(counts.etc),
};
});
return {
rows: sortSummaryRows(rows, summarySort),
totals: {
trgtPrlgText: formatNumber(totals.trgtPrlg),
drvngDstncText: formatNumber(totals.drvngDstnc),
wholeCountText: formatNumber(totals.whole),
structuralCountText: formatNumber(totals.structural),
operationalCountText: formatNumber(totals.operational),
etcCountText: formatNumber(totals.etc),
} as Record<string, string>,
};
}, [jobType, summaryRows, summarySort]);
const summaryColumns = jobType === "PPL" ? PIPE_SUMMARY_TABLE_HEAD : MANHOLE_SUMMARY_TABLE_HEAD;
const selectedSummaryRow = summaryTable.rows.find(
(row) => row.trtdstId === selectedTrtdstId,
) ?? null;
/** 상세현황 헤더를 오름차순·내림차순·기본 순서로 순환한다. */
const handleSummarySort = (column: (typeof summaryColumns)[number]) => {
const key = column.id;
setSummarySort((current) => (
current?.key !== key
? { key, direction: "asc" }
: current.direction === "asc"
? { key, direction: "desc" }
: undefined
));
};
const showPendingNotice = (feature: "다운로드" | "지도보기") => {
const text = feature === "다운로드"
? "다운로드는 집계 데이터와 파일 계약 확정 후 연결할 예정입니다."
: "지도보기는 현재 보류 기능이며 버튼 진입점만 표시합니다.";
setNotice({ type: "info", text });
};
/** 선택 사업·처리분구, 조사유형, 결함 범위를 공통 WindowPopup 상세표에 전달한다. */
const openSurveyDetailPopup = (
summaryRow: DisplaySummaryRow | null = selectedSummaryRow,
defectType: ExmnDetailDefectType = "ALL",
) => {
if (!business || !summaryRow) return;
const popup = openWindowPopup("exmnSurveyDetail", {
exmnBizId: business.exmnBizId,
trtdstId: summaryRow.trtdstId,
trtdstNm: summaryRow.trtdstNm,
jobType,
defectType,
bizYr: business.bizYr,
bizCycl: business.bizCycl,
bizNm: business.bizNm,
ctpvNm: business.ctpvNm,
sggNm: business.sggNm,
trtdstCnt: business.trtdstCnt,
prcisExmnPrlg: business.prcisExmnPrlg,
tpco: business.tpco,
reportYn: business.reportYn,
sample: sampleMode ? "Y" : "N",
});
if (!popup) {
setNotice({
type: "error",
text: "상세보기 팝업을 열 수 없습니다. 브라우저의 팝업 허용 상태를 확인해주세요.",
});
}
};
/** 사업목록과 동일하게 처리분구 행의 어느 셀을 눌러도 상세 대상 행으로 선택한다. */
const handleSummaryRowSelect = (row: DisplaySummaryRow) => {
setSelectedTrtdstId(row.trtdstId);
setNotice(null);
};
/** 보이는 버튼 전체에서 조사유형을 즉시 바꾸고, 상세 조회는 별도 effect에서 수행한다. */
const handleJobTypeChange = (nextJobType: ExmnJobType) => {
if (nextJobType === jobType) return;
setJobType(nextJobType);
};
// PPT 3의 최초 상태처럼 사업을 선택하기 전에는 상세현황 영역 자체를 표시하지 않는다.
if (!business) return null;
return (
<ListDiv className="mt-4 flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="card-header flex min-h-[5.2rem] flex-wrap items-center gap-3">
<h3 className="mr-3 text-2xl font-medium">조사 상세현황</h3>
<div
role="tablist"
aria-label="조사 유형"
aria-busy={isLoading}
className="flex items-center gap-1"
>
{([
{ value: "PPL", label: "관로조사" },
{ value: "MNHL", label: "맨홀조사" },
] as const).map((tab) => (
<button
key={tab.value}
type="button"
role="tab"
aria-selected={jobType === tab.value}
className={`inline-flex cursor-pointer items-center justify-center whitespace-nowrap rounded-md border text-[1.4rem] font-semibold transition-colors ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${jobType === tab.value
? "border-primary bg-primary text-white"
: "border-border bg-background text-muted-foreground hover:bg-accent"
}`}
style={{ width: "8rem", height: "3.2rem" }}
onClick={() => handleJobTypeChange(tab.value)}
>
{tab.label}
</button>
))}
</div>
<div className="ml-auto flex items-center gap-2">
<Button
className={ACTION_BUTTON_CLASS}
size="sm"
disabled={!selectedSummaryRow}
title={selectedSummaryRow ? `${selectedSummaryRow.trtdstNm} 상세보기` : "처리분구 행을 먼저 선택해주세요."}
onClick={() => openSurveyDetailPopup()}
>
상세보기
</Button>
<Button className={ACTION_BUTTON_CLASS} size="sm" onClick={() => showPendingNotice("지도보기")}>지도보기</Button>
<Button
color="green"
className={DOWNLOAD_BUTTON_CLASS}
size="sm"
onClick={() => showPendingNotice("다운로드")}
>
다운로드
</Button>
</div>
</div>
{notice && (
<div role="alert" className={`mx-4 mt-3 rounded border px-4 py-3 text-sm ${notice.type === "error" ? "border-red-200 bg-red-50 text-red-700" : "border-blue-200 bg-blue-50 text-blue-700"}`}>{notice.text}</div>
)}
<div className="flex min-h-0 flex-1 flex-col pb-0 pt-3">
<div className="mx-[1.2rem] mb-2 flex items-center gap-2">
<h4 className="text-lg font-semibold text-gray-700">{jobType === "PPL" ? "관로조사" : "맨홀조사"} 종합집계표</h4>
{isLoading && <span role="status" className="text-sm text-muted-foreground">조회 중...</span>}
</div>
<div className="relative mx-[1.2rem] mb-[1.2rem] min-h-0 overflow-hidden">
<div
className="overflow-x-hidden overflow-y-auto"
style={{ maxHeight: SUMMARY_GRID_MAX_HEIGHT, scrollbarGutter: "stable" }}
>
<div
className="card !m-0"
style={{ paddingTop: 0, paddingBottom: 0 }}
>
<Table variant="secondary" className="table-fixed">
<colgroup>
{summaryColumns.map((column) => (
<col key={column.id} style={{ width: `${column.widthPercent}%` }} />
))}
</colgroup>
<TableHeader
className="font-bold"
style={{ position: "static", zIndex: "auto" }}
>
<TableRow style={{ height: `${SUMMARY_GRID_ROW_HEIGHT_REM}rem` }}>
{summaryColumns.map((column) => {
const direction = summarySort?.key === column.id
? summarySort.direction
: undefined;
return (
<TableHead
key={column.id}
className="sticky top-0 z-20 bg-[#eaf3fb] text-center !py-[0.8rem]"
style={{
borderTopWidth: 0,
boxShadow: "inset 0 1px 0 var(--border)",
}}
aria-sort={
direction === "asc"
? "ascending"
: direction === "desc"
? "descending"
: "none"
}
>
<button
type="button"
className="inline-flex w-full items-center justify-center gap-1.5"
aria-label={`${column.title} 정렬`}
onClick={() => handleSummarySort(column)}
>
<span>{column.title}</span>
{direction === "asc" ? (
<span
className="inline-flex min-w-2 justify-center text-[8px] leading-none text-[#1d4ed8]"
aria-hidden="true"
>
▲
</span>
) : direction === "desc" ? (
<span
className="inline-flex min-w-2 justify-center text-[8px] leading-none text-[#1d4ed8]"
aria-hidden="true"
>
▼
</span>
) : (
<span
className="inline-flex flex-col items-center gap-px text-[6px] leading-none text-[#475569]"
aria-hidden="true"
>
<span>▲</span>
<span>▼</span>
</span>
)}
</button>
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody className="text-center">
{summaryTable.rows.length > 0 ? (
<>
<TableRow
className="text-gray-700"
style={{
height: `${SUMMARY_GRID_ROW_HEIGHT_REM}rem`,
backgroundColor: "#d2deef",
}}
>
<TableCell
colSpan={2}
className="!py-[0.8rem] text-center !font-semibold"
style={{ backgroundColor: "#d2deef" }}
>
합계
</TableCell>
{summaryColumns.slice(2).map((column) => (
<TableCell
key={`total-${column.id}`}
className="!py-[0.8rem] text-center !font-semibold"
style={{ backgroundColor: "#d2deef" }}
>
{summaryTable.totals[column.id] ?? "-"}
</TableCell>
))}
</TableRow>
{summaryTable.rows.map((row, rowIndex) => {
const selected = row.trtdstId === selectedTrtdstId;
return (
<TableRow
key={row.trtdstId}
data-state={selected ? "selected" : undefined}
className="cursor-pointer"
style={{ height: `${SUMMARY_GRID_ROW_HEIGHT_REM}rem` }}
onClick={() => handleSummaryRowSelect(row)}
>
{summaryColumns.map((column) => {
const defectType: ExmnDetailDefectType | null =
column.id === "structuralCountText"
? "STRUCTURAL"
: column.id === "operationalCountText"
? "OPERATIONAL"
: null;
const defectCount = defectType
? Number(
String(row[column.id] ?? "0").replaceAll(",", ""),
)
: 0;
const opensFilteredDetail =
defectType !== null && defectCount > 0;
return (
<TableCell
key={`${row.trtdstId}-${column.id}`}
className="!p-0 text-center"
style={
rowIndex === summaryTable.rows.length - 1
? { borderBottomWidth: 0 }
: undefined
}
>
<button
type="button"
className={`flex w-full cursor-pointer items-center justify-center px-2 py-[0.8rem] text-[1.3rem] ${
opensFilteredDetail
? "font-semibold text-blue-600 underline underline-offset-4 hover:text-blue-800"
: selected
? "font-bold"
: "font-normal"
}`}
style={{ minHeight: `${SUMMARY_GRID_ROW_HEIGHT_REM}rem` }}
aria-label={
opensFilteredDetail
? `${row.trtdstNm} ${
defectType === "STRUCTURAL" ? "구조적" : "운영적"
} 결함 상세보기`
: undefined
}
title={
opensFilteredDetail
? "해당 결함만 상세보기"
: undefined
}
onClick={(event) => {
event.stopPropagation();
handleSummaryRowSelect(row);
if (opensFilteredDetail && defectType) {
openSurveyDetailPopup(row, defectType);
}
}}
>
{row[column.id]}
</button>
</TableCell>
);
})}
</TableRow>
);
})}
</>
) : (
<TableRow>
<TableCell
colSpan={summaryColumns.length}
className="h-24 text-center text-gray-500"
style={{ borderBottomWidth: 0 }}
>
조회된 결과가 없습니다.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
<span
aria-hidden="true"
className="pointer-events-none absolute z-30"
style={{
right: "calc(0.6rem + 17px)",
bottom: 0,
left: "0.6rem",
borderTop: "1px solid var(--border)",
}}
/>
</div>
</div>
</ListDiv>
);
}
@@ -0,0 +1,6 @@
import { ExmnRegistrationPage } from "./ExmnRegistrationPage";
/** 조사현황 URL도 PPT 3·4와 동일한 사업목록·행 선택 상세 통합 화면을 사용한다. */
export function ExmnStatusPage() {
return <ExmnRegistrationPage />;
}
@@ -0,0 +1,739 @@
"use client";
import type { UiTableSort } from "@common/business_components/ui";
import { Button, Input } from "@common/components/ui";
import { useEffect, useMemo, useState, type UIEvent } from "react";
import type { ExmnBusiness, ExmnJobType } from "../model/exmn-business-types";
import {
EXMN_DETAIL_SEVERITIES,
EXMN_DETAIL_TABLE_DEFINITIONS,
type ExmnDetailDefectColumn,
type ExmnDetailDefectType,
type ExmnDetailSeverity,
type ExmnSeverityCounts,
type ExmnSurveyDetailRow,
} from "../model/exmn-detail-table";
import { getExmnSampleSurveyDetails } from "../model/exmn-sample-data";
const SEVERITY_LABELS: Record<ExmnDetailSeverity, string> = {
L: "대",
M: "중",
S: "소",
};
/** 상세보기 상단 조회 조건의 조사대상 표시 문구다. */
const DETAIL_SCOPE_LABELS: Record<ExmnJobType, string> = {
PPL: "관로 / 본단위구간",
MNHL: "맨홀",
};
const DEFECT_TYPE_LABELS: Record<ExmnDetailDefectType, string> = {
ALL: "전체",
STRUCTURAL: "구조적결함",
OPERATIONAL: "운영적결함",
};
const FIXED_COLUMNS = [
{ id: "reportNo", label: "보고서\n번호", width: 118 },
{ id: "pipeNo", label: "관로\n번호", width: 120 },
{ id: "upstreamManhole", label: "상류\n맨홀", width: 135 },
{ id: "downstreamManhole", label: "하류\n맨홀", width: 135 },
{ id: "drainageType", label: "배수\n방식", width: 82 },
{ id: "pipeType", label: "관종", width: 76 },
{ id: "specification", label: "규격", width: 82 },
{ id: "extensionM", label: "연장", width: 86 },
{ id: "drivingDistanceM", label: "주행\n거리", width: 92 },
] as const;
const REPORT_COLUMN_LEFT = 0;
const PIPE_COLUMN_LEFT = FIXED_COLUMNS[0].width;
const STICKY_COLUMN_COUNT = 2;
const SUMMARY_LABEL_COLUMN_COUNT = 7;
const FIXED_INFO_SCROLL_THRESHOLD = FIXED_COLUMNS
.slice(STICKY_COLUMN_COUNT, SUMMARY_LABEL_COLUMN_COUNT)
.reduce((sum, column) => sum + column.width, 0);
const STICKY_COLUMNS_WIDTH = FIXED_COLUMNS
.slice(0, STICKY_COLUMN_COUNT)
.reduce((sum, column) => sum + column.width, 0);
const SUMMARY_LABEL_COLUMNS_WIDTH = FIXED_COLUMNS
.slice(0, SUMMARY_LABEL_COLUMN_COUNT)
.reduce((sum, column) => sum + column.width, 0);
const SUMMARY_LABEL_INITIAL_OFFSET =
(SUMMARY_LABEL_COLUMNS_WIDTH - STICKY_COLUMNS_WIDTH) / 2;
const STICKY_DIVIDER_SHADOW = "2px 0 0 #b8c5d1";
const SEVERITY_COLUMN_WIDTH = 52;
type ExmnDetailFixedColumnId = (typeof FIXED_COLUMNS)[number]["id"];
interface ExmnSurveyDetailPopupPageProps {
business: ExmnBusiness;
trtdstId: string;
trtdstNm: string;
jobType: ExmnJobType;
defectType: ExmnDetailDefectType;
sampleMode: boolean;
}
type Notice = { type: "info"; text: string } | null;
/** 숫자를 엑셀 화면과 같은 최대 소수점 1자리 형식으로 표시한다. */
const formatNumber = (value: number) =>
value.toLocaleString("ko-KR", { maximumFractionDigits: 1 });
/** 이상항목이 없는 셀은 0 대신 엑셀형 빈 상태인 하이픈으로 표시한다. */
const formatCount = (value: number | null | undefined) =>
Number(value ?? 0) === 0 ? "-" : Number(value).toLocaleString("ko-KR");
/** 공통 고정 열의 행 값을 화면용 문자열로 변환한다. */
const getFixedCellValue = (
row: ExmnSurveyDetailRow,
columnId: ExmnDetailFixedColumnId,
) => {
const value = row[columnId];
return typeof value === "number" ? formatNumber(value) : value;
};
/** 고정 열의 숫자·한글·번호 혼합 값을 공통 그리드와 같은 방향으로 비교한다. */
const compareFixedColumnValue = (
left: ExmnSurveyDetailRow,
right: ExmnSurveyDetailRow,
columnId: ExmnDetailFixedColumnId,
) => {
const leftValue = left[columnId];
const rightValue = right[columnId];
if (typeof leftValue === "number" && typeof rightValue === "number") {
return leftValue - rightValue;
}
return String(leftValue ?? "").localeCompare(String(rightValue ?? ""), "ko-KR", {
numeric: true,
sensitivity: "base",
});
};
/** 현재 화면에 노출하는 결함 열만 합산해 행의 대·중·소 합계를 계산한다. */
const getVisibleAnomalyTotals = (
row: ExmnSurveyDetailRow,
defectColumns: ExmnDetailDefectColumn[],
): ExmnSeverityCounts => {
const totals: ExmnSeverityCounts = { L: 0, M: 0, S: 0 };
defectColumns.forEach((column) => {
EXMN_DETAIL_SEVERITIES.forEach((severity) => {
totals[severity] += row.defectCounts[column.code]?.[severity] ?? 0;
});
});
return totals;
};
/** 엑셀 `01. 덕풍`·`01. 덕풍 (2)` 구조를 관로·맨홀 공통 WindowPopup으로 표시한다. */
export function ExmnSurveyDetailPopupPage({
trtdstId,
trtdstNm,
jobType,
defectType,
sampleMode,
}: ExmnSurveyDetailPopupPageProps) {
/** 관로·맨홀 번호 조회 입력값과 실제 적용값을 분리해 조회 버튼 동작을 재현한다. */
const [detailNumberKeyword, setDetailNumberKeyword] = useState("");
const [appliedDetailNumberKeyword, setAppliedDetailNumberKeyword] = useState("");
/** 고정 열 헤더의 오름차순·내림차순·기본 정렬 상태다. */
const [sort, setSort] = useState<UiTableSort>();
/** 가로 스크롤 위치를 기준으로 요약 라벨과 고정 열 경계선 위치를 함께 보정한다. */
const [tableScrollLeft, setTableScrollLeft] = useState(0);
/** 다운로드 미연결 등 현재 팝업 안내 메시지를 표시한다. */
const [notice, setNotice] = useState<Notice>(null);
const definition = EXMN_DETAIL_TABLE_DEFINITIONS[jobType];
const defectTypeLabel = DEFECT_TYPE_LABELS[defectType];
/** 건수 링크 진입 시 선택한 구조·운영 그룹의 결함 열만 표에 남긴다. */
const visibleDefectColumns = useMemo(
() => defectType === "ALL"
? definition.defectColumns
: definition.defectColumns.filter((column) => column.group === defectType),
[defectType, definition],
);
/** 샘플 모드에서 선택 조사유형에 해당하는 상세 원본 행을 제공한다. */
const sourceRows = useMemo(
() => (sampleMode ? getExmnSampleSurveyDetails(jobType) : []),
[jobType, sampleMode],
);
/** 분류별 상세에서는 선택 결함이 한 건 이상 존재하는 시설 행만 표시한다. */
const scopedRows = useMemo(() => {
if (defectType === "ALL") return sourceRows;
return sourceRows.filter((row) =>
visibleDefectColumns.some((column) =>
EXMN_DETAIL_SEVERITIES.some(
(severity) => (row.defectCounts[column.code]?.[severity] ?? 0) > 0,
),
),
);
}, [defectType, sourceRows, visibleDefectColumns]);
/** 조회 버튼으로 확정한 번호 조건을 현재 상세 행에 적용한다. */
const filteredRows = useMemo(() => {
const normalizedKeyword = appliedDetailNumberKeyword.trim().toLocaleLowerCase("ko-KR");
if (!normalizedKeyword) return scopedRows;
return scopedRows.filter((row) =>
row.pipeNo.toLocaleLowerCase("ko-KR").includes(normalizedKeyword),
);
}, [appliedDetailNumberKeyword, scopedRows]);
/** 보고서번호부터 주행거리까지의 고정 열에 선택 정렬을 적용한다. */
const rows = useMemo(() => {
if (!sort) return filteredRows;
const columnId = sort.key as ExmnDetailFixedColumnId;
const direction = sort.direction === "asc" ? 1 : -1;
return [...filteredRows].sort(
(left, right) => compareFixedColumnValue(left, right, columnId) * direction,
);
}, [filteredRows, sort]);
/** 팝업 문서 자체 스크롤을 잠그고 넓은 표의 스크롤은 표 영역에서만 처리한다. */
useEffect(() => {
const previousHtmlOverflow = document.documentElement.style.overflow;
const previousBodyOverflow = document.body.style.overflow;
const previousBodyWidth = document.body.style.width;
document.documentElement.style.overflow = "hidden";
document.body.style.overflow = "hidden";
document.body.style.width = "100%";
return () => {
document.documentElement.style.overflow = previousHtmlOverflow;
document.body.style.overflow = previousBodyOverflow;
document.body.style.width = previousBodyWidth;
};
}, []);
/** 관로·맨홀 조사유형 또는 결함 범위가 바뀌면 이전 검색 조건과 안내를 초기화한다. */
useEffect(() => {
setDetailNumberKeyword("");
setAppliedDetailNumberKeyword("");
setSort(undefined);
setNotice(null);
}, [defectType, jobType]);
/** 화면 조회 버튼으로 입력 번호를 확정해 샘플 상세 행을 필터링한다. */
const handleSearch = () => {
setAppliedDetailNumberKeyword(detailNumberKeyword.trim());
setNotice(null);
};
/** 공통 UiTable 규칙대로 asc, desc, 기본 순서로 고정 열 정렬을 순환한다. */
const handleSortChange = (columnId: ExmnDetailFixedColumnId) => {
if (sort?.key !== columnId) {
setSort({ key: columnId, direction: "asc" });
return;
}
if (sort.direction === "asc") {
setSort({ key: columnId, direction: "desc" });
return;
}
setSort(undefined);
};
/** 규격 열 경계가 사라지는 가로 스크롤 지점부터 고정 열 구분선을 활성화한다. */
const handleTableScroll = (event: UIEvent<HTMLDivElement>) => {
setTableScrollLeft(event.currentTarget.scrollLeft);
};
const showStickyDivider =
tableScrollLeft >= FIXED_INFO_SCROLL_THRESHOLD;
const summaryLabelOffset = Math.max(
0,
SUMMARY_LABEL_INITIAL_OFFSET - tableScrollLeft,
);
/** 실제 엑셀 다운로드 API 연동 전 사용자에게 후속 연결 상태를 안내한다. */
const handleDownload = () => {
setNotice({
type: "info",
text: "엑셀 다운로드는 실제 상세 API 연동 단계에서 연결할 예정입니다.",
});
};
const totals = useMemo(
() => rows.reduce(
(sum, row) => ({
extensionM: sum.extensionM + row.extensionM,
drivingDistanceM: sum.drivingDistanceM + row.drivingDistanceM,
}),
{
extensionM: 0,
drivingDistanceM: 0,
},
),
[rows],
);
/** 고정 열 중 합산 의미가 있는 연장·주행거리 2개 열의 요약 표시값이다. */
const fixedSummaryValues = [
{ key: "extensionM", value: formatNumber(totals.extensionM) },
{ key: "drivingDistanceM", value: formatNumber(totals.drivingDistanceM) },
];
/** 현재 보이는 구조·운영 결함 열만 기준으로 대·중·소 소계와 항목별 합계를 계산한다. */
const defectSummary = useMemo(() => {
const anomalySubtotals = { L: 0, M: 0, S: 0 };
const defectSubtotals = Object.fromEntries(
visibleDefectColumns.map((column) => [
column.code,
{ L: 0, M: 0, S: 0 },
]),
) as Record<string, Record<ExmnDetailSeverity, number>>;
rows.forEach((row) => {
visibleDefectColumns.forEach((column) => {
const columnCounts = defectSubtotals[column.code];
if (!columnCounts) return;
EXMN_DETAIL_SEVERITIES.forEach((severity) => {
const count = row.defectCounts[column.code]?.[severity] ?? 0;
columnCounts[severity] += count;
anomalySubtotals[severity] += count;
});
});
});
const sumSeverities = (counts: Record<ExmnDetailSeverity, number>) =>
EXMN_DETAIL_SEVERITIES.reduce(
(sum, severity) => sum + counts[severity],
0,
);
return {
anomalySubtotals,
anomalyTotal: sumSeverities(anomalySubtotals),
defectSubtotals,
defectTotals: Object.fromEntries(
visibleDefectColumns.map((column) => [
column.code,
sumSeverities(
defectSubtotals[column.code] ?? { L: 0, M: 0, S: 0 },
),
]),
) as Record<string, number>,
};
}, [rows, visibleDefectColumns]);
const tableWidth = useMemo(
() => FIXED_COLUMNS.reduce((sum, column) => sum + column.width, 0)
+ (visibleDefectColumns.length + 1)
* EXMN_DETAIL_SEVERITIES.length
* SEVERITY_COLUMN_WIDTH,
[visibleDefectColumns.length],
);
const popupTitle = defectType === "ALL"
? definition.title
: `${definition.defectGroupTitle}조사 ${defectTypeLabel} 상세현황`;
const tableTitle = defectType === "ALL"
? `${trtdstNm} ${definition.defectGroupTitle}조사 집계표`
: `${trtdstNm} ${definition.defectGroupTitle}조사 ${defectTypeLabel} 집계표`;
const visibleDefectGroupTitle = defectType === "ALL"
? definition.defectGroupTitle
: `${definition.defectGroupTitle} ${defectTypeLabel}`;
return (
<main className="sn-popup-page">
<header className="sn-popup-header">
<h1 className="sn-popup-title">{popupTitle}</h1>
</header>
{notice && (
<div role="status" className="sn-popup-success-bar shrink-0 text-primary">
{notice.text}
</div>
)}
<section className="sn-popup-section overflow-y-hidden">
<div
className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:items-end"
style={{ gridTemplateColumns: "180px 220px 300px minmax(0, 1fr)" }}
>
<label className="sn-popup-field">
<div className="sn-popup-field-label">처리분구</div>
<select
className="sn-popup-input !text-[1.1rem]"
defaultValue={trtdstId}
aria-label="처리분구"
>
<option value={trtdstId}>{trtdstNm}</option>
</select>
</label>
<label className="sn-popup-field">
<div className="sn-popup-field-label">조사대상</div>
<select
className="sn-popup-input !text-[1.1rem]"
defaultValue={jobType}
aria-label="조사대상"
>
<option value={jobType}>
{DETAIL_SCOPE_LABELS[jobType]}
{defectType === "ALL" ? "" : ` / ${defectTypeLabel}`}
</option>
</select>
</label>
<label className="sn-popup-field">
<div className="sn-popup-field-label">
{jobType === "PPL" ? "관로번호" : "맨홀번호"}
</div>
<Input
className="sn-popup-input !text-[1.1rem]"
value={detailNumberKeyword}
placeholder={`${jobType === "PPL" ? "관로" : "맨홀"}번호 입력`}
onChange={(event) => setDetailNumberKeyword(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") handleSearch();
}}
/>
</label>
<div className="sn-popup-actions">
<Button
type="button"
size="sm"
color="primary"
className="sn-popup-primary-button !text-[1.1rem]"
onClick={handleSearch}
>
조회
</Button>
<Button
type="button"
size="sm"
color="green"
className="sn-popup-primary-button !text-[1.1rem]"
onClick={handleDownload}
>
다운로드
</Button>
</div>
</div>
{!sampleMode && (
<div className="shrink-0 border-b border-border bg-secondary px-4 py-2 text-[1.05rem] text-primary">
실제 상세 데이터 API는 후속 백엔드 연동 단계에서 연결합니다. 화면 샘플은 `sample=Y`에서 확인할 수 있습니다.
</div>
)}
<div className="flex shrink-0 items-center justify-between gap-4 py-3">
<div className="flex min-w-0 items-baseline gap-3">
<h2 className="shrink-0 text-[1.25rem] font-semibold text-title">
{tableTitle}
</h2>
<span className="truncate text-[1.05rem] text-label">
조회 결과 총 {rows.length.toLocaleString("ko-KR")}건
</span>
</div>
<div className="flex shrink-0 items-center gap-7 whitespace-nowrap text-[1.05rem] text-label">
<span><strong className="mr-2 text-foreground">연장거리 합계</strong>{formatNumber(totals.extensionM)}m</span>
<span><strong className="mr-2 text-foreground">주행거리 합계</strong>{formatNumber(totals.drivingDistanceM)}m</span>
</div>
</div>
<div
className="min-h-0 flex-1 overflow-auto border border-border bg-background"
onScroll={handleTableScroll}
>
<table
className="border-separate border-spacing-0 text-center text-[1.05rem] text-foreground"
style={{ width: `${tableWidth}px`, minWidth: `${tableWidth}px` }}
>
<colgroup>
{FIXED_COLUMNS.map((column) => (
<col key={column.id} style={{ width: `${column.width}px` }} />
))}
{Array.from({
length: (visibleDefectColumns.length + 1)
* EXMN_DETAIL_SEVERITIES.length,
}).map((_, index) => (
<col key={`severity-col-${index}`} style={{ width: `${SEVERITY_COLUMN_WIDTH}px` }} />
))}
</colgroup>
<thead className="sticky top-0 z-30 bg-[#eaf3fb] text-[1.3rem] font-medium leading-[1.2] text-[#647784]">
<tr>
{FIXED_COLUMNS.map((column, columnIndex) => {
const sticky = columnIndex < STICKY_COLUMN_COUNT;
const direction = sort?.key === column.id ? sort.direction : undefined;
const columnLabel =
column.id === "pipeNo" && jobType === "MNHL"
? "맨홀\n번호"
: column.label;
return (
<th
key={column.id}
rowSpan={4}
className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2"
style={sticky ? {
position: "sticky",
left: columnIndex === 0 ? REPORT_COLUMN_LEFT : PIPE_COLUMN_LEFT,
zIndex: 60,
boxShadow:
columnIndex === STICKY_COLUMN_COUNT - 1 && showStickyDivider
? STICKY_DIVIDER_SHADOW
: undefined,
} : undefined}
aria-sort={
direction === "asc"
? "ascending"
: direction === "desc"
? "descending"
: "none"
}
>
<button
type="button"
className="inline-flex h-full w-full items-center justify-center gap-1.5"
aria-label={`${columnLabel.replace("\n", " ")} 정렬`}
onClick={() => handleSortChange(column.id)}
>
<span className="whitespace-pre-line">{columnLabel}</span>
{direction === "asc" ? (
<span
className="inline-flex min-w-2 justify-center text-[8px] leading-none text-[#1d4ed8]"
aria-hidden="true"
>
▲
</span>
) : direction === "desc" ? (
<span
className="inline-flex min-w-2 justify-center text-[8px] leading-none text-[#1d4ed8]"
aria-hidden="true"
>
▼
</span>
) : (
<span
className="inline-flex flex-col items-center gap-px text-[6px] leading-none text-[#475569]"
aria-hidden="true"
>
<span>▲</span>
<span>▼</span>
</span>
)}
</button>
</th>
);
})}
<th
colSpan={(visibleDefectColumns.length + 1) * EXMN_DETAIL_SEVERITIES.length}
className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2"
>
이상항목
</th>
</tr>
<tr>
<th
colSpan={EXMN_DETAIL_SEVERITIES.length}
className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2"
>
합계
</th>
<th
colSpan={visibleDefectColumns.length * EXMN_DETAIL_SEVERITIES.length}
className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2"
>
{visibleDefectGroupTitle}
</th>
</tr>
<tr>
{EXMN_DETAIL_SEVERITIES.map((severity) => (
<th
key={`total-${severity}`}
rowSpan={2}
className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2"
>
{SEVERITY_LABELS[severity]}
</th>
))}
{visibleDefectColumns.map((column) => (
<th
key={column.code}
colSpan={EXMN_DETAIL_SEVERITIES.length}
title={`${column.label} (${column.code})`}
className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2 whitespace-nowrap"
>
{column.label} ({column.code})
</th>
))}
</tr>
<tr>
{visibleDefectColumns.flatMap((column) =>
EXMN_DETAIL_SEVERITIES.map((severity) => (
<th
key={`${column.code}-${severity}`}
className="border-b border-r border-border bg-[#eaf3fb] px-2 py-1.5 font-medium"
>
{SEVERITY_LABELS[severity]}
</th>
)),
)}
</tr>
</thead>
<tbody>
{rows.length > 0 ? (
<>
{rows.map((row) => {
const visibleAnomalyTotals = getVisibleAnomalyTotals(
row,
visibleDefectColumns,
);
return (
<tr key={row.reportNo} className="sn-popup-grid-row sn-popup-grid-row-hover">
{FIXED_COLUMNS.map((column, columnIndex) => {
const sticky = columnIndex < STICKY_COLUMN_COUNT;
return (
<td
key={`${row.reportNo}-${column.id}`}
className="border-b border-r border-border bg-background px-2 py-2.5 whitespace-nowrap"
style={sticky ? {
position: "sticky",
left: columnIndex === 0 ? REPORT_COLUMN_LEFT : PIPE_COLUMN_LEFT,
zIndex: 10,
boxShadow:
columnIndex === STICKY_COLUMN_COUNT - 1
&& showStickyDivider
? STICKY_DIVIDER_SHADOW
: undefined,
} : undefined}
>
{getFixedCellValue(row, column.id)}
</td>
);
})}
{EXMN_DETAIL_SEVERITIES.map((severity) => (
<td
key={`${row.reportNo}-total-${severity}`}
className="border-b border-r border-border bg-background px-2 py-2.5"
>
{formatCount(visibleAnomalyTotals[severity])}
</td>
))}
{visibleDefectColumns.flatMap((column) =>
EXMN_DETAIL_SEVERITIES.map((severity) => (
<td
key={`${row.reportNo}-${column.code}-${severity}`}
className="border-b border-r border-border px-2 py-2.5"
>
{formatCount(row.defectCounts[column.code]?.[severity])}
</td>
)),
)}
</tr>
);
})}
<tr className="bg-background font-medium">
<td
colSpan={STICKY_COLUMN_COUNT}
className="sticky left-0 z-20 border-b border-border bg-background px-2 py-2.5 text-center align-middle"
style={{
boxShadow: showStickyDivider
? STICKY_DIVIDER_SHADOW
: undefined,
}}
>
<span
className="inline-block whitespace-nowrap"
style={{
transform: `translateX(${summaryLabelOffset}px)`,
}}
>
소계
</span>
</td>
<td
aria-hidden="true"
colSpan={SUMMARY_LABEL_COLUMN_COUNT - STICKY_COLUMN_COUNT}
className="border-b border-r border-border bg-background px-2 py-2.5"
/>
{fixedSummaryValues.map((summary) => (
<td
key={`subtotal-fixed-${summary.key}`}
className="border-b border-r border-border bg-background px-2 py-2.5 text-center"
>
{summary.value}
</td>
))}
{EXMN_DETAIL_SEVERITIES.map((severity) => (
<td
key={`subtotal-anomaly-${severity}`}
className="border-b border-r border-border px-2 py-2.5"
>
{formatCount(defectSummary.anomalySubtotals[severity])}
</td>
))}
{visibleDefectColumns.flatMap((column) =>
EXMN_DETAIL_SEVERITIES.map((severity) => (
<td
key={`subtotal-${column.code}-${severity}`}
className="border-b border-r border-border px-2 py-2.5"
>
{formatCount(
defectSummary.defectSubtotals[column.code]?.[severity],
)}
</td>
)),
)}
</tr>
<tr className="bg-[#dcdcdc] font-medium">
<td
colSpan={STICKY_COLUMN_COUNT}
className="sticky left-0 z-20 border-b border-border bg-[#dcdcdc] px-2 py-2.5 text-center align-middle"
style={{
boxShadow: showStickyDivider
? STICKY_DIVIDER_SHADOW
: undefined,
}}
>
<span
className="inline-block whitespace-nowrap"
style={{
transform: `translateX(${summaryLabelOffset}px)`,
}}
>
합계
</span>
</td>
<td
aria-hidden="true"
colSpan={SUMMARY_LABEL_COLUMN_COUNT - STICKY_COLUMN_COUNT}
className="border-b border-r border-border bg-[#dcdcdc] px-2 py-2.5"
/>
{fixedSummaryValues.map((summary) => (
<td
key={`total-fixed-${summary.key}`}
className="border-b border-r border-border bg-[#dcdcdc] px-2 py-2.5 text-center"
>
{summary.value}
</td>
))}
<td
colSpan={EXMN_DETAIL_SEVERITIES.length}
className="border-b border-r border-border px-2 py-2.5 text-center"
>
{formatCount(defectSummary.anomalyTotal)}
</td>
{visibleDefectColumns.map((column) => (
<td
key={`total-${column.code}`}
colSpan={EXMN_DETAIL_SEVERITIES.length}
className="border-b border-r border-border px-2 py-2.5 text-center"
>
{formatCount(defectSummary.defectTotals[column.code])}
</td>
))}
</tr>
</>
) : (
<tr>
<td
colSpan={FIXED_COLUMNS.length
+ (visibleDefectColumns.length + 1)
* EXMN_DETAIL_SEVERITIES.length}
className="sn-popup-empty-cell h-28 border-b border-r border-border text-center"
>
조회된 상세 결과가 없습니다.
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
</main>
);
}
@@ -0,0 +1,141 @@
"use client";
import { Button } from "@common/components/ui";
import { UiModal, UiTable } from "@common/business_components/ui";
import { useEffect, useMemo, useState } from "react";
import type { PplFacility } from "../model/exmn-business-types";
const UNIT_TABLE_HEAD = [
{ id: "unitSeq", title: "단위", widthPercent: 8 },
{ id: "startPositionM", title: "시작위치(m)", widthPercent: 14 },
{ id: "unitLengthM", title: "단위연장(m)", widthPercent: 14 },
{ id: "structuralScore", title: "구조점수", widthPercent: 13 },
{ id: "structuralGrade", title: "구조등급", widthPercent: 13 },
{ id: "operationalScore", title: "운영점수", widthPercent: 13 },
{ id: "operationalGrade", title: "운영등급", widthPercent: 13 },
{ id: "detailCount", title: "기록수", widthPercent: 12 },
];
const DETAIL_TABLE_HEAD = [
{ id: "detailSeq", title: "순번", widthPercent: 6 },
{ id: "positionM", title: "위치(m)", widthPercent: 9 },
{ id: "code", title: "코드", widthPercent: 8 },
{ id: "recordType", title: "기록유형", widthPercent: 10 },
{ id: "typeText", title: "결함구분", widthPercent: 10 },
{ id: "severityText", title: "심각도", widthPercent: 8 },
{ id: "occurrencePositionText", title: "발생위치", widthPercent: 10 },
{ id: "continuousText", title: "연속결함", widthPercent: 9 },
{ id: "structuralScoreText", title: "구조점수", widthPercent: 10 },
{ id: "operationalScoreText", title: "운영점수", widthPercent: 10 },
{ id: "statusGroupText", title: "상태그룹", widthPercent: 10 },
];
interface PplFacilityDetailModalProps {
open: boolean;
facility: PplFacility | null;
onOpenChange: (open: boolean) => void;
}
/** PPL_UNIT_V1 정규화 응답을 단위 구간과 상태·결함 기록으로 표시한다. */
export function PplFacilityDetailModal({
open,
facility,
onOpenChange,
}: PplFacilityDetailModalProps) {
// 선택 단위는 원문 배열 인덱스가 아니라 unitSeq로 보관해 서버 정렬과 무관하게 유지한다.
const [selectedUnitSeq, setSelectedUnitSeq] = useState<number | null>(null);
/** 시설이 바뀌면 첫 단위 구간을 기본 선택한다. */
useEffect(() => {
if (!open) return;
setSelectedUnitSeq(facility?.unitSections?.[0]?.unitSeq ?? null);
}, [facility, open]);
const unitRows = useMemo(() => (facility?.unitSections ?? []).map((unit) => ({
...unit,
detailCount: unit.details?.length ?? 0,
})), [facility]);
const selectedUnit = useMemo(
() => facility?.unitSections?.find((unit) => unit.unitSeq === selectedUnitSeq) ?? null,
[facility, selectedUnitSeq],
);
const detailRows = useMemo(() => (selectedUnit?.details ?? []).map((detail) => ({
...detail,
typeText: detail.type ?? "-",
severityText: detail.severity ?? "-",
occurrencePositionText: detail.occurrencePosition ?? "-",
continuousText: detail.continuous == null ? "-" : detail.continuous ? "Y" : "N",
structuralScoreText: detail.structuralScore ?? "-",
operationalScoreText: detail.operationalScore ?? "-",
statusGroupText: detail.statusGroup ?? "-",
})), [selectedUnit]);
return (
<UiModal
open={open}
onOpenChange={onOpenChange}
bodyClass="max-h-[94vh] max-w-[96vw] overflow-y-auto"
headerTitle="PPL 조사시설 상세"
headerTitleClass="text-xl font-semibold text-gray-800"
footerContent={<div className="flex w-full justify-end"><Button variant="outline" onClick={() => onOpenChange(false)}>닫기</Button></div>}
body={(
<div className="space-y-5 py-2">
<section className="grid grid-cols-4 gap-x-5 gap-y-3 rounded border bg-gray-50 p-4 text-sm">
<div><strong>보고서번호</strong><p className="mt-1 text-gray-600">{facility?.rptpNo || "-"}</p></div>
<div><strong>GIS 관로관리번호</strong><p className="mt-1 text-gray-600">{facility?.exmnFcltyGisMngNo || "-"}</p></div>
<div><strong>처리분구</strong><p className="mt-1 text-gray-600">{facility?.trtdstNm || "-"}</p></div>
<div><strong>상류/하류 맨홀</strong><p className="mt-1 text-gray-600">{facility?.upstmMnhlNo || "-"} / {facility?.dnstmMnhlNo || "-"}</p></div>
<div><strong>배수방식</strong><p className="mt-1 text-gray-600">{facility?.drgptnCd || "-"}</p></div>
<div><strong>관종/규격</strong><p className="mt-1 text-gray-600">{facility?.pptyCd || "-"} / {facility?.spcfctCd || "-"}</p></div>
<div><strong>연장/주행거리</strong><p className="mt-1 text-gray-600">{facility?.prlg ?? "-"} / {facility?.drvngDstnc ?? "-"}</p></div>
<div><strong>구조/운영 등급</strong><p className="mt-1 text-gray-600">{facility?.strctDfcGrdCd || "-"} / {facility?.operDfcGrdCd || "-"}</p></div>
</section>
<section>
<h4 className="mb-2 font-semibold text-gray-700">단위 구간</h4>
<UiTable
headName=""
headlist={UNIT_TABLE_HEAD}
tableData={unitRows}
total={unitRows.length}
pageSize={Math.max(unitRows.length, 1)}
curPage={1}
widthFixed
index={unitRows.findIndex((unit) => unit.unitSeq === selectedUnitSeq)}
cellClick={(index) => setSelectedUnitSeq(index == null ? null : unitRows[index]?.unitSeq ?? null)}
onPageChange={() => undefined}
/>
</section>
<section className="rounded border bg-gray-50 p-3 text-sm text-gray-600">
<strong>조사중단사유:</strong> {selectedUnit?.surveyStopReason || "없음"}
<span className="mx-4 text-gray-300">|</span>
<strong>비고:</strong> {selectedUnit?.remark || "없음"}
</section>
<section>
<h4 className="mb-2 font-semibold text-gray-700">상태·결함 상세 기록</h4>
<UiTable
headName=""
headlist={DETAIL_TABLE_HEAD}
tableData={detailRows}
total={detailRows.length}
pageSize={Math.max(detailRows.length, 1)}
curPage={1}
widthFixed
cellClick={undefined}
onPageChange={() => undefined}
/>
</section>
<section className="flex items-center justify-between rounded border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
<span>시설 보고서 파일 매핑 원천이 확정되기 전까지 보고서 조회는 제공하지 않습니다.</span>
<Button disabled title="보고서 매핑 모델 확정 필요">보고서 보기</Button>
</section>
</div>
)}
/>
);
}
@@ -0,0 +1,373 @@
"use client";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { Button, Input } from "@common/components/ui";
import { UiModal, UiTable } from "@common/business_components/ui";
import type { UiTableSort } from "@common/business_components/ui";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
downloadPplExport,
getExmnBusiness,
getPplFacilities,
getPplSummaries,
} from "../api/exmn-business-api";
import type {
ExmnBusiness,
ExmnBusinessDetail,
PplFacility,
PplSummaryRow,
} from "../model/exmn-business-types";
import { PplFacilityDetailModal } from "./PplFacilityDetailModal";
const 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";
const SUMMARY_TABLE_HEAD = [
{ id: "trtdstNm", title: "처리분구", widthPercent: 20, sortable: true },
{ id: "trgtPrlgText", title: "대상연장(m)", widthPercent: 13, sortable: true, sortKey: "trgtPrlg" },
{ id: "drvngDstncText", title: "주행거리(m)", widthPercent: 13, sortable: true, sortKey: "drvngDstnc" },
{ id: "pplExmnWholAnmlyArtclCnt", title: "PPL 전체이상", widthPercent: 14, sortable: true },
{ id: "pplExmnStrctDfcCnt", title: "구조결함", widthPercent: 13, sortable: true },
{ id: "pplExmnOperDfcCnt", title: "운영결함", widthPercent: 13, sortable: true },
{ id: "pplExmnEtcCnt", title: "기타", widthPercent: 14, sortable: true },
];
const FACILITY_TABLE_HEAD = [
{ id: "trtdstNm", title: "처리분구", widthPercent: 13, sortable: true },
{ id: "rptpNo", title: "보고서번호", widthPercent: 12, sortable: true },
{ id: "exmnFcltyGisMngNo", title: "GIS 관로관리번호", widthPercent: 18, sortable: true },
{ id: "upstreamDownstream", title: "상류/하류 맨홀", widthPercent: 17 },
{ id: "pipeSpec", title: "관종/규격", widthPercent: 13 },
{ id: "prlgText", title: "연장(m)", widthPercent: 9, sortable: true, sortKey: "prlg" },
{ id: "strctDfcGrdCdText", title: "구조등급", widthPercent: 9, sortable: true, sortKey: "strctDfcGrdCd" },
{ id: "operDfcGrdCdText", title: "운영등급", widthPercent: 9, sortable: true, sortKey: "operDfcGrdCd" },
];
const SUMMARY_CLICKABLE_HEAD_IDS = [
"pplExmnWholAnmlyArtclCnt",
"pplExmnStrctDfcCnt",
"pplExmnOperDfcCnt",
"pplExmnEtcCnt",
];
type Notice = { type: "success" | "error"; text: string } | null;
type PplDefectType = "ALL" | "STRUCTURAL" | "OPERATIONAL" | "ETC";
interface PplSummaryModalProps {
open: boolean;
business: ExmnBusiness | null;
onOpenChange: (open: boolean) => void;
}
/** 숫자 값을 목록에서 단위 없이 천단위 구분 형식으로 표시한다. */
const formatNumber = (value: number | null | undefined) =>
Number(value ?? 0).toLocaleString("ko-KR", { maximumFractionDigits: 3 });
/** Blob을 PPL 집계 엑셀 다운로드로 전달한다. */
const saveBlob = (blob: Blob, fileName: string) => {
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = fileName;
anchor.click();
URL.revokeObjectURL(objectUrl);
};
/** 처리분구별 PPL 집계에서 결함 수를 따라 시설 목록과 단위 상세까지 탐색하는 모달이다. */
export function PplSummaryModal({ open, business, onOpenChange }: PplSummaryModalProps) {
const [businessDetail, setBusinessDetail] = useState<ExmnBusinessDetail | null>(null);
const [summaryRows, setSummaryRows] = useState<PplSummaryRow[]>([]);
const [summaryTotal, setSummaryTotal] = useState(0);
const [summaryPage, setSummaryPage] = useState(0);
const [summarySort, setSummarySort] = useState<UiTableSort>();
const [facilityRows, setFacilityRows] = useState<PplFacility[]>([]);
const [facilityTotal, setFacilityTotal] = useState(0);
const [facilityPage, setFacilityPage] = useState(0);
const [facilitySort, setFacilitySort] = useState<UiTableSort>();
// 집계 건수 클릭과 상세 조회조건이 공유하는 처리분구·결함유형 컨텍스트다.
const [trtdstId, setTrtdstId] = useState("");
const [sectionType, setSectionType] = useState("");
const [gisManagementNo, setGisManagementNo] = useState("");
const [structuralGrade, setStructuralGrade] = useState("");
const [operationalGrade, setOperationalGrade] = useState("");
const [defectType, setDefectType] = useState<PplDefectType>("ALL");
const [selectedFacility, setSelectedFacility] = useState<PplFacility | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [notice, setNotice] = useState<Notice>(null);
const displayedSummaryRows = useMemo(() => summaryRows.map((row) => ({
...row,
trgtPrlgText: formatNumber(row.trgtPrlg),
drvngDstncText: formatNumber(row.drvngDstnc),
})), [summaryRows]);
const displayedFacilityRows = useMemo(() => facilityRows.map((row) => ({
...row,
upstreamDownstream: `${row.upstmMnhlNo || "-"} / ${row.dnstmMnhlNo || "-"}`,
pipeSpec: `${row.pptyCd || "-"} / ${row.spcfctCd || "-"}`,
prlgText: row.prlg == null ? "-" : formatNumber(row.prlg),
strctDfcGrdCdText: row.strctDfcGrdCd || "-",
operDfcGrdCdText: row.operDfcGrdCd || "-",
})), [facilityRows]);
/** 현재 상세 필터로 PPL 시설 페이지를 요청한다. */
const fetchFacilities = useCallback(async (
nextPage = facilityPage,
nextSort = facilitySort,
) => {
if (!business?.exmnBizId) return;
setIsLoading(true);
setNotice(null);
try {
const page = await getPplFacilities({
exmnBizId: business.exmnBizId,
trtdstId: trtdstId || undefined,
sectionType: sectionType || undefined,
gisManagementNo: gisManagementNo || undefined,
structuralGrade: structuralGrade || undefined,
operationalGrade: operationalGrade || undefined,
defectType,
page: nextPage,
size: 10,
sort: nextSort ? `${nextSort.key},${nextSort.direction}` : undefined,
});
setFacilityRows(page.content);
setFacilityTotal(page.totalElements);
setFacilityPage(page.number);
} catch (error) {
setFacilityRows([]);
setFacilityTotal(0);
setNotice({
type: "error",
text: error instanceof Error ? error.message : "PPL 시설 조회 중 오류가 발생했습니다.",
});
} finally {
setIsLoading(false);
}
}, [business, defectType, facilityPage, facilitySort, gisManagementNo, operationalGrade, sectionType, structuralGrade, trtdstId]);
/** 모달 진입과 집계 정렬·페이지 변경 시 사업 상세와 PPL 집계를 읽는다. */
useEffect(() => {
if (!open || !business?.exmnBizId) return;
let active = true;
setIsLoading(true);
setNotice(null);
Promise.all([
getExmnBusiness(business.exmnBizId),
getPplSummaries(business.exmnBizId, {
trtdstId: trtdstId || undefined,
page: summaryPage,
size: 10,
sort: summarySort ? `${summarySort.key},${summarySort.direction}` : undefined,
}),
])
.then(([detail, page]) => {
if (!active) return;
setBusinessDetail(detail);
setSummaryRows(page.content);
setSummaryTotal(page.totalElements);
})
.catch((error: unknown) => {
if (!active) return;
setSummaryRows([]);
setSummaryTotal(0);
setNotice({
type: "error",
text: error instanceof Error ? error.message : "PPL 집계 조회 중 오류가 발생했습니다.",
});
})
.finally(() => {
if (active) setIsLoading(false);
});
return () => {
active = false;
};
}, [business, open, summaryPage, summarySort, trtdstId]);
/** 새 사업을 열 때 상세 필터·페이지·선택 시설을 초기화한다. */
useEffect(() => {
if (!open) return;
setSummaryPage(0);
setSummarySort(undefined);
setFacilityRows([]);
setFacilityTotal(0);
setFacilityPage(0);
setFacilitySort(undefined);
setTrtdstId("");
setSectionType("");
setGisManagementNo("");
setStructuralGrade("");
setOperationalGrade("");
setDefectType("ALL");
setSelectedFacility(null);
}, [business, open]);
/** 집계의 전체·구조·운영·기타 건수를 클릭하면 해당 처리분구 시설 조회로 연결한다. */
const handleSummaryCellClick = (index: number | undefined, headId: string) => {
if (index == null) return;
const row = summaryRows[index];
if (!row) return;
const nextDefectType: PplDefectType = headId === "pplExmnStrctDfcCnt"
? "STRUCTURAL"
: headId === "pplExmnOperDfcCnt"
? "OPERATIONAL"
: headId === "pplExmnEtcCnt"
? "ETC"
: "ALL";
setTrtdstId(row.trtdstId);
setDefectType(nextDefectType);
setFacilityPage(0);
setNotice(null);
// React 상태 반영 전 값 대신 클릭 컨텍스트로 즉시 요청한다.
setIsLoading(true);
getPplFacilities({
exmnBizId: business?.exmnBizId ?? "",
trtdstId: row.trtdstId,
sectionType: sectionType || undefined,
gisManagementNo: gisManagementNo || undefined,
structuralGrade: structuralGrade || undefined,
operationalGrade: operationalGrade || undefined,
defectType: nextDefectType,
page: 0,
size: 10,
})
.then((page) => {
setFacilityRows(page.content);
setFacilityTotal(page.totalElements);
setFacilityPage(page.number);
})
.catch((error: unknown) => {
setFacilityRows([]);
setFacilityTotal(0);
setNotice({ type: "error", text: error instanceof Error ? error.message : "PPL 시설 조회 중 오류가 발생했습니다." });
})
.finally(() => setIsLoading(false));
};
/** 현재 처리분구 조건의 PPL 집계 엑셀을 다운로드한다. */
const handleExport = async () => {
if (!business) return;
setIsLoading(true);
setNotice(null);
try {
saveBlob(await downloadPplExport(business.exmnBizId, trtdstId || undefined), `${business.bizNm}_PPL집계.xlsx`);
} catch (error) {
setNotice({ type: "error", text: error instanceof Error ? error.message : "PPL 엑셀 다운로드 중 오류가 발생했습니다." });
} finally {
setIsLoading(false);
}
};
return (
<>
<UiModal
open={open}
onOpenChange={onOpenChange}
bodyClass="max-h-[94vh] max-w-[96vw] overflow-y-auto"
headerTitle="PPL 조사 집계·상세"
headerTitleClass="text-xl font-semibold text-gray-800"
footerContent={<div className="flex w-full justify-end"><Button variant="outline" onClick={() => onOpenChange(false)}>닫기</Button></div>}
body={(
<div className="relative space-y-5 py-2">
<LoadingOverlay isLoading={isLoading} />
{notice && (
<div role="alert" className={`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>
)}
<div className="rounded border bg-gray-50 px-4 py-3 text-sm text-gray-600">
<strong>{business?.bizNm ?? "선택 사업"}</strong>
<span className="mx-3 text-gray-300">|</span>{business?.bizYr}년 {business?.bizCycl ?? "-"}차
<span className="mx-3 text-gray-300">|</span>{business?.ctpvNm} {business?.sggNm}
</div>
<section>
<div className="mb-2 flex items-center justify-between">
<h4 className="font-semibold text-gray-700">처리분구별 PPL 집계</h4>
<Button className={ACTION_BUTTON_CLASS} disabled={isLoading} onClick={() => void handleExport()}>엑셀 다운로드</Button>
</div>
<UiTable
headName=""
headlist={SUMMARY_TABLE_HEAD}
tableData={displayedSummaryRows}
total={summaryTotal}
pageSize={10}
curPage={summaryPage + 1}
widthFixed
clickableHeadIds={SUMMARY_CLICKABLE_HEAD_IDS}
cellClassName={(_, headId) => SUMMARY_CLICKABLE_HEAD_IDS.includes(headId) ? "text-blue-600 underline" : ""}
cellClick={handleSummaryCellClick}
onPageChange={(page) => setSummaryPage(page - 1)}
sort={summarySort}
onSortChange={(sort) => {
setSummarySort(sort);
setSummaryPage(0);
}}
/>
</section>
<section className="rounded border border-gray-200 p-4">
<div className="grid grid-cols-6 gap-3">
<label className="text-sm font-medium text-gray-600">처리분구
<select className="mt-1 h-10 w-full rounded border bg-white px-2" value={trtdstId} onChange={(event) => setTrtdstId(event.target.value)}>
<option value="">전체</option>
{(businessDetail?.districts ?? []).map((district) => <option key={district.trtdstId} value={district.trtdstId}>{district.trtdstNm}</option>)}
</select>
</label>
<label className="text-sm font-medium text-gray-600">구간 구분
<select className="mt-1 h-10 w-full rounded border bg-white px-2" value={sectionType} onChange={(event) => setSectionType(event.target.value)}>
<option value="">전체</option><option value="PIPE">관로</option><option value="UNIT">단위구간</option>
</select>
</label>
<label className="col-span-2 text-sm font-medium text-gray-600">GIS 관로관리번호
<Input className="mt-1 h-10 text-base" value={gisManagementNo} onChange={(event) => setGisManagementNo(event.target.value)} />
</label>
<label className="text-sm font-medium text-gray-600">구조등급
<select className="mt-1 h-10 w-full rounded border bg-white px-2" value={structuralGrade} onChange={(event) => setStructuralGrade(event.target.value)}>
<option value="">전체</option>{[1, 2, 3, 4, 5].map((grade) => <option key={grade} value={grade}>{grade}등급</option>)}
</select>
</label>
<label className="text-sm font-medium text-gray-600">운영등급
<select className="mt-1 h-10 w-full rounded border bg-white px-2" value={operationalGrade} onChange={(event) => setOperationalGrade(event.target.value)}>
<option value="">전체</option>{[1, 2, 3, 4, 5].map((grade) => <option key={grade} value={grade}>{grade}등급</option>)}
</select>
</label>
</div>
<div className="mt-3 flex items-center justify-between">
<span className="text-sm text-gray-500">결함 분류: {defectType}</span>
<Button className={ACTION_BUTTON_CLASS} disabled={isLoading} onClick={() => void fetchFacilities(0)}>상세 조회</Button>
</div>
</section>
<section>
<h4 className="mb-2 font-semibold text-gray-700">PPL 시설 목록</h4>
<UiTable
headName=""
headlist={FACILITY_TABLE_HEAD}
tableData={displayedFacilityRows}
total={facilityTotal}
pageSize={10}
curPage={facilityPage + 1}
widthFixed
cellClick={(index) => setSelectedFacility(index == null ? null : facilityRows[index] ?? null)}
onPageChange={(page) => void fetchFacilities(page - 1)}
sort={facilitySort}
onSortChange={(sort) => {
setFacilitySort(sort);
setFacilityPage(0);
void fetchFacilities(0, sort);
}}
/>
<p className="mt-2 text-right text-sm text-gray-500">시설 행을 선택하면 단위 구간과 상태·결함 상세가 열립니다.</p>
</section>
</div>
)}
/>
<PplFacilityDetailModal
open={Boolean(selectedFacility)}
facility={selectedFacility}
onOpenChange={(nextOpen) => {
if (!nextOpen) setSelectedFacility(null);
}}
/>
</>
);
}
@@ -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>
)}
/>
</>
);
}
@@ -1,6 +1,26 @@
import type { WindowPopupDefinition } from "./types";
export const windowPopupDefinitions = {
exmnBusinessRegister: {
key: "exmnBusinessRegister",
title: "사업정보 등록",
width: 920,
height: 620,
},
exmnResultRegister: {
key: "exmnResultRegister",
title: "조사 결과 업로드",
width: 980,
height: 340,
requiredParams: ["exmnBizId"],
},
exmnSurveyDetail: {
key: "exmnSurveyDetail",
title: "조사 상세현황",
width: 1500,
height: 720,
requiredParams: ["exmnBizId", "trtdstId", "trtdstNm", "jobType"],
},
oldPpUpload: {
key: "oldPpUpload",
title: "노후 하수관로 정비사업 등록",
@@ -31,6 +31,8 @@ export type OpenWindowPopupParams = Record<
>;
export const WINDOW_POPUP_MESSAGE_TYPE = {
EXMN_BUSINESS_CREATED: "sn:popup:exmnBusinessCreated",
EXMN_RESULT_COMMITTED: "sn:popup:exmnResultCommitted",
OLD_PP_UPLOAD_COMPLETED: "sn:popup:oldPpUploadCompleted",
FLOOD_PRE_UPLOAD_COMPLETED: "sn:popup:floodPreUploadCompleted",
MAP_SEARCH_SELECT_FEATURE: "sn:popup:mapSearchSelectFeature",
@@ -1,6 +1,9 @@
import { windowPopupDefinitions, type WindowPopupKey } from "./model/popupDefinitions";
import type { WindowPopupRegistryItem } from "./model/types";
import AgedPipePopupContent from "./ui/AgedPipePopupContent";
import ExmnBusinessRegisterPopupContent from "./ui/ExmnBusinessRegisterPopupContent";
import ExmnResultRegisterPopupContent from "./ui/ExmnResultRegisterPopupContent";
import ExmnSurveyDetailPopupContent from "./ui/ExmnSurveyDetailPopupContent";
import FloodPreTablePopupContent from "./ui/FloodPreTablePopupContent";
import FloodPreUploadPopupContent from "./ui/FloodPreUploadPopupContent";
import InfilChartPopupContent from "./ui/InfilChartPopupContent";
@@ -34,6 +37,18 @@ const GisEditPopupContent = (_props: WindowPopupContentProps) => (
);
export const windowPopupRegistry = {
exmnBusinessRegister: {
...windowPopupDefinitions.exmnBusinessRegister,
component: ExmnBusinessRegisterPopupContent,
},
exmnResultRegister: {
...windowPopupDefinitions.exmnResultRegister,
component: ExmnResultRegisterPopupContent,
},
exmnSurveyDetail: {
...windowPopupDefinitions.exmnSurveyDetail,
component: ExmnSurveyDetailPopupContent,
},
oldPpUpload: {
...windowPopupDefinitions.oldPpUpload,
component: OldPpUploadPopupContent,
@@ -0,0 +1,24 @@
"use client";
import { ExmnBusinessRegisterPopupPage } from "@/features/exmn/ui/ExmnBusinessRegisterPopupPage";
import { useEffect, useState } from "react";
import type { WindowPopupContentProps } from "../model/types";
/** query parameter가 단일 문자열인지 안전하게 해석한다. */
const getParam = (value: string | string[] | undefined) =>
Array.isArray(value) ? value[0] ?? "" : value ?? "";
/** 조사사업 등록 WindowPopup을 실제 업무 화면에 연결한다. */
export default function ExmnBusinessRegisterPopupContent({ searchParams }: WindowPopupContentProps) {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
if (!isMounted) {
return <div className="min-h-dvh bg-[#f3f7fa]" suppressHydrationWarning />;
}
return <ExmnBusinessRegisterPopupPage sampleMode={getParam(searchParams.sample) === "Y"} />;
}
@@ -0,0 +1,46 @@
"use client";
import { ExmnResultRegisterPopupPage } from "@/features/exmn/ui/ExmnResultRegisterPopupPage";
import type { ExmnBusiness } from "@/features/exmn/model/exmn-business-types";
import { useEffect, useState } from "react";
import type { WindowPopupContentProps } from "../model/types";
/** query parameter가 단일 문자열인지 안전하게 해석한다. */
const getParam = (value: string | string[] | undefined) =>
Array.isArray(value) ? value[0] ?? "" : value ?? "";
/** 부모 목록이 전달한 선택 사업 요약을 팝업 표시 계약으로 복원한다. */
const buildBusinessSummary = (searchParams: WindowPopupContentProps["searchParams"]): ExmnBusiness => ({
exmnBizId: getParam(searchParams.exmnBizId),
bizYr: getParam(searchParams.bizYr),
bizNm: getParam(searchParams.bizNm),
bizCycl: Number(getParam(searchParams.bizCycl) || 0),
ctpvNm: getParam(searchParams.ctpvNm),
sggNm: getParam(searchParams.sggNm),
trtdstCnt: Number(getParam(searchParams.trtdstCnt) || 0),
prcisExmnPrlg: Number(getParam(searchParams.prcisExmnPrlg) || 0),
tpco: Number(getParam(searchParams.tpco) || 0),
reportYn: getParam(searchParams.reportYn) === "Y" ? "Y" : "N",
});
/** 선택 조사사업의 결과등록 WindowPopup을 실제 업로드 화면에 연결한다. */
export default function ExmnResultRegisterPopupContent({ searchParams }: WindowPopupContentProps) {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
if (!isMounted) {
return <div className="min-h-dvh bg-[#f3f7fa]" suppressHydrationWarning />;
}
const businessSummary = buildBusinessSummary(searchParams);
return (
<ExmnResultRegisterPopupPage
exmnBizId={businessSummary.exmnBizId}
sampleMode={getParam(searchParams.sample) === "Y"}
businessSummary={businessSummary}
/>
);
}
@@ -0,0 +1,67 @@
"use client";
import type { ExmnBusiness, ExmnJobType } from "@/features/exmn/model/exmn-business-types";
import type { ExmnDetailDefectType } from "@/features/exmn/model/exmn-detail-table";
import { ExmnSurveyDetailPopupPage } from "@/features/exmn/ui/ExmnSurveyDetailPopupPage";
import { useEffect, useState } from "react";
import type { WindowPopupContentProps } from "../model/types";
/** query parameter가 단일 문자열인지 안전하게 해석한다. */
const getParam = (value: string | string[] | undefined) =>
Array.isArray(value) ? value[0] ?? "" : value ?? "";
/** 상세보기 버튼이 전달한 조사유형을 PPL·MNHL 두 값으로 제한한다. */
const getJobType = (value: string | string[] | undefined): ExmnJobType =>
getParam(value) === "MNHL" ? "MNHL" : "PPL";
/** 결함 건수 링크가 전달한 구조·운영 구분을 복원하며 직접 진입은 통합 보기로 처리한다. */
const getDefectType = (
value: string | string[] | undefined,
): ExmnDetailDefectType => {
const defectType = getParam(value);
return defectType === "STRUCTURAL" || defectType === "OPERATIONAL"
? defectType
: "ALL";
};
/** 부모 조사사업 목록의 선택 행 정보를 팝업 상단 표시 계약으로 복원한다. */
const buildBusinessSummary = (
searchParams: WindowPopupContentProps["searchParams"],
): ExmnBusiness => ({
exmnBizId: getParam(searchParams.exmnBizId),
bizYr: getParam(searchParams.bizYr),
bizNm: getParam(searchParams.bizNm),
bizCycl: Number(getParam(searchParams.bizCycl) || 0),
ctpvNm: getParam(searchParams.ctpvNm),
sggNm: getParam(searchParams.sggNm),
trtdstCnt: Number(getParam(searchParams.trtdstCnt) || 0),
prcisExmnPrlg: Number(getParam(searchParams.prcisExmnPrlg) || 0),
tpco: Number(getParam(searchParams.tpco) || 0),
reportYn: getParam(searchParams.reportYn) === "Y" ? "Y" : "N",
});
/** 선택 사업과 현재 관로·맨홀 조사유형으로 엑셀형 상세보기 WindowPopup을 연다. */
export default function ExmnSurveyDetailPopupContent({
searchParams,
}: WindowPopupContentProps) {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
if (!isMounted) {
return <div className="min-h-dvh bg-[#f3f7fa]" suppressHydrationWarning />;
}
return (
<ExmnSurveyDetailPopupPage
business={buildBusinessSummary(searchParams)}
trtdstId={getParam(searchParams.trtdstId)}
trtdstNm={getParam(searchParams.trtdstNm)}
jobType={getJobType(searchParams.jobType)}
defectType={getDefectType(searchParams.defectType)}
sampleMode={getParam(searchParams.sample) === "Y"}
/>
);
}
+1
View File
@@ -6,6 +6,7 @@ export default {
darkMode: ["class", "[data-mode='dark']"],
content: [
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./src/**/*.{js,ts,jsx,tsx,mdx}",
"../../packages/common/components/src/ui/**/*.{js,ts,jsx,tsx,mdx}",
"../../packages/common/business_components/src/ui/**/**/*.{js,ts,jsx,tsx,mdx}",
],
@@ -40,6 +40,7 @@ type UiTableProps<T extends object> = {
curPage?: number;
headButton?: React.ReactNode | undefined;
widthFixed?: boolean;
noOuterMargin?: boolean; // 외부 스크롤 래퍼가 표 여백을 직접 관리할 때 사용한다.
isHideCount?: boolean; // 총 건수 default false
clickableHeadIds?: string[];
sort?: UiTableSort;
@@ -65,6 +66,7 @@ export function UiTable<T extends object = Record<string, unknown>>({
curPage = 1,
headButton = undefined,
widthFixed = false,
noOuterMargin = false,
isHideCount = false,
clickableHeadIds,
sort,
@@ -170,7 +172,7 @@ export function UiTable<T extends object = Record<string, unknown>>({
) : (
<></>
)}
<div className={`card !m-[1.2rem] ${className}`}>
<div className={`card ${noOuterMargin ? "!m-0" : "!m-[1.2rem]"} ${className}`}>
<Table variant="secondary" className={widthFixed ? "table-fixed" : ""}>
{widthFixed ? (
<colgroup>
@@ -195,7 +197,11 @@ export function UiTable<T extends object = Record<string, unknown>>({
<TableBody className="text-center">
{tableData.length > 0 ? (
tableData?.map((el: T, index) => (
<TableRow key={`row-${index}`} className={rowClassName ? rowClassName(el, index) : ""}>
<TableRow
key={`row-${index}`}
data-state={idx === index ? "selected" : undefined}
className={rowClassName ? rowClassName(el, index) : ""}
>
{headlist
.filter((item) => item.visible != false)
.map(