From 3cc0e9de141b6a07a125299461dd0b63510f5217 Mon Sep 17 00:00:00 2001 From: kiminchan Date: Thu, 30 Jul 2026 10:53:32 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A1=B0=EC=82=AC=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20=EC=A7=91=EA=B3=84=20=EC=97=91?= =?UTF-8?q?=EC=85=80=20=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../001-qa-SFR-010-004~007-조사사업-통합검증계획.md | 40 +- .../002-qa-SFR-010-004~007-조사사업-통합검증결과.md | 165 +++++++ .../001-fe-SFR-010-004~007-조사사업-작업카드.md | 49 +- .../001-fe-SFR-010-004~007-조사사업-통합구현기록.md | 116 ++++- .../features/exmn/api/exmn-business-api.ts | 127 ++++- .../features/exmn/model/exmn-detail-table.ts | 10 +- .../features/exmn/model/exmn-sample-data.ts | 462 ------------------ .../exmn/ui/ExmnBusinessRegisterPopupPage.tsx | 89 +--- .../exmn/ui/ExmnPplUnitDetailTable.tsx | 32 +- .../features/exmn/ui/ExmnRegistrationPage.tsx | 98 +--- .../exmn/ui/ExmnResultRegisterPopupPage.tsx | 42 +- .../exmn/ui/ExmnStatusDetailPanel.tsx | 73 ++- .../exmn/ui/ExmnSurveyDetailPopupPage.tsx | 370 +++++++++++--- .../ui/ExmnBusinessRegisterPopupContent.tsx | 14 - .../ui/ExmnResultRegisterPopupContent.tsx | 1 - .../ui/ExmnSurveyDetailPopupContent.tsx | 1 - 16 files changed, 843 insertions(+), 846 deletions(-) delete mode 100644 apps/sn/src/features/exmn/model/exmn-sample-data.ts diff --git a/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/001-qa-SFR-010-004~007-조사사업-통합검증계획.md b/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/001-qa-SFR-010-004~007-조사사업-통합검증계획.md index 579a11b58..fc7f6fdbc 100644 --- a/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/001-qa-SFR-010-004~007-조사사업-통합검증계획.md +++ b/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/001-qa-SFR-010-004~007-조사사업-통합검증계획.md @@ -44,7 +44,7 @@ - 자동: TypeScript, `git diff --check`, Next 빌드 - 수동: `/oper/trtdst` 진입, 헤더 정렬, 선택 행 유지, 검색조건 간격, 버튼 우측 정렬·글자색, 모달 입력, 행 상태 색, 메시지 표시 - 통합: 실제 처리분구 API와 409 참조 제약, 저장 후 사업등록 후보 즉시 갱신 -- 백엔드 미구현 동안 실 API 통합 항목은 보류로 기록한다. 사용자 승인 샘플은 `sample=Y`에만 격리하고 일반 화면 성공 데이터로 대체하지 않는다. +- 실제 API·DB·JSONB 결과를 기준으로 검증하며 샘플 화면이나 자동 성공 fallback은 사용하지 않는다. ## 6. 다음 검증 순서 @@ -91,23 +91,23 @@ | 공통 선택 | 사업년도·시도·시군구 | 연도 선택과 I/I 지역 선택 재사용, 지역 기본값 고정 없음 | | 목록 열 | 사용자 확정 열 순서·단위 | NO부터 보고서까지 9개 열과 km·개소 단위 일치 | | 버튼 | 상단 업무 버튼 | `PPL 집계·상세` 직접 버튼 미표시 | -| 샘플 격리 | 일반 URL / `?sample=Y` | 일반 URL은 API만 사용, sample URL만 샘플 사업·처리분구·검토결과 표시 | +| 데이터 원천 | 일반 URL 진입 | 사업·처리분구·검토결과를 실제 API 응답으로만 표시 | ## 9. FE-1 수동 확인 경로 1. 메뉴 연결 URL을 `/oper/exmn/register`로 등록하거나 주소로 직접 진입한다. -2. 조회조건과 빈 사업 목록 레이아웃을 확인한다. -3. 백엔드 데이터가 없으면 개발 환경의 `샘플 데이터 보기`를 눌러 `sample=Y` 샘플 사업목록을 확인한다. +2. 조회조건과 실제 사업 목록 또는 정상 빈 결과 레이아웃을 확인한다. +3. 결과가 등록된 실제 사업을 선택해 관로·맨홀 집계와 상세현황을 확인한다. 4. `사업등록` WindowPopup을 열어 행정구역·가나다순 처리분구 체크박스·필수값·양식 버튼을 확인한다. 5. 사업 행 선택 후 `결과등록` WindowPopup의 파일 5종, 찾기, 데이터 검토, 저장 활성 조건을 확인한다. 6. 상단에 `PPL 집계·상세` 직접 버튼이 없고 목록 열 순서·단위가 확정안과 일치하는지 확인한다. -7. 일반 URL의 백엔드 미구현 오류는 화면 오류 메시지로 표시되는지 확인하고 샘플 성공으로 오해하지 않는다. +7. API 오류는 화면 오류 메시지로 표시되고 빈 결과와 구분되는지 확인한다. ## 10. 조사현황·집계 1차 검증 계획 | 구분 | 시나리오 | 기대 결과 | | --- | --- | --- | -| 진입 | `/oper/exmn/register`, `?sample=Y` | 기존 통합 화면 유지, 일반 URL은 실 API, sample URL만 샘플 데이터 사용 | +| 진입 | `/oper/exmn/register` | 기존 통합 화면을 유지하고 실제 API 데이터만 사용 | | 목록 | 조건 조회·페이지·정렬·행 선택 | 등록 화면과 같은 조건 계약, 선택 행 강조 | | 상세현황 | 사업 행의 어느 데이터 셀 선택 | 하단 조사 상세현황 표시, 상단과 중복되는 선택 사업 요약 띠는 표시하지 않음 | | 선택 전 상태 | 최초 진입·재조회로 선택 해제 | 조사 상세현황 영역 자체가 표시되지 않음 | @@ -128,8 +128,8 @@ | 검색조건 | 사업차수·사업연도·시도·시군구를 차례로 변경 후 조회 | 조건 간격이 자연스럽고 기존 연도·행정구역 연동 동작 유지 | | 행 선택 | NO부터 보고서까지 각 열의 빈 공간·텍스트 주변 선택 | 어느 위치에서도 같은 행의 상세현황 표시, 선택 행 강조 | | 정렬 | 각 정렬 헤더를 3회 연속 선택 | 오름차순→내림차순→기본 순환, 공통 아이콘 상태 일치 | -| 데이터 원천 | 일반 URL과 `sample=Y`에서 같은 열 정렬 | 서버 결과와 샘플 결과가 같은 값 비교 규칙 사용 | -| 목록 높이 | 샘플 사업 4건 확인 | 3행은 하단선까지 온전히 표시, 4번째부터 목록 내부 스크롤 | +| 데이터 원천 | 실제 사업목록 정렬 | 서버 결과에 화면 정렬 규칙 적용 | +| 목록 높이 | 실제 사업 4건 이상 확인 | 3행은 하단선까지 온전히 표시, 4번째부터 목록 내부 스크롤 | | 폭·경계 | 목록과 상세 좌우 끝선 및 스크롤 상태 비교 | 같은 가로 폭·여백, 상·하단 구분선 잘림 없음 | | 조사유형 | 관로·맨홀 버튼의 가장자리와 중앙 선택 | 보이는 button 전체가 클릭되고 해당 유형 집계 재조회 | | 요청 순서 | 사업·조사유형을 연속으로 빠르게 변경 | 마지막 선택의 응답만 화면에 반영 | @@ -152,7 +152,7 @@ | 고정 열 | 표를 오른쪽으로 이동 | 보고서번호·관로/맨홀번호가 고정되고 규격 이후 경계선 유지 | | 정렬 | 고정 정보 헤더를 3회 클릭 | 보고서번호~주행거리만 오름차순→내림차순→기본 순환 | | 조회 | 관로/맨홀번호 입력 후 조회 | 번호 포함 행만 표시되고 빈 조건에서 전체 3행 복원 | -| 소계 | 샘플 3행 값 합산 | 연장·주행거리와 코드별 대·중·소 합계 일치 | +| 소계 | 실제 시설 행 값 합산 | 연장·주행거리와 코드별 대·중·소 합계 일치 | | 합계 | 이상항목 합계 행 확인 | 각 항목 대·중·소 3열 병합 후 총합 표시 | | 요약 라벨 | 가로 스크롤 전·후 비교 | 규격 전에는 병합영역 중앙, 규격 이후에도 고정열 오른쪽에 소계·합계 라벨 표시 | | 목록 정렬 | 조사사업 `NO` 헤더 3회 클릭 | 정순→역순→기본 순환, 서버에 가상 `rowNumber` 필드 미전달 | @@ -174,7 +174,7 @@ | 전체 상세 | 행 선택 후 상단 상세보기 클릭 | `ALL` 기준 구조·운영 이상항목을 함께 표시 | | 조사유형 | 관로·맨홀 탭에서 각각 링크 클릭 | 선택 탭에 맞는 관로/맨홀 상세표 표시 | | 분류 열 필터 | 전체·구조·운영 팝업 비교 | 선택 분류 코드 열만 표시 | -| 분류 행 필터 | 분류별 샘플 데이터 확인 | 선택 분류의 결함이 하나 이상인 시설 행만 표시 | +| 분류 행 필터 | 분류별 실제 데이터 확인 | 선택 분류의 결함이 하나 이상인 시설 행만 표시 | | 코드 헤더 | 이상항목 헤더 확인 | 모든 헤더가 `명칭 (코드)` 형식 | | 제거 열 | 고정 정보 열 확인 | 구조적·운영적 내부결함 열 없음 | | 집계 | 전체·구조·운영 각각 소계·합계 확인 | 현재 표시 행·열의 대·중·소 값과 일치 | @@ -182,7 +182,7 @@ | 결과등록 요약 | 사업정보 영역 확인 | 읽기 전용 텍스트가 한 줄에서 잘리지 않음 | | 회귀 | 사업등록·결과등록·상세보기 팝업 비교 | 파란 헤더와 옅은 파란 본문 배경 일관성 유지 | -- 브라우저 수동 검증은 로그인된 `SN` 세션과 `/oper/exmn/register?sample=Y` 진입이 선행조건이다. +- 브라우저 수동 검증은 로그인된 `SN` 세션과 `/oper/exmn/register` 진입이 선행조건이다. - 개발 서버가 실행 중이면 별도 프로덕션 빌드를 실행하지 않고 정적 검사와 사용자 화면 확인을 우선한다. ## 14. 관로 본단위구간과 결함별 상세 팝업 회귀 계획 @@ -202,17 +202,31 @@ | 정적 검사 | 변경 저장소에서 `git diff --check` | 공백 오류 없음 | - 실제 다운로드 파일은 Excel에서 열어 헤더, 병합 범위, 소계·합계와 한글 파일명을 대조한다. -- 실제 API 연결 후에는 샘플과 동일한 조건으로 JSONB 코드·점수·단위구간 매핑을 재검증한다. +- 실제 API 응답과 DB JSONB의 코드·점수·단위구간 매핑을 대조한다. ## 15. 사업등록 사업차수·행정구역 배치 보완 검증 계획 | 검증 항목 | 방법 | 기대 결과 | | --- | --- | --- | | 입력 순서 | 사업등록 팝업 첫째·둘째 줄 확인 | 첫 줄 5개 조건과 둘째 줄 사업명이 확정 순서로 표시 | -| 사업차수 기본값 | 일반·`sample=Y` 각각 진입 | 일반 1, 샘플 3 표시 | +| 사업차수 기본값 | 신규 사업등록 진입 | 기본값 1 표시 | | 사업차수 검증 | 빈 값·0·소수·양의 정수 저장 | 1 이상의 정수만 저장 가능 | | 행정구역 명칭 | 강원특별자치도·대구광역시·긴 시군구 선택 | 선택값이 말줄임·잘림 없이 확인 가능 | | 간격 | 첫 줄 전체 항목 비교 | 항목 묶음 사이 20px 고정 간격, 과도한 균등 분산 없음 | | 공통 회귀 | `compact` 미지정 기존 화면 확인 | 기존 시도·시군구 폭과 간격 유지 | | 팝업 크기 | 새 창 외곽·본문 확인 | `1000×620`, 세로 구성과 처리분구 영역 유지 | | 정적 검사 | 프론트 저장소 `git diff --check` | 공백 오류 없음 | + +## 16. 샘플 보기 제거 후 최종 회귀 계획 + +| 검증 항목 | 방법 | 기대 결과 | +| --- | --- | --- | +| 샘플 코드 | `sampleMode`, `sample=`, `exmn-sample-data` 검색 | 런타임 코드 참조 0건 | +| 최초 목록 | `/oper/exmn/register` 진입 | 빈 조건으로 실제 사업목록 자동 조회 | +| 등록·수정 | 신규 저장 및 선택 사업 수정 | 실제 API 저장 후 부모 목록 재조회 | +| 결과등록 | 파일 검토·최종 저장 | 실제 검토 API와 첨부·commit 호출 | +| 종합집계 | 결과 사업 행 선택·관로/맨홀 전환 | 실제 처리분구별 집계 표시 | +| 상세보기 | 전체·본단위·구조·운영 진입 | 실제 시설 컬럼과 JSONB 결과 표시 | +| 다운로드 | 관로·맨홀 다운로드 | 종합집계 1개와 처리분구 상세 N개 시트 생성 | +| 과거 URL | `/oper/exmn/register?sample=Y` 진입 | 파라미터를 무시하고 실제 API 데이터 표시 | +| 지도보기 | 버튼 클릭 | 보류 안내 유지 | diff --git a/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md b/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md index 53e91ea6e..3f5506d48 100644 --- a/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md +++ b/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md @@ -313,3 +313,168 @@ - 사업보고서 링크 클릭 시 첨부 원본 파일명으로 실제 다운로드되는지 최종 확인한다. - 맨홀 PDF 내부 맨홀번호 대조는 검토 기준 협의 후 구현·검증한다. - GIS 기준시설과 조사결과 시설번호 대조 및 지도보기는 후속 범위다. + +## 21. 2026-07-29 조사 상세현황 실제 집계 조회 + +| 검증 항목 | 결과 | 비고 | +| --- | --- | --- | +| 프론트 API URL·파라미터 계약 | 정적 확인 | `/sections?jobType=PPL\|MNHL` | +| 관로 집계 필드 매핑 | 정적 확인 | PPL 전체이상·구조·운영·기타, 연장·주행거리 | +| 맨홀 집계 필드 매핑 | 정적 확인 | MNHL 전체이상·구조·운영·기타 | +| 빈 배열 화면 | 정적 확인 | `조회된 결과가 없습니다.` | +| 빠른 사업·탭 전환 보호 | 유지 | request id 기반 이전 응답 차단 | +| 실제 서버 화면 검증 | 대기 | 백엔드 재기동 후 사용자 확인 필요 | + +### 남은 검증 + +- 결과가 저장된 사업을 선택해 관로·맨홀 탭의 처리분구별 수치와 DB 집계를 대조한다. +- 결과 미등록 사업에서 오류 배너가 표시되지 않는지 확인한다. + +## 22. 2026-07-29 조사 상세보기 전체 집계 API 정적 검증 + +| 검증 항목 | 결과 | 비고 | +| --- | --- | --- | +| 상세 API 요청 | 정적 확인 | 사업·처리분구·PPL/MNHL·관리번호 전달 | +| 실제/샘플 데이터 분리 | 통과 | `sample=Y`는 기존 샘플, 일반 URL은 서버 응답 | +| 기존 표 계약 유지 | 통과 | 고정 열·다단 결함 열·소계·합계·정렬 재사용 | +| 요청 순서 보호 | 통과 | effect cleanup 후 이전 응답 무시 | +| 후속 기능 격리 | 통과 | UNIT·결함별 화면은 아직 실 API 미호출 | +| 변경 공백 검사 | 통과 | `git diff --check` | + +### 잔여 확인 + +- 실제 서버 재기동 후 상세보기 팝업의 6개 PPL·6개 MNHL 시설과 JSONB 코드별 대·중·소 값을 확인한다. +- 본단위구간, 결함별 조회와 서버 다운로드는 다음 단계에서 검증한다. + +## 23. 2026-07-29 맨홀 전체 상세표 고정정보 정적 검증 + +| 검증 항목 | 결과 | 비고 | +| --- | --- | --- | +| 맨홀 식별 열 용어 | 통과 | `맨홀번호` 대신 `관로번호` 표시 | +| 관로 고정 열 | 정적 확인 | 보고서번호부터 주행거리까지 표시 | +| 맨홀 구조·운영 등급 | 정적 확인 | 공통 열 뒤에 시설 등급코드 두 열 표시 | +| 소계·합계 | 정적 확인 | 관로는 연장·주행거리, 맨홀은 구조·운영 등급까지 합산 | +| 조사유형별 결함 열 | 정적 확인 | 고정 열 이후 PPL/MNHL 코드 정의 분기 | +| 엑셀 다운로드 열 | 정적 확인 | 관로·맨홀 화면별 고정 열과 결함 집계 생성 | +| 변경 화면 ESLint | 통과 | `ExmnSurveyDetailPopupPage.tsx` 오류·경고 없음 | + +### 남은 검증 + +- 실제 맨홀 상세 팝업에서 덕풍 3건의 공통 기본정보·등급과 결함별 대·중·소 건수를 확인한다. +- 가로 스크롤 시 두 고정 열과 소계·합계 라벨 경계가 유지되는지 확인한다. +- 현재 브라우저 세션은 로그인 화면이므로 인증 후 실제 팝업을 재확인한다. + +## 24. 2026-07-29 상세보기 처리분구 선택 정적 검증 + +| 검증 항목 | 결과 | 비고 | +| --- | --- | --- | +| 조사유형별 처리분구 목록 | 정적 확인 | 기존 `/sections` API를 PPL·MNHL로 구분 호출 | +| 진입 처리분구 기본값 | 통과 | 조사 상세현황에서 선택한 처리분구 ID 유지 | +| 처리분구 변경 재조회 | 정적 확인 | 선택 ID를 시설 상세 API에 전달 | +| 이전 조회 상태 초기화 | 통과 | 상세 행·관리번호·정렬·안내 초기화 | +| 제목·다운로드 이름 | 통과 | 현재 선택 처리분구명 사용 | +| 대상 화면 ESLint | 통과 | `ExmnSurveyDetailPopupPage.tsx` 오류·경고 없음 | +| 변경 공백 검사 | 통과 | `git diff --check` | + +### 남은 검증 + +- 로그인된 실제 화면에서 덕풍처리분구로 진입한 뒤 초이처리분구를 선택해 표 내용과 제목이 함께 변경되는지 확인한다. +- 관로 상세보기에는 PPL 처리분구만, 맨홀 상세보기에는 MNHL 처리분구만 표시되는지 확인한다. + +## 25. 2026-07-29 관로 본단위구간 실데이터 연결 검증 + +| 검증 항목 | 결과 | 비고 | +| --- | --- | --- | +| 실제 API 연결 | 통과 | PPL 단위구간 전용 API 호출 | +| 초기 전체 조회 | 정적 확인 | 관리번호 없이 현재 처리분구 전체 | +| 관리번호 부분 조회 | 정적 확인 | 조회 버튼 확정값 전달 | +| 상태·결함 명칭 | 통과 | 코드 기반 프론트 매핑 | +| 빈 상세 단위구간 | 통과 | 결함 필드 하이픈 표시 | +| 기존 샘플·다운로드 | 유지 | 현재 14열 화면 계약 재사용 | +| 대상 ESLint | 통과 | 관련 4개 파일 오류·경고 없음 | + +### 남은 검증 + +- 사용자 환경에서 관로 상세보기 → `본단위구간` 전환 후 덕풍·초이 전체 관로가 표시되는지 확인한다. +- 관리번호 일부를 입력해 해당 관로 단위구간만 표시되는지 확인한다. +- 구조·운영 결함별 상세와 서버 다운로드는 다음 단계에서 검증한다. + +### 본단위구간 표 너비 회귀 + +- 세로 스크롤 유무와 관계없이 표가 스크롤 컨테이너의 가로 폭을 채우도록 너비 계약을 보정했다. +- 컨테이너가 1422px보다 작을 때는 기존 내부 가로 스크롤 동작을 유지한다. + +### 조사중단사유·비고 셀 분리 + +- 조사중단사유와 비고에 단위구간 `rowSpan`이 적용되지 않는 것을 소스로 확인했다. +- 각 상세 행에 두 셀이 각각 렌더링되어 행별 구분선이 유지되는 것을 확인했다. + +## 26. 2026-07-29 구조·운영 결함별 실데이터 연결 검증 + +| 검증 항목 | 결과 | 비고 | +| --- | --- | --- | +| 결함 링크 요청 | 통과 | 선택 `defectType`을 시설 상세 API에 전달 | +| 간소화 팝업 | 통과 | 조회조건·모드 없이 결과와 다운로드 표시 | +| 선택 코드 열 | 통과 | 구조 또는 운영 코드 정의만 사용 | +| 선택 시설 행 | 통과 | 선택 분류 결함이 존재하는 행만 표시 | +| 소계·합계 | 통과 | 현재 표시 행·열 기준 계산 | +| 대상 ESLint | 통과 | API·팝업 변경 파일 오류 없음 | + +### 남은 검증 + +- 사용자 환경에서 관로·맨홀 각각 구조적·운영적 결함 링크를 열어 화면 건수와 DB JSONB를 대조한다. +- 현재 화면 기준 엑셀 파일의 열·소계·합계를 팝업과 대조한다. + +## 27. 2026-07-30 조사 상세현황 다중 시트 다운로드 정적 검증 + +| 검증 항목 | 결과 | 비고 | +| --- | --- | --- | +| 프론트 API 연결 | 구현 | `/sections/download?jobType=PPL\|MNHL` | +| 다운로드 파일 | 구현 | 서버 Blob 1개 저장 | +| 시트 계약 | 정적 확인 | 종합집계 1 + 처리분구 상세 N | +| 본단위구간 제외 | 정적 확인 | 서버 생성 계약에서 제외 | +| 빈 결과·진행 상태 | 구현 | 버튼 비활성·안내 표시 | +| 샘플 모드 | 제거 | 실제 API만 사용하도록 단일화 | +| ESLint | 미실행 | pnpm TTY 오류와 로컬 eslint 파일 접근 `EPERM` | + +### 남은 검증 + +- 로그인된 실제 화면에서 관로·맨홀 탭별 다운로드를 실행한다. +- Excel에서 처리분구 10개 기준 11개 시트인지 확인한다. +- 첫 시트 종합집계와 각 처리분구 상세의 소계·합계를 화면 값과 대조한다. + +## 28. 2026-07-30 샘플 데이터 보기 제거 정적 검증 + +| 검증 항목 | 결과 | 비고 | +| --- | --- | --- | +| 샘플 데이터 파일 | 제거 | `exmn-sample-data.ts` 삭제 | +| 샘플 URL·상태 분기 | 통과 | 기능 범위에서 `sample=`, `sampleMode` 참조 0건 | +| 목록·팝업 실제 API | 통과 | 사업·처리분구·검토·집계·상세 API 호출 유지 | +| 양식 생성 예시행 정리 | 영향 없음 | 백엔드 실제 양식 생성 로직 유지 | +| 전체 빌드 | 미실행 | 사용자 요청에 따라 정적 확인만 수행 | + +### 남은 검증 + +- 조사관리 최초 진입과 사업 행 선택 시 실제 목록·상세현황이 조회되는지 확인한다. +- 사업등록/수정, 결과등록, 전체 상세와 본단위구간 WindowPopup이 실제 데이터로 열리는지 확인한다. +- 과거 `sample=Y` 주소로 진입해도 샘플 화면이 아닌 실제 데이터가 표시되는지 확인한다. + +## 29. 2026-07-30 샘플 제거 변경분 코드 리뷰 결과 + +| 검증 항목 | 결과 | 비고 | +| --- | --- | --- | +| 목록 최초 조회 | 통과 | 빈 조회조건으로 실제 목록 API 호출 | +| 등록·수정 후 동기화 | 통과 | popup 메시지 수신 후 현재 조건으로 목록 재조회 | +| 결과등록 | 통과 | 실제 상세조회·검토·첨부 연결·commit 흐름 유지 | +| 종합집계 | 통과 | 선택 사업·조사유형으로 실제 집계 API 호출 | +| 전체·본단위·결함별 상세 | 통과 | 사업·처리분구·유형 파라미터와 실제 API 호출 유지 | +| WindowPopup props | 통과 | 제거된 샘플 props 참조 없음 | +| 런타임 샘플 참조 | 통과 | 기능 범위 검색 결과 0건 | +| diff 형식 | 통과 | 프론트·공통 문서 `git diff --check` 오류 없음 | +| TypeScript/전체 빌드 | 미실행 | 로컬 TypeScript 접근 `EPERM`, 사용자 요청에 따라 장시간 빌드 생략 | + +### 남은 수동 회귀 + +- 로그인된 실제 화면에서 최초 목록, 등록·수정, 결과등록과 상세 팝업을 한 번씩 진입한다. +- 관로·맨홀 다운로드 파일의 시트 수와 화면·DB 집계값을 대조한다. +- 과거 `sample=Y` 북마크가 존재하더라도 실제 API 화면으로 동작하는지 확인한다. diff --git a/apps/sn/docs/작업계획/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-작업카드.md b/apps/sn/docs/작업계획/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-작업카드.md index 068713867..47f8e42b4 100644 --- a/apps/sn/docs/작업계획/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-작업카드.md +++ b/apps/sn/docs/작업계획/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-작업카드.md @@ -4,7 +4,7 @@ - 작업 ID: `SFR-010-004~007-EXMN-NEW-FE-FIRST` - 작업명: CCTV·맨홀 조사사업 프론트엔드 선행 구현 -- 상태: 화면 1~3단계와 관로·맨홀 상세보기 팝업 완료·커밋 대기(실 API·엑셀 다운로드 후속, 지도보기 보류) +- 상태: 처리분구·사업·결과등록·집계·전체/본단위/결함별 상세·다중 시트 다운로드 실 API 연동 완료(지도보기 보류, 샘플 보기 제거) - 작성일: 2026-07-22 - 담당 역할: FE - 관련 역할: BE, QA @@ -22,9 +22,9 @@ | 단계 | 범위 | 현재 상태 | 다음 단계 진입 조건 | | --- | --- | --- | --- | | FE-0 | 공통 타입·API, 처리분구관리 `/oper/trtdst` | 완료(사용자 화면·UI 검증 통과) | 충족 | -| FE-1 | `SFR-010-004` 사업등록·파일검토·PPL 집계/상세 | 화면 완료 | 사업등록·결과등록·종합집계·관로 상세보기 UI 확인 완료, 실 API·다운로드 후속 | +| FE-1 | `SFR-010-004` 사업등록·파일검토·PPL 집계/상세 | 완료 | 실 API·JSONB·다운로드 연동 완료 | | FE-2 | `SFR-010-005` 조사현황·지도 진입 | 부분 완료 | 같은 화면 조사현황 완료, 지도 진입 보류 | -| FE-3 | `SFR-010-006` MNHL 집계·상세 | 화면 완료 | 맨홀 종합집계·상세보기 UI 확인 완료, 실 API·다운로드 후속 | +| FE-3 | `SFR-010-006` MNHL 집계·상세 | 완료 | 실 API·JSONB·다운로드 연동 완료 | | FE-4 | `SFR-010-007` GIS 레이어·등급 필터·상세 | 대기 | FE-3 완료 | ### 3.1 사용자 확인 기준 화면 단계 @@ -32,7 +32,7 @@ | 화면 단계 | 완료 내용 | 판정 | | --- | --- | --- | | 1단계 | 처리분구관리 조회·추가·수정·삭제, 시도·시군구 조건, 그리드 UI | 완료 | -| 2단계 | 조사사업 검색·정렬·내부 스크롤, 행 선택·강조, 사업등록·결과등록 WindowPopup, `sample=Y` 데이터 | 완료 | +| 2단계 | 조사사업 검색·정렬·내부 스크롤, 행 선택·강조, 사업등록·결과등록 WindowPopup, 실제 API 데이터 | 완료 | | 3단계 | 같은 화면 하단의 관로·맨홀 조사 전환, 처리분구별 종합집계·합계, 상세 내부 스크롤, 요청 순서 보호 | 완료 | - 위 1~3단계는 화면 구현 체크포인트다. 공식 요구사항 단계는 실제 상세보기·다운로드·지도 및 백엔드 연동 범위를 포함하므로 `FE-1~FE-3`을 모두 완료로 판정하지 않는다. @@ -89,7 +89,7 @@ | 사업등록 | `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` 전용 사업·처리분구·검토결과 | +| 데이터 원천 | `src/features/exmn/api/exmn-business-api.ts` | 사업·처리분구·파일검토·저장 실제 API | | PPL | `src/features/exmn/ui/PplSummaryModal.tsx`, `PplFacilityDetailModal.tsx` | 집계·시설·단위구간 상세 | | 타입/API/검증 | `src/features/exmn/{model,api,lib}/exmn-business-*` | 승인 계약 구현 | | 첨부 오류 | `app/components/lib/utils/fileUploadUtil.tsx` | 실패 콜백 추가 | @@ -103,7 +103,7 @@ - 사업등록 WindowPopup의 행정구역 연동 처리분구 체크 선택·가나다순과 저장 전 검증을 확인한다. - 사업등록 WindowPopup은 콘텐츠 창 `1000×620` 안에서 첫 줄에 `사업차수·사업년도·시도·시군구·정밀조사연장`, 둘째 줄에 `사업명`을 표시하고, 별도 닫기·양식 생성 버튼 없이 저장·양식 받기·조사보고서 양식을 제공하는지 확인한다. - 결과등록 WindowPopup의 파일 5종, 행별 찾기·데이터 검토, 검토 정상 전 저장 비활성을 확인한다. -- `/oper/exmn/register?sample=Y`에서만 샘플 사업과 팝업 샘플 상태가 보이고 일반 URL은 실제 API 계약을 유지하는지 확인한다. +- `/oper/exmn/register`에서 실제 사업목록과 팝업 상세·검토 상태가 API 응답대로 표시되는지 확인한다. - 상단에 설계되지 않은 `PPL 집계·상세` 직접 버튼이 표시되지 않는지 확인한다. - 사업·결과 popup 확인 후 PPT 3·4의 동일 목록 화면에 조사 상세현황을 결합하고 사용자 확인을 받는다. @@ -116,7 +116,7 @@ | 호환 라우트 | `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` 전용 관로·맨홀 처리분구 집계 | +| 데이터 원천 | `src/features/exmn/api/exmn-business-api.ts` | 실제 관로·맨홀 처리분구 집계 API | - 원본 화면설계서 슬라이드 3·4처럼 기존 사업등록·결과등록 버튼과 사업목록이 있는 화면에서 행 선택 시 바로 아래에 조사 상세현황을 펼친다. 별도 상세 페이지로 이동하지 않는다. - 사용자 표출 명칭은 `CCTV조사` 대신 `관로조사`, `맨홀조사`를 사용하고 서버 구분값은 `PPL`, `MNHL`을 유지한다. @@ -126,7 +126,7 @@ ## 12. 조사현황·집계 1차 확인 게이트 -- `/oper/exmn/register?sample=Y`의 기존 사업목록에서 샘플 사업을 선택하면 같은 화면 하단 상세현황이 표시되는지 확인한다. +- `/oper/exmn/register`의 실제 사업목록에서 결과가 등록된 사업을 선택하면 같은 화면 하단 상세현황이 표시되는지 확인한다. - 관로조사 탭은 대상연장·주행거리·전체·구조·운영·기타 열, 맨홀조사 탭은 전체·구조·운영·기타 열을 표시하는지 확인한다. - 탭 이름에 `CCTV조사`가 남지 않고 `관로조사`, `맨홀조사`가 표시되는지 확인한다. - 세 기능 버튼은 선택 사업이 없을 때 비활성이고 선택 후 후속 연결 안내를 표시하는지 확인한다. @@ -152,8 +152,8 @@ - 검색조건 간격을 공통 조회영역에 맞추고 사업차수·사업연도·시도·시군구의 선택 동작을 확인했다. - 목록 전체 행 영역에서 사업을 선택할 수 있으며 선택 행을 강조한다. -- 정렬 헤더는 공통 디자인을 따르고 `오름차순 → 내림차순 → 기본`으로 순환한다. 서버 응답과 `sample=Y` 데이터에 같은 정렬 규칙을 적용한다. -- 검증용 사업 샘플을 4건으로 늘렸다. 목록에는 3행이 하단선까지 온전히 보이고 4번째부터 내부 세로 스크롤로 확인한다. +- 정렬 헤더는 공통 디자인을 따르고 `오름차순 → 내림차순 → 기본`으로 순환하며 실제 서버 응답에 동일한 표시 정렬 규칙을 적용한다. +- 실제 사업 목록에는 3행이 하단선까지 온전히 보이고 4번째부터 내부 세로 스크롤로 확인한다. - 상세현황 표와 동일한 좌우 여백·가로 폭·스크롤바 공간을 사용하고 스크롤 중 상·하단선이 잘리지 않게 보정했다. 스크롤바 공간 예약은 두 조사 그리드의 인라인 스타일로 격리해 WindowPopup 디자인에 영향을 주지 않는다. ### 14.2 조사 상세현황 @@ -175,7 +175,7 @@ | 구분 | 경로 | 완료 내용 | | --- | --- | --- | | 화면 정의 | `src/features/exmn/model/exmn-detail-table.ts` | 관로·맨홀 고정 열, 이상항목 코드·명칭, 구조·운영 구분 | -| 샘플 | `src/features/exmn/model/exmn-sample-data.ts` | 조사유형별 3개 상세 행과 코드별 대·중·소 건수 | +| 데이터 원천 | `src/features/exmn/api/exmn-business-api.ts` | 시설 컬럼·JSONB 기반 관로·맨홀 전체/결함별 상세 | | 상세 팝업 | `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`, 필수 파라미터 등록 | @@ -230,7 +230,7 @@ | 구분 | 확정 내용 | | --- | --- | | 입력 순서 | 첫 줄 `사업차수 → 사업년도 → 시도 → 시군구 → 정밀조사연장`, 둘째 줄 `사업명` | -| 사업차수 | 사용자 입력, 1 이상의 정수 검증, 일반 기본값 1·샘플 기본값 3 | +| 사업차수 | 사용자 입력, 1 이상의 정수 검증, 신규 등록 기본값 1 | | 행정구역 | 기존 `CtpvSggPrcsFcltSelectBox`에 기본값 `false`인 `compact` 옵션 추가 | | 입력 폭 | 시도 180px, 시군구 144px, 정밀조사연장 96px | | 입력 간격 | 첫 줄 입력 묶음 사이 20px, 묶음 내부 라벨·입력 사이 8px | @@ -238,3 +238,28 @@ - `compact`를 지정하지 않은 기존 시도·시군구 사용 화면은 기존 폭과 간격을 유지한다. - 이번 변경은 기존 `bizCycl` 요청 필드를 실제 사용자 입력값으로 전환한 프론트 보완이며 API 필드명과 DB 컬럼은 변경하지 않는다. + +## 19. 2026-07-30 조사 상세현황 다중 시트 다운로드 + +| 작업 단위 | 확정 내용 | +|---|---| +| 다운로드 대상 | 현재 선택한 `관로조사(PPL)` 또는 `맨홀조사(MNHL)` | +| 파일 개수 | XLSX 1개 | +| 시트 구성 | 종합집계 1개 + 처리분구별 전체 상세 N개 | +| 처리분구 10개 예시 | 총 11개 시트 | +| 관로 본단위구간 | 제외 | +| 데이터 원천 | 실제 서버 집계·상세 조회 결과 | + +- 조사 상세현황의 초록색 다운로드 버튼은 서버 Blob API를 호출한다. +- 브라우저에서 화면 집계 한 장을 직접 만들던 임시 구현은 제거한다. +- 화면에 조회된 결과가 없거나 다운로드 중이면 버튼을 비활성화한다. +- 지도보기는 기존 보류 범위를 유지한다. + +## 20. 2026-07-30 현재 구현 상태와 샘플 보기 제거 + +- 처리분구 CRUD, 조사사업 조회·등록·수정, 조사양식·보고서 양식 다운로드를 실제 API로 연결했다. +- 조사결과 Excel과 관로 PDF 검토, 첨부 연결, 최종 저장과 PPL/MNHL 집계를 실제 API로 연결했다. +- 관로·맨홀 종합집계, 전체 상세, 관로 본단위구간, 구조·운영 결함별 상세를 실제 DB·JSONB 조회 결과로 표시한다. +- 종합집계 1개와 처리분구별 전체 상세 N개를 한 XLSX로 내려받으며 본단위구간은 통합 다운로드에서 제외한다. +- 개발환경용 샘플 전환 버튼, `sample=Y` 분기와 `exmn-sample-data.ts`를 제거해 모든 화면이 실제 API만 사용한다. +- 지도보기는 기존 결정대로 보류하며 현재 남은 프론트 기능 범위다. diff --git a/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md b/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md index aa520947b..45e1d56c7 100644 --- a/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md +++ b/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md @@ -26,13 +26,16 @@ | 2026-07-23 | 화면 3단계 완료 | 같은 화면의 관로·맨홀 종합집계, 합계, 내부 스크롤, 요청 순서 보호 사용자 확인 완료 | | 2026-07-23 | 팝업 회귀 복구 | Tailwind `src` 스캔 복구와 핵심 열·폭 인라인 고정 후 완료 디자인 재확인 | | 2026-07-24 | 상세보기 화면 완료 | 관로·맨홀 WindowPopup 다단표, 조회·정렬, 고정열, 소계·합계 사용자 확인 완료 | +| 2026-07-27~29 | 실 API 통합 | 처리분구·사업·파일검토/저장·집계·전체/본단위/결함별 상세 API 연결 | +| 2026-07-30 | 다운로드·샘플 제거 | 다중 시트 XLSX 다운로드 연결 및 샘플 전환·데이터·분기 제거 | ### 3.1 현재 구현 경계 -- 화면 1단계 `처리분구관리`, 화면 2단계 `조사사업 관리`, 화면 3단계 `조사 상세현황`까지 프론트 UI와 `sample=Y` 검증 흐름을 완료했다. -- 화면 3단계의 관로·맨홀 처리분구별 종합집계와 후속 관로·맨홀 상세보기 팝업까지 완료했다. -- 상세보기는 `sample=Y` 데이터와 화면 동작 기준으로 완료했으며 실 상세 API와 엑셀 다운로드 연동은 다음 프론트·백엔드 통합 작업이다. `지도보기`는 보류한다. -- 백엔드 API·서비스·Repository·DB와 실데이터 통합 검증은 아직 시작하지 않았다. +- 처리분구관리, 조사사업 관리, 결과등록과 관로·맨홀 조사 상세현황을 실제 API로 연결했다. +- 관로·맨홀 처리분구별 종합집계, 전체 상세, 관로 본단위구간과 구조·운영 결함별 상세를 실제 DB·JSONB 결과로 표시한다. +- 조사 상세현황 다운로드는 종합집계 1개와 처리분구별 전체 상세 N개 시트를 서버 XLSX로 제공한다. +- 샘플 전환 버튼과 `sample=Y` 분기·데이터 파일은 제거했으며 모든 조사관리 화면은 실제 API만 사용한다. +- 지도보기는 보류 상태이며 현재 남은 프론트 기능 범위다. ## 4. FE-0 처리분구관리 @@ -566,3 +569,108 @@ - 보고서 파일명이 잘리지 않도록 목록 열 비율을 `사업차수 6%`, `처리구역·분구 8%`, `정밀조사 연장 8%`, `보고서 22%`로 최종 조정했다. - 좁아진 두 숫자 열의 헤더는 좌우 여백과 글자 크기를 보정하고 줄바꿈을 금지해 한 줄로 표시한다. - 매우 긴 보고서명은 셀 오른쪽에서 말줄임하고 `title`로 전체 이름을 확인할 수 있다. + +## 25. 2026-07-29 조사 상세현황 실제 집계 API 연결 + +- 기존 화면이 호출하던 `GET /api/v1/exmnBusinesses/{id}/sections?jobType=PPL|MNHL` 백엔드 API가 구현됐다. +- 관로조사는 처리분구별 대상연장·주행거리와 PPL 전체이상·구조·운영·기타 집계를 표시한다. +- 맨홀조사는 처리분구별 MNHL 전체이상·구조·운영·기타 집계를 표시한다. +- 결과 미등록 사업은 서버가 빈 배열을 반환하므로 오류 배너 없이 기존 빈 그리드 문구를 유지한다. +- 사업·탭을 빠르게 전환했을 때 이전 응답을 무시하는 기존 요청 순서 보호는 그대로 사용한다. +- 이번 단계에서는 프론트 소스 계약 변경이 없으며 상세보기·본단위구간·결함별 조회·다운로드는 연결하지 않았다. + +## 26. 2026-07-29 조사 상세보기 전체 집계 API 연결 + +- 처리분구 행 선택 후 여는 기존 상세보기 팝업의 관로 `PIPE` 모드와 맨홀 전체 표를 실제 `/api/v1/exmnFacilities` 응답에 연결했다. +- 선택 사업 ID, 처리분구 ID, 조사유형과 조회 버튼으로 확정한 관리번호를 서버에 전달한다. +- 서버의 시설 기본 컬럼과 `defectCounts[code].L/M/S`를 기존 다단표·소계·합계·정렬 화면 모델에 그대로 사용한다. +- 요청 중 안내와 조회 실패 메시지를 구분하고, 빠른 조건 변경 시 이전 요청 응답을 화면에 반영하지 않는다. +- 샘플 모드는 기존 데이터를 유지한다. +- 본단위구간과 구조·운영 결함별 실데이터 연결은 후속 단계 안내를 유지한다. + +## 27. 2026-07-29 맨홀 전체 상세표 표시 보정 + +- 맨홀조사의 시설 식별자를 별도 맨홀번호가 아닌 관로번호로 통일했다. +- 관로 전체표는 보고서번호부터 주행거리까지만 표시하고 구조적·운영적 등급은 본단위구간 화면에 유지한다. +- 맨홀 전체표는 관로 공통 열 다음에 구조적 내부결함, 운영적 내부결함 등급 열을 추가하고 소계·합계에도 숫자 합산값을 표시한다. +- 고정 열 뒤의 결함 코드 열만 PPL·MNHL 정의에 따라 다르게 표시한다. +- 화면 다운로드도 관로는 주행거리까지, 맨홀은 구조·운영 등급까지 고정 열을 구성한 뒤 조사유형별 결함 코드 열을 추가한다. + +## 28. 2026-07-29 상세보기 처리분구 조회 조건 연계 + +- 상세보기 팝업의 처리분구 선택값을 URL로 전달받은 한 건의 고정 옵션에서 사업·조사유형별 실제 상세현황 처리분구 목록으로 변경했다. +- 관로 상세보기는 PPL 결과가 있는 처리분구, 맨홀 상세보기는 MNHL 결과가 있는 처리분구만 기존 `/sections?jobType=PPL|MNHL` API로 조회한다. +- 조사 상세현황에서 선택한 처리분구를 팝업 최초 기본값으로 유지하고, 목록에 없을 때만 해당 조사유형의 첫 처리분구로 대체한다. +- 처리분구를 변경하면 관리번호·정렬·안내와 이전 상세 행을 초기화한 뒤 선택한 처리분구의 시설 상세를 다시 조회한다. +- 표 제목과 화면 엑셀 다운로드 파일명도 현재 선택한 처리분구명으로 갱신한다. + +## 29. 2026-07-29 관로 본단위구간 실데이터 연결 + +- 관로 상세보기의 `본단위구간` 모드를 신규 PPL 단위구간 조회 API에 연결했다. +- 관리번호가 비어 있으면 현재 처리분구의 전체 관로 단위구간을 표시하고, 입력 후 조회하면 부분 일치 결과를 표시한다. +- 서버의 상태·결함 코드를 기존 관로 결함 코드표와 상태 코드표에 매핑해 엑셀형 14열 표에 표시한다. +- 상세 기록이 없는 단위구간은 결함 필드를 하이픈으로 표시하되 관로번호·단위구간·단위길이는 유지한다. +- 처리분구·모드·관리번호를 빠르게 바꿨을 때 이전 요청이 새 화면을 덮어쓰지 않도록 기존 effect 취소 흐름을 적용했다. +- 실제 API 조회와 현재 화면 기준 엑셀 다운로드를 유지한다. + +### 본단위구간 표 너비 보정 + +- 표의 기존 1422px 최소 너비는 유지하면서 실제 너비를 스크롤 컨테이너의 100%로 확장했다. +- 데이터 양에 따라 세로 스크롤바가 생기거나 사라져도 오른쪽에 스크롤바 폭만큼 빈 영역이 남지 않는다. + +### 조사중단사유·비고 셀 분리 + +- 조사중단사유와 비고는 단위구간 기준 병합 대상에서 제외했다. +- 두 열은 본단위구간의 각 상세 행마다 독립된 셀과 구분선을 표시한다. + +## 30. 2026-07-29 구조·운영 결함별 실데이터 연결 + +- 조사 상세현황의 구조적·운영적 결함 건수 링크에서 전달한 `defectType`을 실제 시설 상세 API에 연결했다. +- 결함별 팝업은 기존 설계대로 처리분구·관리번호·모드·조회 영역을 표시하지 않고 선택 분류 결과를 즉시 조회한다. +- 서버가 반환한 선택 분류의 시설 행과 코드 열만 표시하고 화면 행·열 기준 소계와 합계를 다시 계산한다. +- 후속 구현 안내 문구를 제거하고 조회 중·오류 상태를 실제 API 요청 상태로 표시한다. +- 결함별 다운로드는 현재 표시 행·열과 코드 헤더, 소계·합계 구조를 그대로 사용한다. + +## 31. 2026-07-30 조사 상세현황 다중 시트 다운로드 연결 + +- `downloadExmnSectionSummary` API 함수를 추가해 현재 선택한 조사유형을 서버 다운로드 파라미터로 전달한다. +- 조사 상세현황 다운로드 버튼은 서버가 생성한 XLSX Blob을 사업명·조사유형 기반 파일명으로 저장한다. +- 다운로드 중에는 버튼을 비활성화하고 `다운로드 중...` 상태를 표시한다. +- 조회 결과가 없으면 다운로드를 막고 기존 안내 영역에 메시지를 표시한다. +- 조사 상세현황 다운로드는 샘플 분기 없이 실제 서버 API만 호출한다. +- 브라우저에서 종합집계 1개 시트만 만들던 임시 공통 Excel 함수 호출은 제거했다. +- 관로 본단위구간은 이번 통합 다운로드 대상에 포함하지 않는다. + +### 수정 파일 + +- `src/features/exmn/api/exmn-business-api.ts` +- `src/features/exmn/ui/ExmnStatusDetailPanel.tsx` + +## 32. 2026-07-30 샘플 데이터 보기 기능 제거 + +- 조사사업 목록의 개발환경용 `샘플 데이터 보기`·`실데이터 보기` 버튼을 제거했다. +- `sample=Y` URL 파라미터와 목록·사업등록/수정·결과등록·상세현황·본단위구간의 샘플 분기를 제거했다. +- 샘플 사업, 처리분구, 검토 결과, 집계, 시설 상세와 본단위구간을 제공하던 `exmn-sample-data.ts`를 삭제했다. +- 조사관리 화면과 WindowPopup은 모두 실제 API 데이터만 조회·저장하도록 단일화했다. +- 백엔드 양식 생성기의 예시행 정리 로직은 실제 조사입력 양식 생성에 필요한 기능이므로 이번 제거 대상에 포함하지 않았다. + +### 수정 파일 + +- `src/features/exmn/model/exmn-sample-data.ts` 삭제 +- `src/features/exmn/ui/ExmnRegistrationPage.tsx` +- `src/features/exmn/ui/ExmnStatusDetailPanel.tsx` +- `src/features/exmn/ui/ExmnBusinessRegisterPopupPage.tsx` +- `src/features/exmn/ui/ExmnResultRegisterPopupPage.tsx` +- `src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx` +- `src/features/windowPopup/ui/ExmnBusinessRegisterPopupContent.tsx` +- `src/features/windowPopup/ui/ExmnResultRegisterPopupContent.tsx` +- `src/features/windowPopup/ui/ExmnSurveyDetailPopupContent.tsx` + +## 33. 2026-07-30 샘플 제거 변경분 코드 리뷰 + +- 목록 최초 조회, 등록·수정 완료 후 목록 재조회, 결과등록 검토·저장, 상세현황·본단위구간 실제 API 호출 흐름을 다시 확인했다. +- WindowPopup content와 화면 컴포넌트 사이에서 제거된 `sampleMode`·샘플 사업 props가 남아 있지 않음을 확인했다. +- 실제 사업 요약 query, 사업 ID, 처리분구 ID, 조사유형과 결함유형 전달 계약은 유지했다. +- 기능 범위의 `sampleMode`, `sample=`, `exmn-sample-data`, 샘플 식별자 런타임 참조는 0건이다. +- 코드 추가 수정이 필요한 결함은 발견되지 않아 샘플 제거 변경분을 유지했다. +- `git diff --check`는 통과했다. 로컬 TypeScript 모듈 접근은 기존 `EPERM` 환경 문제로 실행하지 못했고 전체 빌드는 사용자 요청에 따라 수행하지 않았다. diff --git a/apps/sn/src/features/exmn/api/exmn-business-api.ts b/apps/sn/src/features/exmn/api/exmn-business-api.ts index b03e6a65f..29b429ed9 100644 --- a/apps/sn/src/features/exmn/api/exmn-business-api.ts +++ b/apps/sn/src/features/exmn/api/exmn-business-api.ts @@ -1,11 +1,3 @@ -import { - getBlobData, - getData, - getRawJson, - postMutiPart, - postRawJson, - putData, -} from "@/components/lib/api"; import type { ExmnAttachmentKey, ExmnBusiness, @@ -23,6 +15,22 @@ import type { PplFacilitySearchParams, PplSummaryRow, } from "../model/exmn-business-types"; +import { + type ExmnDefectGroup, + type ExmnDetailSeverity, + type ExmnPplUnitDetailRow, + type ExmnSurveyDetailRow, + PIPE_DETAIL_DEFECT_COLUMNS, +} from "../model/exmn-detail-table"; + +import { + getBlobData, + getData, + getRawJson, + postMutiPart, + postRawJson, + putData, +} from "@/components/lib/api"; /** 조사사업 API의 공통 기준 경로다. */ const EXMN_BUSINESS_API_PATH = "v1/exmnBusinesses"; @@ -132,6 +140,109 @@ export const getExmnSectionSummaries = async ( } }; +/** + * 현재 조사유형의 종합집계 1개와 처리분구별 전체 상세 시트를 하나의 XLSX로 내려받는다. + * 관로 본단위구간은 이 다운로드 계약에 포함하지 않는다. + */ +export const downloadExmnSectionSummary = async ( + exmnBizId: string, + jobType: ExmnJobType, +): Promise => { + const query = new URLSearchParams({ jobType }); + return getBlobData( + `${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/sections/download?${query}`, + ); +}; + +/** 선택 사업·처리분구의 관로·맨홀 전체 상세 집계 행을 조회한다. */ +export const getExmnFacilityDetails = async (params: { + exmnBizId: string; + jobType: ExmnJobType; + trtdstId: string; + gisManagementNo?: string; + defectType?: ExmnDefectGroup | "ALL"; +}): Promise => { + const query = new URLSearchParams({ + exmnBizId: params.exmnBizId, + jobType: params.jobType, + trtdstId: params.trtdstId, + }); + if (params.gisManagementNo?.trim()) { + query.set("gisManagementNo", params.gisManagementNo.trim()); + } + if (params.defectType && params.defectType !== "ALL") { + query.set("defectType", params.defectType); + } + const response = await getData(`v1/exmnFacilities?${query}`) as + ExmnSurveyDetailRow[] | { data?: ExmnSurveyDetailRow[] }; + const payload = unwrapData(response); + return Array.isArray(payload) ? payload : []; +}; + +interface ExmnPplUnitDetailPayload { + pipeNo: string; + unitSeq: number; + unitLengthM: number; + defectPositionM: number | null; + code: string; + recordType: "STATUS" | "DEFECT" | null; + defectType: ExmnDefectGroup | null; + severity: ExmnDetailSeverity | null; + occurrencePosition: string; + infiltrationBonus: number | null; + continuous: boolean | null; + structuralScore: number | null; + operationalScore: number | null; + surveyStopReason: string | null; + remark: string | null; +} + +const PPL_STATUS_LABELS: Record = { + IS: "조사시작(순주행)", + ISR: "조사시작(역주행)", + IE: "조사완료(순주행)", + IER: "조사완료(역주행)", + IA: "조사중단", + JE: "이음부(접합부)존재", + WD: "이상 없음", +}; + +const getPplUnitDetailName = (row: ExmnPplUnitDetailPayload) => { + const code = row.code?.trim(); + if (!code) return ""; + const normalizedCode = code.toUpperCase(); + if (row.recordType === "STATUS") { + return `(상태)${PPL_STATUS_LABELS[normalizedCode] ?? code}`; + } + const defect = PIPE_DETAIL_DEFECT_COLUMNS.find( + (column) => column.code.toUpperCase() === normalizedCode, + ); + return `(관로)${defect?.label ?? code}`; +}; + +/** 선택 처리분구의 PPL_UNIT_V1 본단위구간 상태·결함 행을 조회한다. */ +export const getExmnPplUnitDetails = async (params: { + exmnBizId: string; + trtdstId: string; + gisManagementNo?: string; +}): Promise => { + const query = new URLSearchParams({ + exmnBizId: params.exmnBizId, + trtdstId: params.trtdstId, + }); + if (params.gisManagementNo?.trim()) { + query.set("gisManagementNo", params.gisManagementNo.trim()); + } + const response = await getData(`v1/exmnFacilities/pplUnitSections?${query}`) as + ExmnPplUnitDetailPayload[] | { data?: ExmnPplUnitDetailPayload[] }; + const payload = unwrapData(response); + if (!Array.isArray(payload)) return []; + return payload.map((row) => ({ + ...row, + defectName: getPplUnitDetailName(row), + })); +}; + /** 선택한 조사사업의 처리분구·첨부를 포함한 상세정보를 조회한다. */ export const getExmnBusiness = async (exmnBizId: string): Promise => getData(`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}`) as Promise; diff --git a/apps/sn/src/features/exmn/model/exmn-detail-table.ts b/apps/sn/src/features/exmn/model/exmn-detail-table.ts index 5b2321694..f6b63c83e 100644 --- a/apps/sn/src/features/exmn/model/exmn-detail-table.ts +++ b/apps/sn/src/features/exmn/model/exmn-detail-table.ts @@ -23,9 +23,11 @@ export interface ExmnSurveyDetailRow { downstreamManhole: string; drainageType: string; pipeType: string; - specification: number; + specification: string | number; extensionM: number; drivingDistanceM: number; + structuralGrade: string; + operationalGrade: string; anomalyTotals: ExmnSeverityCounts; defectCounts: Record; } @@ -38,14 +40,14 @@ export interface ExmnPplUnitDetailRow { pipeNo: string; unitSeq: number; unitLengthM: number; - defectPositionM: number; + defectPositionM: number | null; defectName: string; code: string; - recordType: "STATUS" | "DEFECT"; + recordType: "STATUS" | "DEFECT" | null; defectType: ExmnDefectGroup | null; severity: ExmnDetailSeverity | null; occurrencePosition: string; - infiltrationBonus: number; + infiltrationBonus: number | null; continuous: boolean | null; structuralScore: number | null; operationalScore: number | null; diff --git a/apps/sn/src/features/exmn/model/exmn-sample-data.ts b/apps/sn/src/features/exmn/model/exmn-sample-data.ts deleted file mode 100644 index ecb90d998..000000000 --- a/apps/sn/src/features/exmn/model/exmn-sample-data.ts +++ /dev/null @@ -1,462 +0,0 @@ -import type { - ExmnBusiness, - ExmnBusinessDetail, - ExmnImportValidationResult, - ExmnJobType, - ExmnSectionSummaryRow, -} from "./exmn-business-types"; -import { - EXMN_DETAIL_SEVERITIES, - EXMN_DETAIL_TABLE_DEFINITIONS, - type ExmnPplUnitDetailRow, - 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", - bizRptpFileName: "2026년_양평군_정밀조사_사업보고서.pdf", - }, - { - 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", - bizRptpFileName: "2024년_원주시_관로맨홀_사업보고서.pdf", - }, - { - 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 => 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, -): 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, - }; -}); - -/** 엑셀 `본단위등급표`의 한 관로를 단위구간별 병합 표로 확인하는 샘플 원형이다. */ -const EXMN_SAMPLE_PPL_UNIT_DETAIL_TEMPLATE: ExmnPplUnitDetailRow[] = [ - { - pipeNo: "118-0026", - unitSeq: 1, - unitLengthM: 6, - defectPositionM: 0, - defectName: "(상태)조사시작(순주행)", - code: "IS", - recordType: "STATUS", - defectType: null, - severity: null, - occurrencePosition: "0/0", - infiltrationBonus: 0, - continuous: null, - structuralScore: null, - operationalScore: null, - surveyStopReason: null, - remark: null, - }, - { - pipeNo: "118-0026", - unitSeq: 1, - unitLengthM: 6, - defectPositionM: 5.4, - defectName: "(관로)변형", - code: "DF", - recordType: "DEFECT", - defectType: "STRUCTURAL", - severity: "S", - occurrencePosition: "10/2", - infiltrationBonus: 0, - continuous: false, - structuralScore: 25, - operationalScore: null, - surveyStopReason: null, - remark: null, - }, - { - pipeNo: "118-0026", - unitSeq: 1, - unitLengthM: 6, - defectPositionM: 6, - defectName: "(관로)이음부이탈", - code: "JS", - recordType: "DEFECT", - defectType: "STRUCTURAL", - severity: "L", - occurrencePosition: "4/6", - infiltrationBonus: 0, - continuous: null, - structuralScore: 35, - operationalScore: null, - surveyStopReason: null, - remark: null, - }, - { - pipeNo: "118-0026", - unitSeq: 1, - unitLengthM: 6, - defectPositionM: 6, - defectName: "(상태)이음부(접합부)존재", - code: "JE", - recordType: "STATUS", - defectType: null, - severity: null, - occurrencePosition: "0/0", - infiltrationBonus: 0, - continuous: null, - structuralScore: null, - operationalScore: null, - surveyStopReason: null, - remark: null, - }, - { - pipeNo: "118-0026", - unitSeq: 2, - unitLengthM: 6, - defectPositionM: 6, - defectName: "(상태)이음부(접합부)존재", - code: "JE", - recordType: "STATUS", - defectType: null, - severity: null, - occurrencePosition: "0/0", - infiltrationBonus: 0, - continuous: null, - structuralScore: null, - operationalScore: null, - surveyStopReason: null, - remark: null, - }, - { - pipeNo: "118-0026", - unitSeq: 2, - unitLengthM: 6, - defectPositionM: 6, - defectName: "(관로)이음부이탈", - code: "JS", - recordType: "DEFECT", - defectType: "STRUCTURAL", - severity: "L", - occurrencePosition: "4/6", - infiltrationBonus: 0, - continuous: null, - structuralScore: 35, - operationalScore: null, - surveyStopReason: null, - remark: null, - }, - { - pipeNo: "118-0026", - unitSeq: 2, - unitLengthM: 6, - defectPositionM: 7, - defectName: "(관로)균열원주", - code: "CC", - recordType: "DEFECT", - defectType: "STRUCTURAL", - severity: "L", - occurrencePosition: "11/12", - infiltrationBonus: 0, - continuous: null, - structuralScore: 40, - operationalScore: null, - surveyStopReason: null, - remark: null, - }, - { - pipeNo: "118-0026", - unitSeq: 2, - unitLengthM: 6, - defectPositionM: 9.7, - defectName: "(관로)균열원주", - code: "CC", - recordType: "DEFECT", - defectType: "STRUCTURAL", - severity: "L", - occurrencePosition: "7/8", - infiltrationBonus: 0, - continuous: null, - structuralScore: 40, - operationalScore: null, - surveyStopReason: null, - remark: null, - }, - { - pipeNo: "118-0026", - unitSeq: 2, - unitLengthM: 6, - defectPositionM: 11.6, - defectName: "(관로)변형", - code: "DF", - recordType: "DEFECT", - defectType: "STRUCTURAL", - severity: "S", - occurrencePosition: "10/12", - infiltrationBonus: 0, - continuous: false, - structuralScore: 25, - operationalScore: null, - surveyStopReason: null, - remark: null, - }, - { - pipeNo: "118-0026", - unitSeq: 2, - unitLengthM: 6, - defectPositionM: 12, - defectName: "(관로)이음부이탈", - code: "JS", - recordType: "DEFECT", - defectType: "STRUCTURAL", - severity: "L", - occurrencePosition: "5/7", - infiltrationBonus: 0, - continuous: null, - structuralScore: 35, - operationalScore: null, - surveyStopReason: null, - remark: null, - }, - { - pipeNo: "118-0026", - unitSeq: 2, - unitLengthM: 6, - defectPositionM: 12, - defectName: "(상태)이음부(접합부)존재", - code: "JE", - recordType: "STATUS", - defectType: null, - severity: null, - occurrencePosition: "0/0", - infiltrationBonus: 0, - continuous: null, - structuralScore: null, - operationalScore: null, - surveyStopReason: null, - remark: null, - }, -]; - -/** - * 본단위구간 모드 최초 진입 시 처리분구의 관리번호 전체가 보이도록 - * 관로 상세 샘플 3건에 동일한 단위구간 원형을 적용한다. - */ -export const EXMN_SAMPLE_PPL_UNIT_DETAILS: ExmnPplUnitDetailRow[] = - SAMPLE_FACILITIES.flatMap((facility) => - EXMN_SAMPLE_PPL_UNIT_DETAIL_TEMPLATE.map((row) => ({ - ...row, - pipeNo: facility.pipeNo, - })), - ); diff --git a/apps/sn/src/features/exmn/ui/ExmnBusinessRegisterPopupPage.tsx b/apps/sn/src/features/exmn/ui/ExmnBusinessRegisterPopupPage.tsx index a12097f4b..63a2374f6 100644 --- a/apps/sn/src/features/exmn/ui/ExmnBusinessRegisterPopupPage.tsx +++ b/apps/sn/src/features/exmn/ui/ExmnBusinessRegisterPopupPage.tsx @@ -19,9 +19,7 @@ import { } 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 { - ExmnBusiness, ExmnBusinessDetail, ExmnBusinessSaveRequest, } from "../model/exmn-business-types"; @@ -33,9 +31,7 @@ const DOWNLOAD_BUTTON_CLASS = type Notice = { type: "success" | "error" | "info"; text: string } | null; interface ExmnBusinessRegisterPopupPageProps { - sampleMode: boolean; exmnBizId?: string; - initialBusiness?: ExmnBusiness; } /** 처리분구 이름을 공백 제거 후 한글 가나다순으로 정렬한다. */ @@ -54,9 +50,7 @@ const saveBlob = (blob: Blob, fileName: string) => { /** 공통 WindowPopup 안에서 사업정보·대상 처리분구·조사양식을 등록·수정하는 화면이다. */ export function ExmnBusinessRegisterPopupPage({ - sampleMode, exmnBizId, - initialBusiness, }: ExmnBusinessRegisterPopupPageProps) { const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore(); const isEditMode = Boolean(exmnBizId); @@ -65,14 +59,14 @@ export function ExmnBusinessRegisterPopupPage({ /** 개발 모드 재실행이나 사업 변경 시 이전 상세조회 응답이 현재 화면을 덮지 않게 한다. */ const businessRequestIdRef = useRef(0); /** 사업 확장 시 등록자가 직접 지정할 수 있도록 저장 요청에 전달하는 사업차수다. */ - const [bizCycl, setBizCycl] = useState(sampleMode ? "3" : "1"); + const [bizCycl, setBizCycl] = useState("1"); const [bizYr, setBizYr] = useState(String(new Date().getFullYear())); - const [bizNm, setBizNm] = useState(sampleMode ? "2026년 양평군 노후 하수관로 정밀조사사업" : ""); + const [bizNm, setBizNm] = useState(""); 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" : ""); + const [ctpvNm, setCtpvNm] = useState(""); + const [sggNm, setSggNm] = useState(""); + const [prcisExmnPrlg, setPrcisExmnPrlg] = useState(""); const [districts, setDistricts] = useState([]); /** 등록 후보가 많을 때 처리분구명으로 화면 목록을 좁히는 검색어다. */ const [districtKeyword, setDistrictKeyword] = useState(""); @@ -104,25 +98,7 @@ export function ExmnBusinessRegisterPopupPage({ setIsLoading(true); setNotice(null); - const sampleDetail = initialBusiness - ? { - ...initialBusiness, - trtdstIds: EXMN_SAMPLE_TRTDST_SETTINGS - .slice(0, initialBusiness.trtdstCnt) - .map((district) => district.trtdstId), - districts: EXMN_SAMPLE_TRTDST_SETTINGS - .slice(0, initialBusiness.trtdstCnt) - .map((district) => ({ - trtdstId: district.trtdstId, - trtdstNm: district.trtdstNm, - })), - } satisfies ExmnBusinessDetail - : null; - - const request = sampleMode - ? Promise.resolve(sampleDetail) - : getExmnBusiness(exmnBizId); - request + getExmnBusiness(exmnBizId) .then((business) => { if (businessRequestIdRef.current !== requestId) return; if (!business) { @@ -155,7 +131,7 @@ export function ExmnBusinessRegisterPopupPage({ businessRequestIdRef.current += 1; } }; - }, [exmnBizId, initialBusiness, sampleMode]); + }, [exmnBizId]); /** 선택 행정구역의 활성 처리분구를 읽고 화면 요구사항대로 가나다순으로 제공한다. */ useEffect(() => { @@ -166,21 +142,6 @@ export function ExmnBusinessRegisterPopupPage({ return; } - if (sampleMode) { - const items = sortDistricts(EXMN_SAMPLE_TRTDST_SETTINGS.map((item) => ({ - ...item, - ctpvNm, - sggNm, - }))); - setDistricts(items); - const pendingIds = pendingSelectedDistrictIdsRef.current; - setSelectedDistrictIds(pendingIds - ? pendingIds.filter((id) => items.some((item) => item.trtdstId === id)) - : []); - pendingSelectedDistrictIdsRef.current = null; - return; - } - let active = true; setIsLoading(true); getTrtdstSettings({ ctpvNm, sggNm, useYn: "Y" }) @@ -209,11 +170,7 @@ export function ExmnBusinessRegisterPopupPage({ return () => { active = false; }; - }, [ctpvNm, sampleMode, sggNm]); - - /** 선택 개수와 이름을 저장 요청과 양식 생성에서 동일하게 사용한다. */ - const selectedDistricts = useMemo(() => districts.filter((district) => - selectedDistrictIds.includes(district.trtdstId)), [districts, selectedDistrictIds]); + }, [ctpvNm, sggNm]); /** 수정 후 저장하지 않은 입력이 있으면 기존 사업 기준 양식 다운로드를 차단한다. */ const hasUnsavedChanges = useMemo(() => { @@ -279,7 +236,7 @@ export function ExmnBusinessRegisterPopupPage({ trtdstIds: selectedDistrictIds, }); - /** 실제 모드에서는 API를 호출하고 샘플 모드에서는 명시적 미리보기 사업만 부모 목록에 전달한다. */ + /** 입력값을 검증해 조사사업을 신규 등록하거나 수정한다. */ const handleSave = async () => { if (!prcisExmnPrlg.trim()) { setNotice({ type: "error", text: "정밀조사 연장을 입력해주세요." }); @@ -295,21 +252,9 @@ export function ExmnBusinessRegisterPopupPage({ setIsLoading(true); setNotice(null); try { - const persisted: ExmnBusinessDetail = sampleMode - ? { - exmnBizId: exmnBizId ?? `SAMPLE-BIZ-${Date.now()}`, - ...request, - trtdstCnt: selectedDistrictIds.length, - reportYn: "N", - trtdstIds: [...selectedDistrictIds], - districts: selectedDistricts.map((district) => ({ - trtdstId: district.trtdstId, - trtdstNm: district.trtdstNm, - })), - } - : isEditMode && exmnBizId - ? await updateExmnBusiness(exmnBizId, request) - : await createExmnBusiness(request); + const persisted: ExmnBusinessDetail = isEditMode && exmnBizId + ? await updateExmnBusiness(exmnBizId, request) + : await createExmnBusiness(request); setSavedBusiness(persisted); postPopupMessage( @@ -318,17 +263,13 @@ export function ExmnBusinessRegisterPopupPage({ : WINDOW_POPUP_MESSAGE_TYPE.EXMN_BUSINESS_CREATED, { exmnBizId: persisted.exmnBizId, - sample: sampleMode ? "Y" : "N", - business: sampleMode ? JSON.stringify(persisted) : "", }, ); setNotice({ type: "success", text: isEditMode ? "사업정보를 수정했습니다. 변경된 처리분구 기준으로 양식을 다시 받을 수 있습니다." - : sampleMode - ? "샘플 사업정보를 저장했습니다. 부모 목록에서 바로 확인할 수 있습니다." - : "사업정보와 처리분구별 기본 요약행을 저장했습니다.", + : "사업정보와 처리분구별 기본 요약행을 저장했습니다.", }); } catch (error) { setNotice({ @@ -350,10 +291,6 @@ export function ExmnBusinessRegisterPopupPage({ setNotice({ type: "info", text: "수정한 사업정보를 먼저 저장한 뒤 양식을 받아주세요." }); return; } - if (sampleMode) { - setNotice({ type: "info", text: "샘플 모드에서는 실제 양식 파일을 다운로드하지 않습니다." }); - return; - } setIsLoading(true); setNotice(null); try { diff --git a/apps/sn/src/features/exmn/ui/ExmnPplUnitDetailTable.tsx b/apps/sn/src/features/exmn/ui/ExmnPplUnitDetailTable.tsx index 819a84cae..0b466d634 100644 --- a/apps/sn/src/features/exmn/ui/ExmnPplUnitDetailTable.tsx +++ b/apps/sn/src/features/exmn/ui/ExmnPplUnitDetailTable.tsx @@ -76,7 +76,7 @@ export function ExmnPplUnitDetailTable({
{COLUMN_WIDTHS.map((width, index) => ( @@ -142,35 +142,29 @@ export function ExmnPplUnitDetailTable({ )} - + - + - {showUnitCells && ( - <> - - - - )} + + ); }) : ( diff --git a/apps/sn/src/features/exmn/ui/ExmnRegistrationPage.tsx b/apps/sn/src/features/exmn/ui/ExmnRegistrationPage.tsx index 57f9c4ab8..a2bcdeb4f 100644 --- a/apps/sn/src/features/exmn/ui/ExmnRegistrationPage.tsx +++ b/apps/sn/src/features/exmn/ui/ExmnRegistrationPage.tsx @@ -29,7 +29,6 @@ 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 = @@ -72,7 +71,7 @@ const EMPTY_FILTERS: BusinessFilters = { type Notice = { type: "success" | "error" | "info"; text: string } | null; -/** 서버·샘플 목록에 같은 비교 규칙을 적용해 정렬 헤더와 실제 행 순서를 일치시킨다. */ +/** 조회 목록에 공통 비교 규칙을 적용해 정렬 헤더와 실제 행 순서를 일치시킨다. */ const sortBusinessList = ( source: ExmnBusiness[], nextSort: UiTableSort | undefined, @@ -116,9 +115,6 @@ export function ExmnRegistrationPage() { const [draftFilters, setDraftFilters] = useState(EMPTY_FILTERS); const [appliedFilters, setAppliedFilters] = useState(EMPTY_FILTERS); const [businesses, setBusinesses] = useState([]); - // 명시적 sample=Y 모드에서 신규 샘플 등록까지 유지하는 부모 창 전용 데이터 원본이다. - const [sampleBusinesses, setSampleBusinesses] = useState(EXMN_SAMPLE_BUSINESSES); - const [sampleMode, setSampleMode] = useState(false); const [total, setTotal] = useState(0); const [sort, setSort] = useState(); // 선택 사업 ID는 서버 재조회 뒤에도 같은 행을 복원할 수 있도록 객체와 별도로 유지한다. @@ -185,14 +181,6 @@ export function ExmnRegistrationPage() { 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, @@ -223,21 +211,6 @@ export function ExmnRegistrationPage() { : 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, @@ -246,13 +219,6 @@ export function ExmnRegistrationPage() { 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); @@ -273,17 +239,10 @@ export function ExmnRegistrationPage() { } }; - /** URL에 sample=Y가 명시된 경우에만 실제 API와 분리된 화면 검증용 사업목록을 노출한다. */ + /** 화면 최초 진입 시 빈 조회조건으로 실제 조사사업 목록을 조회한다. */ useEffect(() => { if (initialBusinessesLoadedRef.current) return; initialBusinessesLoadedRef.current = true; - const enabled = new URLSearchParams(window.location.search).get("sample") === "Y"; - setSampleMode(enabled); - if (enabled) { - setBusinesses(sampleBusinesses); - setTotal(sampleBusinesses.length); - return; - } void fetchBusinesses(EMPTY_FILTERS, undefined); // 최초 진입 시점의 빈 조회조건으로 한 번만 실행한다. // eslint-disable-next-line react-hooks/exhaustive-deps @@ -302,11 +261,6 @@ export function ExmnRegistrationPage() { setNotice({ type: "error", text: "다운로드할 사업보고서 첨부파일 정보가 없습니다." }); return; } - if (sampleMode) { - setNotice({ type: "info", text: "샘플 모드에서는 실제 사업보고서 파일을 다운로드하지 않습니다." }); - return; - } - setIsLoading(true); setNotice(null); try { @@ -329,7 +283,6 @@ export function ExmnRegistrationPage() { const openBusinessRegister = () => { setNotice(null); const popup = openWindowPopup("exmnBusinessRegister", { - sample: sampleMode ? "Y" : undefined, sourcePath: window.location.pathname, }); if (!popup) { @@ -346,8 +299,6 @@ export function ExmnRegistrationPage() { setNotice(null); const popup = openWindowPopup("exmnBusinessRegister", { exmnBizId: selectedBusiness.exmnBizId, - sample: sampleMode ? "Y" : undefined, - business: sampleMode ? JSON.stringify(selectedBusiness) : undefined, sourcePath: window.location.pathname, }); if (!popup) { @@ -373,7 +324,6 @@ export function ExmnRegistrationPage() { prcisExmnPrlg: selectedBusiness.prcisExmnPrlg, tpco: selectedBusiness.tpco, reportYn: selectedBusiness.reportYn ?? "N", - sample: sampleMode ? "Y" : undefined, sourcePath: window.location.pathname, }); if (!popup) { @@ -386,20 +336,6 @@ export function ExmnRegistrationPage() { 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(null); - setNotice({ type: "success", text: "샘플 조사사업이 등록되었습니다." }); - } catch { - setNotice({ type: "error", text: "샘플 사업정보를 부모 목록에 반영하지 못했습니다." }); - } - return; - } setSelectedBusinessId(null); void fetchBusinesses(appliedFilters, sort).then((refreshed) => { if (refreshed) setNotice({ type: "success", text: "조사사업이 등록되었습니다." }); @@ -408,20 +344,6 @@ export function ExmnRegistrationPage() { } if (event.data?.type === WINDOW_POPUP_MESSAGE_TYPE.EXMN_BUSINESS_UPDATED) { - const payload = event.data?.payload as { sample?: string; business?: string } | undefined; - if (payload?.sample === "Y" && payload.business) { - try { - const updated = JSON.parse(payload.business) as ExmnBusiness; - setSampleBusinesses((current) => current.map((item) => - item.exmnBizId === updated.exmnBizId ? updated : item)); - setBusinesses((current) => current.map((item) => - item.exmnBizId === updated.exmnBizId ? updated : item)); - setNotice({ type: "success", text: "샘플 조사사업이 수정되었습니다." }); - } catch { - setNotice({ type: "error", text: "수정된 샘플 사업정보를 부모 목록에 반영하지 못했습니다." }); - } - return; - } void fetchBusinesses(appliedFilters, sort).then((refreshed) => { if (refreshed) setNotice({ type: "success", text: "조사사업이 수정되었습니다." }); }); @@ -429,11 +351,6 @@ export function ExmnRegistrationPage() { } 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 집계를 완료했습니다." }); }); @@ -515,15 +432,6 @@ export function ExmnRegistrationPage() {

조사사업 목록 총 {total.toLocaleString("ko-KR")}건

- {process.env.NODE_ENV === "development" && ( - - )}
- +
); } diff --git a/apps/sn/src/features/exmn/ui/ExmnResultRegisterPopupPage.tsx b/apps/sn/src/features/exmn/ui/ExmnResultRegisterPopupPage.tsx index ee33ac8f1..da45702ab 100644 --- a/apps/sn/src/features/exmn/ui/ExmnResultRegisterPopupPage.tsx +++ b/apps/sn/src/features/exmn/ui/ExmnResultRegisterPopupPage.tsx @@ -18,10 +18,6 @@ import { validateExmnFile, validateExmnFiles, } from "../lib/exmn-business-validation"; -import { - EXMN_SAMPLE_IMPORT_VALIDATION, - getExmnSampleBusinessDetail, -} from "../model/exmn-sample-data"; import type { ExmnAttachmentKey, ExmnBusiness, @@ -90,7 +86,6 @@ type FileValidationMessages = Partial>; interface ExmnResultRegisterPopupPageProps { exmnBizId: string; - sampleMode: boolean; businessSummary: ExmnBusiness; } @@ -152,7 +147,6 @@ const uploadAttachment = ( /** 공통 WindowPopup 안에서 조사 파일 5종을 선택·검토·최종 저장하는 화면이다. */ export function ExmnResultRegisterPopupPage({ exmnBizId, - sampleMode, businessSummary, }: ExmnResultRegisterPopupPageProps) { const [detail, setDetail] = useState(null); @@ -190,11 +184,6 @@ export function ExmnResultRegisterPopupPage({ setNotice({ type: "error", text: "결과를 등록할 조사사업 식별자가 없습니다." }); return; } - if (sampleMode) { - setDetail(getExmnSampleBusinessDetail(exmnBizId, businessSummary)); - return; - } - let active = true; setIsLoading(true); getExmnBusiness(exmnBizId) @@ -214,7 +203,7 @@ export function ExmnResultRegisterPopupPage({ return () => { active = false; }; - }, [businessSummary, exmnBizId, sampleMode]); + }, [exmnBizId]); /** 파일 변경 시 이전 검토결과를 폐기하고 확장자·크기 규칙을 즉시 확인한다. */ const handleFileChange = (fileType: ExmnFileType, fileList: FileList | null) => { @@ -269,7 +258,7 @@ export function ExmnResultRegisterPopupPage({ } }; - /** 실제 모드는 서버 검토를 호출하고 샘플 모드는 명시적 정상 검토 결과만 화면에 제공한다. */ + /** 선택한 파일을 서버 검토 API로 전달하고 파일별 결과를 화면에 반영한다. */ const handleValidate = async (fileType: ExmnFileType) => { if (!files[fileType]) { setNotice({ @@ -279,24 +268,6 @@ export function ExmnResultRegisterPopupPage({ return; } - if (sampleMode) { - setValidationResults((current) => ({ - ...current, - [fileType]: EXMN_SAMPLE_IMPORT_VALIDATION, - })); - setFileValidationStatuses((current) => ({ - ...current, - [fileType]: "success", - })); - setFileValidationMessages((current) => { - const next = { ...current }; - delete next[fileType]; - return next; - }); - setNotice(null); - return; - } - const fileMap = Object.fromEntries( Object.entries(files).map(([type, selected]) => [type, selected?.file]), ) as Partial>; @@ -371,14 +342,6 @@ export function ExmnResultRegisterPopupPage({ || fileValidationStatuses.EXMN_EXCEL !== "success" || fileValidationStatuses.PPL_EXMN_PDF !== "success" ) return; - if (sampleMode) { - postPopupMessageAndClose(WINDOW_POPUP_MESSAGE_TYPE.EXMN_RESULT_COMMITTED, { - exmnBizId, - sample: "Y", - }); - return; - } - setIsLoading(true); setNotice(null); try { @@ -386,7 +349,6 @@ export function ExmnResultRegisterPopupPage({ await commitExmnImport(exmnBizId, buildImportFormData(files, excelValidation)); postPopupMessageAndClose(WINDOW_POPUP_MESSAGE_TYPE.EXMN_RESULT_COMMITTED, { exmnBizId, - sample: "N", }); } catch (error) { setNotice({ diff --git a/apps/sn/src/features/exmn/ui/ExmnStatusDetailPanel.tsx b/apps/sn/src/features/exmn/ui/ExmnStatusDetailPanel.tsx index 96a5302b8..9f2c24be3 100644 --- a/apps/sn/src/features/exmn/ui/ExmnStatusDetailPanel.tsx +++ b/apps/sn/src/features/exmn/ui/ExmnStatusDetailPanel.tsx @@ -13,14 +13,16 @@ import { 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 { + downloadExmnSectionSummary, + 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"; @@ -59,12 +61,23 @@ 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 }); +/** 서버가 생성한 다중 시트 XLSX Blob을 브라우저 다운로드로 전달한다. */ +const saveBlob = (blob: Blob, fileName: string) => { + const objectUrl = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = objectUrl; + anchor.download = fileName; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(objectUrl); +}; + const getSummarySortValue = (row: DisplaySummaryRow, key: string) => { const value = row[key]; if (key !== "trtdstNm") { @@ -106,13 +119,14 @@ interface DisplaySummaryRow extends Record { } /** 동일 조사현황 화면에서 선택 사업의 관로·맨홀 처리분구 집계를 펼쳐 표시한다. */ -export function ExmnStatusDetailPanel({ business, sampleMode }: ExmnStatusDetailPanelProps) { +export function ExmnStatusDetailPanel({ business }: ExmnStatusDetailPanelProps) { const [jobType, setJobType] = useState("PPL"); const [summarySort, setSummarySort] = useState(); const [summaryRows, setSummaryRows] = useState([]); /** 상세보기 팝업에 전달할 현재 선택 처리분구다. */ const [selectedTrtdstId, setSelectedTrtdstId] = useState(null); const [isLoading, setIsLoading] = useState(false); + const [isDownloading, setIsDownloading] = useState(false); const [notice, setNotice] = useState(null); const summaryRequestIdRef = useRef(0); @@ -140,10 +154,7 @@ export function ExmnStatusDetailPanel({ business, sampleMode }: ExmnStatusDetail summaryRequestIdRef.current = requestId; setIsLoading(true); setNotice(null); - const request = sampleMode - ? Promise.resolve(getExmnSampleSectionSummaries(business.exmnBizId, jobType)) - : getExmnSectionSummaries(business.exmnBizId, jobType); - request + getExmnSectionSummaries(business.exmnBizId, jobType) .then((rows) => { if (summaryRequestIdRef.current === requestId) setSummaryRows(rows); }) @@ -159,7 +170,7 @@ export function ExmnStatusDetailPanel({ business, sampleMode }: ExmnStatusDetail return () => { if (summaryRequestIdRef.current === requestId) summaryRequestIdRef.current += 1; }; - }, [business, jobType, sampleMode]); + }, [business, jobType]); const summaryTable = useMemo(() => { const getCounts = (row: ExmnSectionSummaryRow) => jobType === "PPL" @@ -230,11 +241,37 @@ export function ExmnStatusDetailPanel({ business, sampleMode }: ExmnStatusDetail )); }; - const showPendingNotice = (feature: "다운로드" | "지도보기") => { - const text = feature === "다운로드" - ? "다운로드는 집계 데이터와 파일 계약 확정 후 연결할 예정입니다." - : "지도보기는 현재 보류 기능이며 버튼 진입점만 표시합니다."; - setNotice({ type: "info", text }); + const showMapPendingNotice = () => { + setNotice({ type: "info", text: "지도보기는 현재 보류 기능이며 버튼 진입점만 표시합니다." }); + }; + + /** 현재 조사유형의 종합집계와 처리분구별 전체 상세 시트를 서버 XLSX로 내려받는다. */ + const handleSummaryDownload = async () => { + if (!business || summaryTable.rows.length === 0 || isDownloading) { + if (summaryTable.rows.length === 0) { + setNotice({ type: "info", text: "다운로드할 조사 상세현황 데이터가 없습니다." }); + } + return; + } + + setIsDownloading(true); + setNotice(null); + try { + const jobTypeLabel = jobType === "PPL" ? "관로조사" : "맨홀조사"; + const fileName = `${business.bizNm}_${jobTypeLabel}_조사상세현황.xlsx` + .replace(/[\\/:*?"<>|]/g, "_"); + const blob = await downloadExmnSectionSummary(business.exmnBizId, jobType); + saveBlob(blob, fileName); + } catch (error) { + setNotice({ + type: "error", + text: error instanceof Error + ? error.message + : "조사 상세현황 다운로드 중 오류가 발생했습니다.", + }); + } finally { + setIsDownloading(false); + } }; /** 선택 사업·처리분구, 조사유형, 결함 범위를 공통 WindowPopup 상세표에 전달한다. */ @@ -258,7 +295,6 @@ export function ExmnStatusDetailPanel({ business, sampleMode }: ExmnStatusDetail prcisExmnPrlg: business.prcisExmnPrlg, tpco: business.tpco, reportYn: business.reportYn, - sample: sampleMode ? "Y" : "N", }); if (!popup) { setNotice({ @@ -323,14 +359,15 @@ export function ExmnStatusDetailPanel({ business, sampleMode }: ExmnStatusDetail > 상세보기 - + diff --git a/apps/sn/src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx b/apps/sn/src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx index 4648b13a7..20dd2a839 100644 --- a/apps/sn/src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx +++ b/apps/sn/src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx @@ -1,12 +1,24 @@ "use client"; +import type { UIEvent } from "react"; +import { useEffect, useMemo, useState } from "react"; + import { downloadExcelWithKoreanHeaders, 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 { + getExmnFacilityDetails, + getExmnPplUnitDetails, + getExmnSectionSummaries, +} from "../api/exmn-business-api"; +import type { + ExmnBusiness, + ExmnJobType, + ExmnSectionSummaryRow, +} from "../model/exmn-business-types"; import { EXMN_DETAIL_SEVERITIES, EXMN_DETAIL_TABLE_DEFINITIONS, @@ -14,13 +26,11 @@ import { type ExmnDetailDefectType, type ExmnDetailSeverity, type ExmnPplDetailMode, + type ExmnPplUnitDetailRow, type ExmnSeverityCounts, type ExmnSurveyDetailRow, } from "../model/exmn-detail-table"; -import { - EXMN_SAMPLE_PPL_UNIT_DETAILS, - getExmnSampleSurveyDetails, -} from "../model/exmn-sample-data"; + import { ExmnPplUnitDetailTable } from "./ExmnPplUnitDetailTable"; const SEVERITY_LABELS: Record = { @@ -51,23 +61,14 @@ const FIXED_COLUMNS = [ { id: "specification", label: "규격", width: 82 }, { id: "extensionM", label: "연장", width: 86 }, { id: "drivingDistanceM", label: "주행\n거리", width: 92 }, + { id: "structuralGrade", label: "구조적\n내부결함", width: 105 }, + { id: "operationalGrade", label: "운영적\n내부결함", width: 105 }, ] 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 PPL_FIXED_COLUMN_COUNT = 9; const STICKY_DIVIDER_SHADOW = "2px 0 0 #b8c5d1"; const SEVERITY_COLUMN_WIDTH = 52; type ExmnDetailFixedColumnId = (typeof FIXED_COLUMNS)[number]["id"]; @@ -78,7 +79,6 @@ interface ExmnSurveyDetailPopupPageProps { trtdstNm: string; jobType: ExmnJobType; defectType: ExmnDetailDefectType; - sampleMode: boolean; } type Notice = { type: "info"; text: string } | null; @@ -145,25 +145,63 @@ const getVisibleAnomalyTotals = ( /** 엑셀 `01. 덕풍`·`01. 덕풍 (2)` 구조를 관로·맨홀 공통 WindowPopup으로 표시한다. */ export function ExmnSurveyDetailPopupPage({ + business, trtdstId, trtdstNm, jobType, defectType, - sampleMode, }: ExmnSurveyDetailPopupPageProps) { + /** 현재 조사유형에 실제 결과가 등록된 처리분구 목록과 팝업 조회 대상이다. */ + const [districtOptions, setDistrictOptions] = useState([]); + const [selectedTrtdstId, setSelectedTrtdstId] = useState(trtdstId); /** 관로 상세보기의 전체 관로 집계와 본단위구간 등급표를 전환하는 표시 모드다. */ const [detailMode, setDetailMode] = useState("PIPE"); /** 관로·맨홀 번호 조회 입력값과 실제 적용값을 분리해 조회 버튼 동작을 재현한다. */ const [detailNumberKeyword, setDetailNumberKeyword] = useState(""); const [appliedDetailNumberKeyword, setAppliedDetailNumberKeyword] = useState(""); + /** 같은 검색어로 다시 조회해도 서버 요청을 새로 실행하기 위한 순번이다. */ + const [searchRequestSeq, setSearchRequestSeq] = useState(0); /** 고정 열 헤더의 오름차순·내림차순·기본 정렬 상태다. */ const [sort, setSort] = useState(); /** 가로 스크롤 위치를 기준으로 요약 라벨과 고정 열 경계선 위치를 함께 보정한다. */ const [tableScrollLeft, setTableScrollLeft] = useState(0); /** 다운로드 미연결 등 현재 팝업 안내 메시지를 표시한다. */ const [notice, setNotice] = useState(null); + /** 실제 DB 상세조회 결과와 요청 상태다. */ + const [actualRows, setActualRows] = useState([]); + const [actualUnitRows, setActualUnitRows] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [loadError, setLoadError] = useState(null); const definition = EXMN_DETAIL_TABLE_DEFINITIONS[jobType]; const defectTypeLabel = DEFECT_TYPE_LABELS[defectType]; + const selectedDistrict = districtOptions.find( + (district) => district.trtdstId === selectedTrtdstId, + ); + const selectedTrtdstNm = selectedDistrict?.trtdstNm + ?? (selectedTrtdstId === trtdstId ? trtdstNm : ""); + /** 관로는 등급을 본단위구간에서 표시하고, 맨홀 전체표만 구조·운영 등급 열을 추가한다. */ + const fixedColumns = useMemo( + () => jobType === "PPL" + ? FIXED_COLUMNS.slice(0, PPL_FIXED_COLUMN_COUNT) + : FIXED_COLUMNS, + [jobType], + ); + const summaryLabelColumnCount = 7; + const fixedInfoScrollThreshold = useMemo( + () => fixedColumns + .slice(STICKY_COLUMN_COUNT, summaryLabelColumnCount) + .reduce((sum, column) => sum + column.width, 0), + [fixedColumns, summaryLabelColumnCount], + ); + const summaryLabelInitialOffset = useMemo(() => { + const stickyColumnsWidth = fixedColumns + .slice(0, STICKY_COLUMN_COUNT) + .reduce((sum, column) => sum + column.width, 0); + const labelColumnsWidth = fixedColumns + .slice(0, summaryLabelColumnCount) + .reduce((sum, column) => sum + column.width, 0); + return (labelColumnsWidth - stickyColumnsWidth) / 2; + }, [fixedColumns, summaryLabelColumnCount]); /** 맨홀은 별도 모드가 없으므로 항상 기존 상세 집계표를 사용한다. */ const isUnitMode = jobType === "PPL" && detailMode === "UNIT"; /** 결함 건수 링크 진입 화면은 조회 조건 없이 선택 분류 결과만 바로 표시한다. */ @@ -175,27 +213,17 @@ export function ExmnSurveyDetailPopupPage({ : definition.defectColumns.filter((column) => column.group === defectType), [defectType, definition], ); - /** 샘플 모드에서 선택 조사유형에 해당하는 상세 원본 행을 제공한다. */ - const sourceRows = useMemo( - () => (sampleMode ? getExmnSampleSurveyDetails(jobType) : []), - [jobType, sampleMode], - ); - /** 본단위구간은 PPL 전용이며 샘플 모드에서 엑셀 기준 단위 기록을 제공한다. */ - const sourceUnitRows = useMemo( - () => (sampleMode && jobType === "PPL" ? EXMN_SAMPLE_PPL_UNIT_DETAILS : []), - [jobType, sampleMode], - ); /** 분류별 상세에서는 선택 결함이 한 건 이상 존재하는 시설 행만 표시한다. */ const scopedRows = useMemo(() => { - if (defectType === "ALL") return sourceRows; - return sourceRows.filter((row) => + if (defectType === "ALL") return actualRows; + return actualRows.filter((row) => visibleDefectColumns.some((column) => EXMN_DETAIL_SEVERITIES.some( (severity) => (row.defectCounts[column.code]?.[severity] ?? 0) > 0, ), ), ); - }, [defectType, sourceRows, visibleDefectColumns]); + }, [actualRows, defectType, visibleDefectColumns]); /** 조회 버튼으로 확정한 번호 조건을 현재 상세 행에 적용한다. */ const filteredRows = useMemo(() => { const normalizedKeyword = appliedDetailNumberKeyword.trim().toLocaleLowerCase("ko-KR"); @@ -208,11 +236,11 @@ export function ExmnSurveyDetailPopupPage({ const unitRows = useMemo(() => { if (!isUnitMode) return []; const normalizedKeyword = appliedDetailNumberKeyword.trim().toLocaleLowerCase("ko-KR"); - if (!normalizedKeyword) return sourceUnitRows; - return sourceUnitRows.filter( + if (!normalizedKeyword) return actualUnitRows; + return actualUnitRows.filter( (row) => row.pipeNo.toLocaleLowerCase("ko-KR").includes(normalizedKeyword), ); - }, [appliedDetailNumberKeyword, isUnitMode, sourceUnitRows]); + }, [actualUnitRows, appliedDetailNumberKeyword, isUnitMode]); /** 보고서번호부터 주행거리까지의 고정 열에 선택 정렬을 적용한다. */ const rows = useMemo(() => { if (!sort) return filteredRows; @@ -247,9 +275,123 @@ export function ExmnSurveyDetailPopupPage({ setNotice(null); }, [defectType, jobType]); - /** 화면 조회 버튼으로 입력 번호를 확정해 샘플 상세 행을 필터링한다. */ + /** 사업·조사유형별 상세현황에 실제 표시되는 처리분구만 조회 조건으로 제공한다. */ + useEffect(() => { + let active = true; + setSelectedTrtdstId(trtdstId); + void getExmnSectionSummaries(business.exmnBizId, jobType) + .then((districts) => { + if (!active) return; + setDistrictOptions(districts); + setSelectedTrtdstId( + districts.some((district) => district.trtdstId === trtdstId) + ? trtdstId + : districts[0]?.trtdstId ?? trtdstId, + ); + }) + .catch(() => { + if (!active) return; + setDistrictOptions([]); + setSelectedTrtdstId(trtdstId); + }); + return () => { + active = false; + }; + }, [business.exmnBizId, jobType, trtdstId]); + + /** 전체 또는 결함 분류별 모드에서 선택 처리분구의 활성 시설과 JSONB 결함 집계를 조회한다. */ + useEffect(() => { + if (isUnitMode) { + setActualRows([]); + setIsLoading(false); + setLoadError(null); + return; + } + let active = true; + setActualRows([]); + setIsLoading(true); + setLoadError(null); + void getExmnFacilityDetails({ + exmnBizId: business.exmnBizId, + jobType, + trtdstId: selectedTrtdstId, + gisManagementNo: appliedDetailNumberKeyword, + defectType, + }) + .then((result) => { + if (active) setActualRows(result); + }) + .catch((error) => { + if (!active) return; + setActualRows([]); + setLoadError( + error instanceof Error + ? error.message + : "조사 상세 집계 조회 중 오류가 발생했습니다.", + ); + }) + .finally(() => { + if (active) setIsLoading(false); + }); + return () => { + active = false; + }; + }, [ + appliedDetailNumberKeyword, + business.exmnBizId, + defectType, + isUnitMode, + jobType, + searchRequestSeq, + selectedTrtdstId, + ]); + + /** 본단위구간 모드에서 선택 처리분구의 PPL_UNIT_V1을 실제 상세행으로 조회한다. */ + useEffect(() => { + if (!isUnitMode || isDefectTypeView) { + setActualUnitRows([]); + return; + } + let active = true; + setActualUnitRows([]); + setIsLoading(true); + setLoadError(null); + void getExmnPplUnitDetails({ + exmnBizId: business.exmnBizId, + trtdstId: selectedTrtdstId, + gisManagementNo: appliedDetailNumberKeyword, + }) + .then((result) => { + if (active) setActualUnitRows(result); + }) + .catch((error) => { + if (!active) return; + setActualUnitRows([]); + setLoadError( + error instanceof Error + ? error.message + : "본단위구간 조회 중 오류가 발생했습니다.", + ); + }) + .finally(() => { + if (active) setIsLoading(false); + }); + return () => { + active = false; + }; + }, [ + appliedDetailNumberKeyword, + business.exmnBizId, + isDefectTypeView, + isUnitMode, + searchRequestSeq, + selectedTrtdstId, + ]); + + /** 화면 조회 버튼으로 입력 번호를 확정해 상세 조회를 다시 실행한다. */ const handleSearch = () => { setAppliedDetailNumberKeyword(detailNumberKeyword.trim()); + setSearchRequestSeq((current) => current + 1); setNotice(null); }; @@ -282,11 +424,12 @@ export function ExmnSurveyDetailPopupPage({ setTableScrollLeft(event.currentTarget.scrollLeft); }; - const showStickyDivider = - tableScrollLeft >= FIXED_INFO_SCROLL_THRESHOLD; + const showStickyDivider = fixedInfoScrollThreshold === 0 + ? tableScrollLeft > 0 + : tableScrollLeft >= fixedInfoScrollThreshold; const summaryLabelOffset = Math.max( 0, - SUMMARY_LABEL_INITIAL_OFFSET - tableScrollLeft, + summaryLabelInitialOffset - tableScrollLeft, ); const totals = useMemo( @@ -294,19 +437,29 @@ export function ExmnSurveyDetailPopupPage({ (sum, row) => ({ extensionM: sum.extensionM + row.extensionM, drivingDistanceM: sum.drivingDistanceM + row.drivingDistanceM, + structuralGrade: sum.structuralGrade + Number(row.structuralGrade || 0), + operationalGrade: sum.operationalGrade + Number(row.operationalGrade || 0), }), { extensionM: 0, drivingDistanceM: 0, + structuralGrade: 0, + operationalGrade: 0, }, ), [rows], ); - /** 고정 열 중 합산 의미가 있는 연장·주행거리 2개 열의 요약 표시값이다. */ + /** 고정 열의 합산값이며 구조·운영 등급 합계는 맨홀 전체표에만 표시한다. */ const fixedSummaryValues = [ { key: "extensionM", value: formatNumber(totals.extensionM) }, { key: "drivingDistanceM", value: formatNumber(totals.drivingDistanceM) }, + ...(jobType === "MNHL" + ? [ + { key: "structuralGrade", value: formatNumber(totals.structuralGrade) }, + { key: "operationalGrade", value: formatNumber(totals.operationalGrade) }, + ] + : []), ]; /** 현재 보이는 구조·운영 결함 열만 기준으로 대·중·소 소계와 항목별 합계를 계산한다. */ @@ -355,7 +508,7 @@ export function ExmnSurveyDetailPopupPage({ /** 현재 화면에 표시된 관로·맨홀 상세 결과를 한글 헤더 엑셀로 내려받는다. */ const handleDownload = async () => { const downloadDate = new Date().toISOString().slice(0, 10); - const safeFilePrefix = `${trtdstNm}_${definition.defectGroupTitle}조사_${ + const safeFilePrefix = `${selectedTrtdstNm}_${definition.defectGroupTitle}조사_${ isUnitMode ? "본단위등급표" : defectTypeLabel }`.replace(/[\\/:*?"<>|]/g, "_"); @@ -416,7 +569,7 @@ export function ExmnSurveyDetailPopupPage({ const detailHeaderMapping: Record = { reportNo: "보고서번호", - pipeNo: jobType === "PPL" ? "관로번호" : "맨홀번호", + pipeNo: "관로번호", upstreamManhole: "상류맨홀", downstreamManhole: "하류맨홀", drainageType: "배수방식", @@ -424,10 +577,18 @@ export function ExmnSurveyDetailPopupPage({ specification: "규격", extensionM: "연장(m)", drivingDistanceM: "주행거리(m)", + }; + if (jobType === "MNHL") { + Object.assign(detailHeaderMapping, { + structuralGrade: "구조적 내부결함", + operationalGrade: "운영적 내부결함", + }); + } + Object.assign(detailHeaderMapping, { anomalyL: "이상항목 합계-대", anomalyM: "이상항목 합계-중", anomalyS: "이상항목 합계-소", - }; + }); visibleDefectColumns.forEach((column) => { EXMN_DETAIL_SEVERITIES.forEach((severity) => { detailHeaderMapping[`${column.code}_${severity}`] = @@ -447,10 +608,18 @@ export function ExmnSurveyDetailPopupPage({ specification: row.specification, extensionM: row.extensionM, drivingDistanceM: row.drivingDistanceM, + }; + if (jobType === "MNHL") { + Object.assign(result, { + structuralGrade: row.structuralGrade, + operationalGrade: row.operationalGrade, + }); + } + Object.assign(result, { anomalyL: anomalyTotals.L, anomalyM: anomalyTotals.M, anomalyS: anomalyTotals.S, - }; + }); visibleDefectColumns.forEach((column) => { EXMN_DETAIL_SEVERITIES.forEach((severity) => { result[`${column.code}_${severity}`] = @@ -469,9 +638,6 @@ export function ExmnSurveyDetailPopupPage({ specification: "", extensionM: totals.extensionM, drivingDistanceM: totals.drivingDistanceM, - anomalyL: defectSummary.anomalySubtotals.L, - anomalyM: defectSummary.anomalySubtotals.M, - anomalyS: defectSummary.anomalySubtotals.S, }; const totalRow: Record = { reportNo: "합계", @@ -483,10 +649,27 @@ export function ExmnSurveyDetailPopupPage({ specification: "", extensionM: totals.extensionM, drivingDistanceM: totals.drivingDistanceM, + }; + if (jobType === "MNHL") { + Object.assign(subtotalRow, { + structuralGrade: totals.structuralGrade, + operationalGrade: totals.operationalGrade, + }); + Object.assign(totalRow, { + structuralGrade: totals.structuralGrade, + operationalGrade: totals.operationalGrade, + }); + } + Object.assign(subtotalRow, { + anomalyL: defectSummary.anomalySubtotals.L, + anomalyM: defectSummary.anomalySubtotals.M, + anomalyS: defectSummary.anomalySubtotals.S, + }); + Object.assign(totalRow, { anomalyL: defectSummary.anomalyTotal, anomalyM: "", anomalyS: "", - }; + }); visibleDefectColumns.forEach((column) => { EXMN_DETAIL_SEVERITIES.forEach((severity) => { subtotalRow[`${column.code}_${severity}`] = @@ -501,12 +684,13 @@ export function ExmnSurveyDetailPopupPage({ const subtotalExcelRow = rows.length + 2; const totalExcelRow = rows.length + 3; + const summaryLabelLastColumn = getExcelColumnName(summaryLabelColumnCount); const mergeRanges = [ - `A${subtotalExcelRow}:G${subtotalExcelRow}`, - `A${totalExcelRow}:G${totalExcelRow}`, + `A${subtotalExcelRow}:${summaryLabelLastColumn}${subtotalExcelRow}`, + `A${totalExcelRow}:${summaryLabelLastColumn}${totalExcelRow}`, ]; for ( - let groupStartColumn = 10; + let groupStartColumn = fixedColumns.length + 1; groupStartColumn < Object.keys(detailHeaderMapping).length; groupStartColumn += EXMN_DETAIL_SEVERITIES.length ) { @@ -539,11 +723,11 @@ export function ExmnSurveyDetailPopupPage({ }; const tableWidth = useMemo( - () => FIXED_COLUMNS.reduce((sum, column) => sum + column.width, 0) + () => fixedColumns.reduce((sum, column) => sum + column.width, 0) + (visibleDefectColumns.length + 1) * EXMN_DETAIL_SEVERITIES.length * SEVERITY_COLUMN_WIDTH, - [visibleDefectColumns.length], + [fixedColumns, visibleDefectColumns.length], ); /** 본단위구간 요약은 조회된 한 관로의 단위 수와 상태·결함 기록 수를 사용한다. */ const unitSectionCount = useMemo( @@ -556,10 +740,10 @@ export function ExmnSurveyDetailPopupPage({ ? definition.title : `${definition.defectGroupTitle}조사 ${defectTypeLabel} 상세현황`; const tableTitle = isUnitMode - ? `${trtdstNm} 관로조사 본단위등급표` + ? `${selectedTrtdstNm} 관로조사 본단위등급표` : defectType === "ALL" - ? `${trtdstNm} ${definition.defectGroupTitle}조사 집계표` - : `${trtdstNm} ${definition.defectGroupTitle}조사 ${defectTypeLabel} 집계표`; + ? `${selectedTrtdstNm} ${definition.defectGroupTitle}조사 집계표` + : `${selectedTrtdstNm} ${definition.defectGroupTitle}조사 ${defectTypeLabel} 집계표`; const visibleDefectGroupTitle = defectType === "ALL" ? definition.defectGroupTitle : `${definition.defectGroupTitle} ${defectTypeLabel}`; @@ -584,10 +768,27 @@ export function ExmnSurveyDetailPopupPage({
처리분구
@@ -666,9 +867,19 @@ export function ExmnSurveyDetailPopupPage({ )} - {!sampleMode && !isDefectTypeView && ( + {isLoading && (
- 실제 상세 데이터 API는 후속 백엔드 연동 단계에서 연결합니다. 화면 샘플은 `sample=Y`에서 확인할 수 있습니다. + {isUnitMode + ? "본단위구간을 조회하고 있습니다." + : isDefectTypeView + ? `${defectTypeLabel} 상세를 조회하고 있습니다.` + : "조사 상세 집계를 조회하고 있습니다."} +
+ )} + + {loadError && ( +
+ {loadError}
)} @@ -726,7 +937,7 @@ export function ExmnSurveyDetailPopupPage({ style={{ width: `${tableWidth}px`, minWidth: `${tableWidth}px` }} >
- {FIXED_COLUMNS.map((column) => ( + {fixedColumns.map((column) => ( ))} {Array.from({ @@ -738,13 +949,10 @@ export function ExmnSurveyDetailPopupPage({ - {FIXED_COLUMNS.map((column, columnIndex) => { + {fixedColumns.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; + const columnLabel = column.label; return ( - -
{row.defectPositionM} + {formatOptionalValue(row.defectPositionM)} + {row.defectName} {row.code} {row.severity ? SEVERITY_LABELS[row.severity] : "-"} {formatOptionalValue(row.occurrencePosition)}{row.infiltrationBonus} + {formatOptionalValue(row.infiltrationBonus)} + {row.continuous == null ? "-" : row.continuous ? "Y" : "N"} {formatOptionalValue(row.structuralScore)} {formatOptionalValue(row.operationalScore)} - {formatOptionalValue(row.surveyStopReason)} - - {formatOptionalValue(row.remark)} - + {formatOptionalValue(row.surveyStopReason)} + + {formatOptionalValue(row.remark)} +
- {FIXED_COLUMNS.map((column, columnIndex) => { + {fixedColumns.map((column, columnIndex) => { const sticky = columnIndex < STICKY_COLUMN_COUNT; return ( Array.isArray(value) ? value[0] ?? "" : value ?? ""; -/** 샘플 수정 팝업에 전달된 사업 JSON을 안전하게 복원한다. */ -const getBusiness = (value: string | string[] | undefined): ExmnBusiness | undefined => { - const text = getParam(value); - if (!text) return undefined; - try { - return JSON.parse(text) as ExmnBusiness; - } catch { - return undefined; - } -}; - /** 조사사업 등록 WindowPopup을 실제 업무 화면에 연결한다. */ export default function ExmnBusinessRegisterPopupContent({ searchParams }: WindowPopupContentProps) { const [isMounted, setIsMounted] = useState(false); @@ -34,9 +22,7 @@ export default function ExmnBusinessRegisterPopupContent({ searchParams }: Windo return ( ); } diff --git a/apps/sn/src/features/windowPopup/ui/ExmnResultRegisterPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/ExmnResultRegisterPopupContent.tsx index bd8014891..22a8e0d51 100644 --- a/apps/sn/src/features/windowPopup/ui/ExmnResultRegisterPopupContent.tsx +++ b/apps/sn/src/features/windowPopup/ui/ExmnResultRegisterPopupContent.tsx @@ -39,7 +39,6 @@ export default function ExmnResultRegisterPopupContent({ searchParams }: WindowP return ( ); diff --git a/apps/sn/src/features/windowPopup/ui/ExmnSurveyDetailPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/ExmnSurveyDetailPopupContent.tsx index 49d3ad46f..b314b6406 100644 --- a/apps/sn/src/features/windowPopup/ui/ExmnSurveyDetailPopupContent.tsx +++ b/apps/sn/src/features/windowPopup/ui/ExmnSurveyDetailPopupContent.tsx @@ -61,7 +61,6 @@ export default function ExmnSurveyDetailPopupContent({ trtdstNm={getParam(searchParams.trtdstNm)} jobType={getJobType(searchParams.jobType)} defectType={getDefectType(searchParams.defectType)} - sampleMode={getParam(searchParams.sample) === "Y"} /> ); }