From 11cb2b90444b59d103389ec9d6be9bb391c4488f Mon Sep 17 00:00:00 2001 From: kiminchan Date: Fri, 24 Jul 2026 13:05:26 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A1=B0=EC=82=AC=EC=82=AC=EC=97=85=20?= =?UTF-8?q?=EA=B4=80=EB=A1=9C=C2=B7=EB=A7=A8=ED=99=80=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=ED=8C=9D=EC=97=85=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../001-qa-SFR-010-004~007-조사사업-통합검증계획.md | 26 +- .../002-qa-SFR-010-004~007-조사사업-통합검증결과.md | 18 + .../001-fe-SFR-010-004~007-조사사업-작업카드.md | 32 +- .../001-fe-SFR-010-004~007-조사사업-통합구현기록.md | 26 +- .../features/exmn/model/exmn-detail-table.ts | 123 ++++ .../features/exmn/model/exmn-sample-data.ts | 84 +++ .../features/exmn/ui/ExmnRegistrationPage.tsx | 16 +- .../exmn/ui/ExmnStatusDetailPanel.tsx | 194 ++++- .../exmn/ui/ExmnSurveyDetailPopupPage.tsx | 690 ++++++++++++++++++ .../windowPopup/model/popupDefinitions.ts | 7 + apps/sn/src/features/windowPopup/registry.tsx | 5 + .../ui/ExmnSurveyDetailPopupContent.tsx | 53 ++ 12 files changed, 1222 insertions(+), 52 deletions(-) create mode 100644 apps/sn/src/features/exmn/model/exmn-detail-table.ts create mode 100644 apps/sn/src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx create mode 100644 apps/sn/src/features/windowPopup/ui/ExmnSurveyDetailPopupContent.tsx 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 0c60db9bd..356f5852b 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 @@ -111,10 +111,10 @@ | 관로 집계 | 관로조사 탭 | 대상연장·주행거리·전체·구조·운영·기타와 합계 표시 | | 맨홀 집계 | 맨홀조사 탭 | 전체·구조·운영·기타와 합계 표시 | | 버튼 상태 | 미선택/선택 상태 | 미선택 시 상세 패널 미표시, 선택 후 세 버튼 표시·미연결 안내 | -| 보류 경계 | 상세보기·지도보기 클릭 | 상세 화면·지도 이동 없이 후속/보류 안내 | +| 1차 보류 경계 | 상세보기·지도보기 클릭 | 1차 집계 검증에서는 상세 화면·지도 이동 없이 후속/보류 안내 | - 원본 PPT의 시각 배치 확인 후 실제 브라우저에서 상·하단 카드 높이와 열 줄바꿈을 사용자 재확인한다. -- 상세보기 실제 화면, 엑셀 파일, 지도 컨텍스트 전달은 이번 1차 완료 기준에서 제외한다. +- 상세보기 실제 화면, 엑셀 파일, 지도 컨텍스트 전달은 이 1차 완료 기준에서 제외했다. 상세보기 검증은 이후 12절에서 수행한다. - 로그인된 사용자 화면에서 목록 전체 셀 선택, 관로·맨홀 전환, 목록·상세 배치와 집계 표시를 확인해 조사관리 상세현황 1차 수동 검증은 완료했다. ## 11. 조사사업 목록·상세현황 최종 회귀 계획 @@ -136,3 +136,25 @@ | 팝업 회귀 | 사업등록·결과등록 WindowPopup 재진입 | 확정된 팝업 크기·배치·버튼·처리분구·파일행 디자인 유지 | - 상세보기·다운로드 실제 동작, 지도 이동, 실 API 성공 응답은 이번 화면 회귀의 완료 범위에 포함하지 않는다. + +## 12. 관로·맨홀 상세보기 팝업 검증 계획 + +| 구분 | 확인 방법 | 기대 결과 | +| --- | --- | --- | +| 진입 조건 | 사업 행 선택 후 관로·맨홀 탭에서 상세보기 | 선택 사업·조사유형의 `exmnSurveyDetail` WindowPopup 표시 | +| 팝업 구조 | 헤더·상단 조건·본문 확인 | 파란 헤더·흰 제목, 별도 사업요약 띠 없음, 조회조건과 버튼이 본문 상단에 표시 | +| 표 구조 | 관로·맨홀 팝업 비교 | 같은 고정 정보 열과 대·중·소 3열 구조, 조사유형별 이상항목 코드·명칭 적용 | +| 스크롤 | 표를 좌우·상하로 이동 | 팝업 문서 전체 스크롤 없음, 표 영역에서만 스크롤 | +| 고정 열 | 표를 오른쪽으로 이동 | 보고서번호·관로/맨홀번호가 고정되고 규격 이후 경계선 유지 | +| 정렬 | 고정 정보 헤더를 3회 클릭 | 보고서번호~운영적 내부결함만 오름차순→내림차순→기본 순환 | +| 조회 | 관로/맨홀번호 입력 후 조회 | 번호 포함 행만 표시되고 빈 조건에서 전체 3행 복원 | +| 소계 | 샘플 3행 값 합산 | 연장·주행거리·내부결함과 코드별 대·중·소 합계 일치 | +| 합계 | 이상항목 합계 행 확인 | 각 항목 대·중·소 3열 병합 후 총합 표시 | +| 요약 라벨 | 가로 스크롤 전·후 비교 | 규격 전에는 병합영역 중앙, 규격 이후에도 고정열 오른쪽에 소계·합계 라벨 표시 | +| 목록 정렬 | 조사사업 `NO` 헤더 3회 클릭 | 정순→역순→기본 순환, 서버에 가상 `rowNumber` 필드 미전달 | +| 상세현황 정렬 | 합계와 일반 처리분구 행 비교 | 합계는 상단 고정, 일반 행만 선택 열 기준 정렬 | +| 액션 | 상세현황 버튼 확인 | 상세보기→지도보기→다운로드 순서, 다운로드 초록색 | +| 보류 기능 | 팝업 다운로드·지도보기 | 미구현 성공 처리 없이 후속/보류 안내 | + +- 사용자 화면 확인을 이번 프론트 상세보기 완료 기준으로 사용한다. +- 실 API 응답, JSONB 코드 매핑, 실제 엑셀 다운로드 파일 검증은 백엔드 구현 후 통합 검증으로 이관한다. 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 583f483e3..6580e124e 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 @@ -171,6 +171,24 @@ - `sample=Y`는 실제 첨부서비스를 호출하지 않고 안내를 표시한다. - 실제 첨부 ID를 사용한 브라우저 다운로드와 PDF 열림 확인은 백엔드·첨부 데이터 준비 후 수행해야 한다. +## 12. 관로·맨홀 상세보기 팝업 검증 결과 + +- 사용자가 조사사업 행 선택과 관로·맨홀 탭 선택 후 `상세보기` WindowPopup 진입을 확인했다. +- 공통 팝업 디자인에 맞춘 파란 헤더·흰색 제목, 본문 상단 조회조건·조회·다운로드 배치와 사업정보 요약 띠 제거를 확인했다. +- 관로·맨홀 화면은 같은 고정 정보 열과 대·중·소 3열 구조를 사용하며 이상항목 내용만 코드 정의에 따라 달라지는 것을 확인했다. +- 상세표 헤더 배경·글꼴을 공통 그리드와 맞추고 팝업 문서 대신 표 영역에서만 가로·세로 스크롤하는 구조를 확인했다. +- 관로/맨홀번호 입력 폭을 축소하고 번호 포함 검색으로 샘플 행을 필터링하는 동작을 확인했다. +- 보고서번호부터 운영적 내부결함까지만 정렬 버튼이 표시되고 `asc → desc → undefined`로 순환하는 소스를 확인했다. +- 소계는 연장·주행거리·구조적·운영적 내부결함과 이상항목별 대·중·소 합산값을 표시한다. +- 합계는 각 이상항목의 대·중·소 3열을 병합하고 총합을 표시한다. +- 소계·합계 라벨을 보고서번호부터 규격까지 가운데 배치하고, 가로 스크롤로 규격이 사라진 뒤에도 고정열 오른쪽에서 라벨과 경계선이 유지되도록 보정했다. +- 조사사업 목록 `NO` 정렬은 가상 필드를 서버에 전달하지 않고 조회 원본 정·역순으로 동작한다. +- 조사 상세현황은 합계 행을 정렬 배열 밖에 유지하고 처리분구 데이터 행만 모든 표시 열에서 정렬한다. +- 상세현황 버튼 순서를 `상세보기 → 지도보기 → 다운로드`로 변경하고 다운로드 버튼에 기존 초록색을 적용했다. +- 사용자가 최종 화면을 확인하고 상세보기 프론트 화면을 완료 판정했다. +- `git diff --check`는 통과했다. 실행 중 개발 서버 보호와 로컬 TypeScript compiler의 샌드박스 `EPERM` 이력 때문에 별도 프로덕션 빌드·타입 검사는 수행하지 않았다. +- 실 상세 API, JSONB 코드 매핑, 실제 엑셀 다운로드와 지도보기는 검증되지 않았으며 후속 범위다. + ## 7. 흡수한 기존 문서 - 없음 diff --git a/apps/sn/docs/작업계획/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-작업카드.md b/apps/sn/docs/작업계획/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-작업카드.md index d85d4ca1f..493f4c80b 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 후속, 지도보기 보류) +- 상태: 화면 1~3단계와 관로·맨홀 상세보기 팝업 완료·커밋 대기(실 API·엑셀 다운로드 후속, 지도보기 보류) - 작성일: 2026-07-22 - 담당 역할: FE - 관련 역할: BE, QA @@ -22,9 +22,9 @@ | 단계 | 범위 | 현재 상태 | 다음 단계 진입 조건 | | --- | --- | --- | --- | | FE-0 | 공통 타입·API, 처리분구관리 `/oper/trtdst` | 완료(사용자 화면·UI 검증 통과) | 충족 | -| FE-1 | `SFR-010-004` 사업등록·파일검토·PPL 집계/상세 | 부분 완료 | 사업등록·결과등록·종합집계 UI 확인 완료, 실제 상세보기·단계 커밋 대기 | +| FE-1 | `SFR-010-004` 사업등록·파일검토·PPL 집계/상세 | 화면 완료 | 사업등록·결과등록·종합집계·관로 상세보기 UI 확인 완료, 실 API·다운로드 후속 | | FE-2 | `SFR-010-005` 조사현황·지도 진입 | 부분 완료 | 같은 화면 조사현황 완료, 지도 진입 보류 | -| FE-3 | `SFR-010-006` MNHL 집계·상세 | 부분 구현 | 맨홀 종합집계 UI 완료, 실제 상세보기 대기 | +| FE-3 | `SFR-010-006` MNHL 집계·상세 | 화면 완료 | 맨홀 종합집계·상세보기 UI 확인 완료, 실 API·다운로드 후속 | | FE-4 | `SFR-010-007` GIS 레이어·등급 필터·상세 | 대기 | FE-3 완료 | ### 3.1 사용자 확인 기준 화면 단계 @@ -121,8 +121,8 @@ - 원본 화면설계서 슬라이드 3·4처럼 기존 사업등록·결과등록 버튼과 사업목록이 있는 화면에서 행 선택 시 바로 아래에 조사 상세현황을 펼친다. 별도 상세 페이지로 이동하지 않는다. - 사용자 표출 명칭은 `CCTV조사` 대신 `관로조사`, `맨홀조사`를 사용하고 서버 구분값은 `PPL`, `MNHL`을 유지한다. - 사업 행 선택 시 기본 `관로조사` 탭을 열고, 탭별 처리분구 합계와 상세 행을 표시한다. -- `상세보기`, `다운로드`, `지도보기`는 이번 단계에서 버튼 진입점과 미연결 안내만 제공한다. -- 상세보기 실제 화면은 사용자의 후속 집계·상세 설명을 받은 뒤 연결하고, 지도보기는 명시적 보류 상태를 유지한다. +- 이 1차 구현에서는 `상세보기`, `다운로드`, `지도보기`를 버튼 진입점과 미연결 안내로 시작했으며, 이후 상세보기는 15절 범위로 완료했다. +- 엑셀 다운로드와 지도보기는 명시적 후속·보류 상태를 유지한다. ## 12. 조사현황·집계 1차 확인 게이트 @@ -133,7 +133,7 @@ - 실제 상세보기·다운로드·지도 페이지가 구현 완료된 것으로 판정하지 않는다. - 사용자 최종 확인 결과 목록 전체 셀 선택, 관로조사·맨홀조사 전환, 목록·상세 배치와 10행 샘플 집계는 통과했다. -- 조사관리 상세현황 화면 단계는 완료하며 `상세보기`, 다운로드, 지도보기와 실 API는 후속 범위로 남긴다. +- 조사관리 상세현황 화면 단계 완료 후 상세보기는 15절에서 추가 완료했다. 다운로드, 지도보기와 실 API는 후속 범위로 남긴다. ## 13. 2026-07-23 git status 기준 실제 변경 범위 @@ -169,3 +169,23 @@ - 임시 조사관리 전용 공통 레이아웃 래퍼·헤더 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, 엑셀 다운로드, 지도보기는 완료 범위가 아니다. 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 d3383fc11..c25bf7349 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 @@ -25,12 +25,13 @@ | 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단계 완료 범위는 관로·맨홀의 처리분구별 종합집계까지다. -- `상세보기` 실제 화면과 다운로드 연동은 다음 프론트 작업이며 `지도보기`는 보류한다. +- 화면 3단계의 관로·맨홀 처리분구별 종합집계와 후속 관로·맨홀 상세보기 팝업까지 완료했다. +- 상세보기는 `sample=Y` 데이터와 화면 동작 기준으로 완료했으며 실 상세 API와 엑셀 다운로드 연동은 다음 프론트·백엔드 통합 작업이다. `지도보기`는 보류한다. - 백엔드 API·서비스·Repository·DB와 실데이터 통합 검증은 아직 시작하지 않았다. ## 4. FE-0 처리분구관리 @@ -202,7 +203,7 @@ - 사용자 화면 명칭은 `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+처리분구` 두 열을 병합한 전용 연한 배경 행으로 표시한다. @@ -241,7 +242,7 @@ - 사용자가 조사사업 목록의 전체 셀 선택, 관로조사·맨홀조사 전환, 상세 집계 10행과 목록·상세 배치를 최종 확인해 조사관리 상세현황 화면을 완료 판정했다. - 이번 완료 범위는 조사사업 행 선택 후 같은 화면 하단에 표시되는 관로·맨홀 종합집계까지다. -- `상세보기` 실제 화면과 다운로드 연동은 후속 설명·계약 확정 후 진행하며, `지도보기`는 기존 결정대로 보류한다. 실 API·백엔드·DB 연동도 별도 단계로 유지한다. +- 당시 후속이었던 `상세보기` 화면은 11절에서 완료했다. 다운로드 연동과 `지도보기`, 실 API·백엔드·DB 연동은 별도 단계로 유지한다. ### 9.13 최종 소스 상태 동기화 @@ -301,3 +302,20 @@ - 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는 변경하지 않았다. 실 상세조회와 엑셀 다운로드 계약은 후속이다. diff --git a/apps/sn/src/features/exmn/model/exmn-detail-table.ts b/apps/sn/src/features/exmn/model/exmn-detail-table.ts new file mode 100644 index 000000000..1e8beed87 --- /dev/null +++ b/apps/sn/src/features/exmn/model/exmn-detail-table.ts @@ -0,0 +1,123 @@ +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 ExmnSeverityCounts = Record; + +/** 화면 열과 추후 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; + structuralInternalDefect: number; + operationalInternalDefect: number; + anomalyTotals: ExmnSeverityCounts; + defectCounts: Record; +} + +/** 선택 조사유형에 따라 팝업 제목과 이상항목 설정을 함께 반환하는 화면 계약이다. */ +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, + }, +}; diff --git a/apps/sn/src/features/exmn/model/exmn-sample-data.ts b/apps/sn/src/features/exmn/model/exmn-sample-data.ts index 0244438ca..5230064dc 100644 --- a/apps/sn/src/features/exmn/model/exmn-sample-data.ts +++ b/apps/sn/src/features/exmn/model/exmn-sample-data.ts @@ -5,6 +5,12 @@ import type { 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 화면 검증에서만 사용하는 조사사업 목록이다. */ @@ -161,3 +167,81 @@ export const getExmnSampleSectionSummaries = ( }; }); }; + +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, + structuralInternalDefect: rowIndex === 1 ? 3 : 1, + operationalInternalDefect: 1, + anomalyTotals, + defectCounts, + }; +}); diff --git a/apps/sn/src/features/exmn/ui/ExmnRegistrationPage.tsx b/apps/sn/src/features/exmn/ui/ExmnRegistrationPage.tsx index e873d76bb..78f76b417 100644 --- a/apps/sn/src/features/exmn/ui/ExmnRegistrationPage.tsx +++ b/apps/sn/src/features/exmn/ui/ExmnRegistrationPage.tsx @@ -39,7 +39,7 @@ const ACTION_BUTTON_CLASS = const BUSINESS_LIST_SCROLL_FETCH_SIZE = 1000; const BUSINESS_TABLE_HEAD = [ - { id: "rowNumber", title: "NO", widthPercent: 5 }, + { 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 }, @@ -78,6 +78,10 @@ const sortBusinessList = ( 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) { @@ -199,12 +203,14 @@ export function ExmnRegistrationPage() { /** 사업 목록의 숫자·행정구역·보고서 여부를 표시 전용 값으로 변환한다. */ const businessRows = useMemo(() => businesses.map((business, index) => ({ ...business, - rowNumber: index + 1, + 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]); + })), [businesses, sort]); /** 화면 조건을 행정구역 이름과 목록 내부 스크롤용 단일 서버 Page 계약으로 변환한다. */ const buildSearchParams = ( @@ -218,7 +224,9 @@ export function ExmnRegistrationPage() { bizNm: filters.bizNm || undefined, page: 0, size: BUSINESS_LIST_SCROLL_FETCH_SIZE, - sort: nextSort ? `${nextSort.key},${nextSort.direction}` : undefined, + sort: nextSort && nextSort.key !== "rowNumber" + ? `${nextSort.key},${nextSort.direction}` + : undefined, }); /** 샘플 원본에 실제 조회조건과 조사사업 목록의 공통 비교 규칙을 적용한다. */ diff --git a/apps/sn/src/features/exmn/ui/ExmnStatusDetailPanel.tsx b/apps/sn/src/features/exmn/ui/ExmnStatusDetailPanel.tsx index 1568594cb..45d874b0e 100644 --- a/apps/sn/src/features/exmn/ui/ExmnStatusDetailPanel.tsx +++ b/apps/sn/src/features/exmn/ui/ExmnStatusDetailPanel.tsx @@ -1,5 +1,6 @@ "use client"; +import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup"; import { Button, Table, @@ -9,6 +10,7 @@ import { 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"; @@ -21,6 +23,8 @@ 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행이 온전히 보인다. @@ -31,23 +35,23 @@ const SUMMARY_GRID_MAX_HEIGHT = `${ }rem`; const PIPE_SUMMARY_TABLE_HEAD = [ - { id: "rowNumber", title: "NO", widthPercent: 6 }, - { id: "trtdstNm", title: "처리분구", widthPercent: 18 }, - { id: "trgtPrlgText", title: "대상연장(m)", widthPercent: 13 }, - { id: "drvngDstncText", title: "주행거리(m)", widthPercent: 13 }, - { id: "wholeCountText", title: "총 이상항목(개소)", widthPercent: 15 }, - { id: "structuralCountText", title: "구조적결함(개소)", widthPercent: 14 }, - { id: "operationalCountText", title: "운영적결함(개소)", widthPercent: 14 }, - { id: "etcCountText", title: "기타", widthPercent: 7 }, + { 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 }, - { id: "trtdstNm", title: "처리분구", widthPercent: 28 }, - { id: "wholeCountText", title: "총 이상항목(개소)", widthPercent: 18 }, - { id: "structuralCountText", title: "구조적결함(개소)", widthPercent: 17 }, - { id: "operationalCountText", title: "운영적결함(개소)", widthPercent: 17 }, - { id: "etcCountText", title: "기타", widthPercent: 12 }, + { 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; @@ -60,6 +64,34 @@ interface ExmnStatusDetailPanelProps { 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 { rowNumber: string | number; trtdstNm: string; @@ -74,6 +106,7 @@ interface DisplaySummaryRow extends Record { /** 동일 조사현황 화면에서 선택 사업의 관로·맨홀 처리분구 집계를 펼쳐 표시한다. */ export function ExmnStatusDetailPanel({ business, sampleMode }: ExmnStatusDetailPanelProps) { const [jobType, setJobType] = useState("PPL"); + const [summarySort, setSummarySort] = useState(); const [summaryRows, setSummaryRows] = useState([]); const [isLoading, setIsLoading] = useState(false); const [notice, setNotice] = useState(null); @@ -81,9 +114,14 @@ export function ExmnStatusDetailPanel({ business, sampleMode }: ExmnStatusDetail useEffect(() => { setJobType("PPL"); + setSummarySort(undefined); setNotice(null); }, [business?.exmnBizId]); + useEffect(() => { + setSummarySort(undefined); + }, [jobType]); + useEffect(() => { if (!business) { summaryRequestIdRef.current += 1; @@ -156,7 +194,7 @@ export function ExmnStatusDetailPanel({ business, sampleMode }: ExmnStatusDetail }; }); return { - rows, + rows: sortSummaryRows(rows, summarySort), totals: { trgtPrlgText: formatNumber(totals.trgtPrlg), drvngDstncText: formatNumber(totals.drvngDstnc), @@ -166,19 +204,54 @@ export function ExmnStatusDetailPanel({ business, sampleMode }: ExmnStatusDetail etcCountText: formatNumber(totals.etc), } as Record, }; - }, [jobType, summaryRows]); + }, [jobType, summaryRows, summarySort]); const summaryColumns = jobType === "PPL" ? PIPE_SUMMARY_TABLE_HEAD : MANHOLE_SUMMARY_TABLE_HEAD; - const showPendingNotice = (feature: "상세보기" | "다운로드" | "지도보기") => { - const text = feature === "상세보기" - ? "상세보기는 조사 집계 화면 확정 후 연결할 예정입니다." - : feature === "다운로드" - ? "다운로드는 집계 데이터와 파일 계약 확정 후 연결할 예정입니다." - : "지도보기는 현재 보류 기능이며 버튼 진입점만 표시합니다."; + /** 상세현황 헤더를 오름차순·내림차순·기본 순서로 순환한다. */ + 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 = () => { + if (!business) return; + const popup = openWindowPopup("exmnSurveyDetail", { + exmnBizId: business.exmnBizId, + jobType, + 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: "상세보기 팝업을 열 수 없습니다. 브라우저의 팝업 허용 상태를 확인해주세요.", + }); + } + }; + /** 보이는 버튼 전체에서 조사유형을 즉시 바꾸고, 상세 조회는 별도 effect에서 수행한다. */ const handleJobTypeChange = (nextJobType: ExmnJobType) => { if (nextJobType === jobType) return; @@ -219,9 +292,16 @@ export function ExmnStatusDetailPanel({ business, sampleMode }: ExmnStatusDetail ))}
- - + +
@@ -254,18 +334,60 @@ export function ExmnStatusDetailPanel({ business, sampleMode }: ExmnStatusDetail style={{ position: "static", zIndex: "auto" }} > - {summaryColumns.map((column) => ( - - {column.title} - - ))} + {summaryColumns.map((column) => { + const direction = summarySort?.key === column.id + ? summarySort.direction + : undefined; + return ( + + + + ); + })} diff --git a/apps/sn/src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx b/apps/sn/src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx new file mode 100644 index 000000000..0b93a3aaf --- /dev/null +++ b/apps/sn/src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx @@ -0,0 +1,690 @@ +"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 ExmnDetailSeverity, + type ExmnSurveyDetailRow, +} from "../model/exmn-detail-table"; +import { getExmnSampleSurveyDetails } from "../model/exmn-sample-data"; + +const SEVERITY_LABELS: Record = { + L: "대", + M: "중", + S: "소", +}; + +/** 상세보기 상단 조회 조건의 조사대상 표시 문구다. */ +const DETAIL_SCOPE_LABELS: Record = { + PPL: "관로 / 본단위구간", + MNHL: "맨홀", +}; + +/** 백엔드 처리분구 목록 연동 전 화면 검증에 사용하는 처리분구 선택값이다. */ +const SAMPLE_DISTRICT_NAME = "덕풍"; + +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 }, + { id: "structuralInternalDefect", label: "구조적\n내부결함", width: 105 }, + { id: "operationalInternalDefect", 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 STICKY_DIVIDER_SHADOW = "2px 0 0 #b8c5d1"; +const SEVERITY_COLUMN_WIDTH = 52; +type ExmnDetailFixedColumnId = (typeof FIXED_COLUMNS)[number]["id"]; + +interface ExmnSurveyDetailPopupPageProps { + business: ExmnBusiness; + jobType: ExmnJobType; + 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", + }); +}; + +/** 엑셀 `01. 덕풍`·`01. 덕풍 (2)` 구조를 관로·맨홀 공통 WindowPopup으로 표시한다. */ +export function ExmnSurveyDetailPopupPage({ + jobType, + sampleMode, +}: ExmnSurveyDetailPopupPageProps) { + /** 관로·맨홀 번호 조회 입력값과 실제 적용값을 분리해 조회 버튼 동작을 재현한다. */ + const [detailNumberKeyword, setDetailNumberKeyword] = useState(""); + const [appliedDetailNumberKeyword, setAppliedDetailNumberKeyword] = useState(""); + /** 고정 열 헤더의 오름차순·내림차순·기본 정렬 상태다. */ + const [sort, setSort] = useState(); + /** 가로 스크롤 위치를 기준으로 요약 라벨과 고정 열 경계선 위치를 함께 보정한다. */ + const [tableScrollLeft, setTableScrollLeft] = useState(0); + /** 다운로드 미연결 등 현재 팝업 안내 메시지를 표시한다. */ + const [notice, setNotice] = useState(null); + const definition = EXMN_DETAIL_TABLE_DEFINITIONS[jobType]; + /** 샘플 모드에서 선택 조사유형에 해당하는 상세 원본 행을 제공한다. */ + const sourceRows = useMemo( + () => (sampleMode ? getExmnSampleSurveyDetails(jobType) : []), + [jobType, sampleMode], + ); + /** 조회 버튼으로 확정한 번호 조건을 현재 상세 행에 적용한다. */ + const filteredRows = useMemo(() => { + const normalizedKeyword = appliedDetailNumberKeyword.trim().toLocaleLowerCase("ko-KR"); + if (!normalizedKeyword) return sourceRows; + return sourceRows.filter((row) => + row.pipeNo.toLocaleLowerCase("ko-KR").includes(normalizedKeyword), + ); + }, [appliedDetailNumberKeyword, sourceRows]); + /** 보고서번호부터 운영적 내부결함까지의 고정 열에 선택 정렬을 적용한다. */ + 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); + }, [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) => { + 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, + structuralInternalDefect: + sum.structuralInternalDefect + row.structuralInternalDefect, + operationalInternalDefect: + sum.operationalInternalDefect + row.operationalInternalDefect, + }), + { + extensionM: 0, + drivingDistanceM: 0, + structuralInternalDefect: 0, + operationalInternalDefect: 0, + }, + ), + [rows], + ); + + /** 고정 열 중 합산 의미가 있는 연장·주행거리·내부결함 4개 열의 요약 표시값이다. */ + const fixedSummaryValues = [ + { key: "extensionM", value: formatNumber(totals.extensionM) }, + { key: "drivingDistanceM", value: formatNumber(totals.drivingDistanceM) }, + { + key: "structuralInternalDefect", + value: formatCount(totals.structuralInternalDefect), + }, + { + key: "operationalInternalDefect", + value: formatCount(totals.operationalInternalDefect), + }, + ]; + + /** 현재 조회 결과를 기준으로 관로·맨홀 이상항목의 대·중·소 소계와 항목별 합계를 계산한다. */ + const defectSummary = useMemo(() => { + const anomalySubtotals = { L: 0, M: 0, S: 0 }; + const defectSubtotals = Object.fromEntries( + definition.defectColumns.map((column) => [ + column.code, + { L: 0, M: 0, S: 0 }, + ]), + ) as Record>; + + rows.forEach((row) => { + EXMN_DETAIL_SEVERITIES.forEach((severity) => { + anomalySubtotals[severity] += row.anomalyTotals[severity] ?? 0; + }); + definition.defectColumns.forEach((column) => { + const columnCounts = defectSubtotals[column.code]; + if (!columnCounts) return; + EXMN_DETAIL_SEVERITIES.forEach((severity) => { + columnCounts[severity] += + row.defectCounts[column.code]?.[severity] ?? 0; + }); + }); + }); + + const sumSeverities = (counts: Record) => + EXMN_DETAIL_SEVERITIES.reduce( + (sum, severity) => sum + counts[severity], + 0, + ); + + return { + anomalySubtotals, + anomalyTotal: sumSeverities(anomalySubtotals), + defectSubtotals, + defectTotals: Object.fromEntries( + definition.defectColumns.map((column) => [ + column.code, + sumSeverities( + defectSubtotals[column.code] ?? { L: 0, M: 0, S: 0 }, + ), + ]), + ) as Record, + }; + }, [definition.defectColumns, rows]); + + const tableWidth = useMemo( + () => FIXED_COLUMNS.reduce((sum, column) => sum + column.width, 0) + + (definition.defectColumns.length + 1) + * EXMN_DETAIL_SEVERITIES.length + * SEVERITY_COLUMN_WIDTH, + [definition.defectColumns.length], + ); + + return ( +
+
+

{definition.title}

+
+ + {notice && ( +
+ {notice.text} +
+ )} + +
+
+ + + + + + +
+ + +
+
+ + {!sampleMode && ( +
+ 실제 상세 데이터 API는 후속 백엔드 연동 단계에서 연결합니다. 화면 샘플은 `sample=Y`에서 확인할 수 있습니다. +
+ )} + +
+
+

+ {SAMPLE_DISTRICT_NAME} 처리분구 {definition.defectGroupTitle}조사 집계표 +

+ + 조회 결과 총 {rows.length.toLocaleString("ko-KR")}건 + +
+
+ 연장거리 합계{formatNumber(totals.extensionM)}m + 주행거리 합계{formatNumber(totals.drivingDistanceM)}m +
+
+ +
+ + + {FIXED_COLUMNS.map((column) => ( + + ))} + {Array.from({ + length: (definition.defectColumns.length + 1) + * EXMN_DETAIL_SEVERITIES.length, + }).map((_, index) => ( + + ))} + + + + {FIXED_COLUMNS.map((column, columnIndex) => { + const sticky = columnIndex < STICKY_COLUMN_COUNT; + const direction = sort?.key === column.id ? sort.direction : undefined; + return ( + + ); + })} + + + + + + + + {EXMN_DETAIL_SEVERITIES.map((severity) => ( + + ))} + {definition.defectColumns.map((column) => ( + + ))} + + + {definition.defectColumns.flatMap((column) => + EXMN_DETAIL_SEVERITIES.map((severity) => ( + + )), + )} + + + + {rows.length > 0 ? ( + <> + {rows.map((row) => ( + + {FIXED_COLUMNS.map((column, columnIndex) => { + const sticky = columnIndex < STICKY_COLUMN_COUNT; + return ( + + ); + })} + {EXMN_DETAIL_SEVERITIES.map((severity) => ( + + ))} + {definition.defectColumns.flatMap((column) => + EXMN_DETAIL_SEVERITIES.map((severity) => ( + + )), + )} + + ))} + + + + ))} + {EXMN_DETAIL_SEVERITIES.map((severity) => ( + + ))} + {definition.defectColumns.flatMap((column) => + EXMN_DETAIL_SEVERITIES.map((severity) => ( + + )), + )} + + + + + ))} + + {definition.defectColumns.map((column) => ( + + ))} + + + ) : ( + + + + )} + +
+ + + 이상항목 +
+ 합계 + + {definition.defectGroupTitle} +
+ {SEVERITY_LABELS[severity]} + + {column.label} +
+ {SEVERITY_LABELS[severity]} +
+ {getFixedCellValue(row, column.id)} + + {formatCount(row.anomalyTotals[severity])} + + {formatCount(row.defectCounts[column.code]?.[severity])} +
+ + 소계 + + + {summary.value} + + {formatCount(defectSummary.anomalySubtotals[severity])} + + {formatCount( + defectSummary.defectSubtotals[column.code]?.[severity], + )} +
+ + 합계 + + + {summary.value} + + {formatCount(defectSummary.anomalyTotal)} + + {formatCount(defectSummary.defectTotals[column.code])} +
+ 조회된 상세 결과가 없습니다. +
+
+
+
+ ); +} diff --git a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts index 2dd0cb5cd..181cb2569 100644 --- a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts +++ b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts @@ -14,6 +14,13 @@ export const windowPopupDefinitions = { height: 340, requiredParams: ["exmnBizId"], }, + exmnSurveyDetail: { + key: "exmnSurveyDetail", + title: "조사 상세현황", + width: 1500, + height: 720, + requiredParams: ["exmnBizId", "jobType"], + }, oldPpUpload: { key: "oldPpUpload", title: "노후 하수관로 정비사업 등록", diff --git a/apps/sn/src/features/windowPopup/registry.tsx b/apps/sn/src/features/windowPopup/registry.tsx index fdfa4eb19..dde8d802a 100644 --- a/apps/sn/src/features/windowPopup/registry.tsx +++ b/apps/sn/src/features/windowPopup/registry.tsx @@ -3,6 +3,7 @@ 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"; @@ -44,6 +45,10 @@ export const windowPopupRegistry = { ...windowPopupDefinitions.exmnResultRegister, component: ExmnResultRegisterPopupContent, }, + exmnSurveyDetail: { + ...windowPopupDefinitions.exmnSurveyDetail, + component: ExmnSurveyDetailPopupContent, + }, oldPpUpload: { ...windowPopupDefinitions.oldPpUpload, component: OldPpUploadPopupContent, diff --git a/apps/sn/src/features/windowPopup/ui/ExmnSurveyDetailPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/ExmnSurveyDetailPopupContent.tsx new file mode 100644 index 000000000..c1494ebe4 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/ExmnSurveyDetailPopupContent.tsx @@ -0,0 +1,53 @@ +"use client"; + +import type { ExmnBusiness, ExmnJobType } from "@/features/exmn/model/exmn-business-types"; +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 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
; + } + + return ( + + ); +}