조사사업 관로·맨홀 상세보기 팝업 구현

This commit is contained in:
kiminchan
2026-07-24 13:05:26 +09:00
parent c1d6cd7b8e
commit 11cb2b9044
12 changed files with 1222 additions and 52 deletions
@@ -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 코드 매핑, 실제 엑셀 다운로드 파일 검증은 백엔드 구현 후 통합 검증으로 이관한다.
@@ -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. 흡수한 기존 문서
- 없음
@@ -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, 엑셀 다운로드, 지도보기는 완료 범위가 아니다.
@@ -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는 변경하지 않았다. 실 상세조회와 엑셀 다운로드 계약은 후속이다.
@@ -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<ExmnDetailSeverity, number>;
/** 화면 열과 추후 JSONB 코드 매핑이 같은 식별자를 사용하도록 하는 이상항목 정의다. */
export interface ExmnDetailDefectColumn {
code: string;
label: string;
group: ExmnDefectGroup;
}
/** 관로·맨홀 상세표의 공통 고정 열과 코드별 이상항목 건수다. */
export interface ExmnSurveyDetailRow {
reportNo: string;
pipeNo: string;
upstreamManhole: string;
downstreamManhole: string;
drainageType: string;
pipeType: string;
specification: number;
extensionM: number;
drivingDistanceM: number;
structuralInternalDefect: number;
operationalInternalDefect: number;
anomalyTotals: ExmnSeverityCounts;
defectCounts: Record<string, ExmnSeverityCounts>;
}
/** 선택 조사유형에 따라 팝업 제목과 이상항목 설정을 함께 반환하는 화면 계약이다. */
export interface ExmnDetailTableDefinition {
jobType: ExmnJobType;
title: string;
defectGroupTitle: string;
defectColumns: ExmnDetailDefectColumn[];
}
const structural = (
code: string,
label: string,
): ExmnDetailDefectColumn => ({ code, label, group: "STRUCTURAL" });
const operational = (
code: string,
label: string,
): ExmnDetailDefectColumn => ({ code, label, group: "OPERATIONAL" });
/** `01. 덕풍` 시트의 관로 이상항목 순서를 코드 식별자와 함께 유지한다. */
export const PIPE_DETAIL_DEFECT_COLUMNS: ExmnDetailDefectColumn[] = [
structural("CL", "균열길이"),
structural("CM", "균열복합"),
structural("CC", "균열원주"),
operational("DE", "내피생성"),
structural("LD", "라이닝결함"),
structural("DF", "변형"),
structural("CX", "붕괴"),
operational("RI", "뿌리침입"),
structural("NS", "역경사"),
structural("LP", "연결관돌출"),
structural("LS", "연결관접합부"),
structural("PO", "영구장애물"),
structural("JD", "이음부단차"),
structural("JF", "이음부손상"),
structural("JS", "이음부이탈"),
operational("TO", "임시장애물"),
structural("BC", "좌굴"),
structural("HL", "천공"),
operational("IF", "침입수"),
structural("SG", "침하"),
operational("DS", "토사퇴적"),
structural("BK", "파손"),
operational("DG", "폐유부착"),
structural("SD", "표면손상"),
];
/** 사용자 제공 코드 체계와 `01. 덕풍 (2)` 시트 순서를 결합한 맨홀 이상항목이다. */
export const MANHOLE_DETAIL_DEFECT_COLUMNS: ExmnDetailDefectColumn[] = [
structural("CHm", "균열-수평"),
structural("CVm", "균열-수직"),
structural("SIm", "표면손상-내부"),
structural("SOm", "표면손상-외부"),
structural("Dm", "변형(연성계열)"),
structural("BIm", "파손-내부"),
structural("BOm", "파손-외부"),
structural("LPm", "하수관로 접속부-돌출"),
structural("LSm", "하수관로 접속부-이상"),
structural("JDm", "블록 이음부-단차"),
structural("JFm", "블록 이음부-손상"),
structural("JSm", "블록 이음부-이탈"),
structural("SGm", "표면단차"),
structural("DBm", "인버트 결함"),
structural("DCm", "맨홀뚜껑/프레임 손상"),
operational("OAm", "악취 발생"),
operational("DEm", "내피생성"),
operational("DGm", "폐유부착"),
operational("TOm", "임시장애물"),
operational("DSm", "사다리손상"),
operational("SCm", "뚜껑밀폐"),
operational("RIm", "뿌리침입"),
operational("IFm", "침입수"),
];
export const EXMN_DETAIL_TABLE_DEFINITIONS: Record<
ExmnJobType,
ExmnDetailTableDefinition
> = {
PPL: {
jobType: "PPL",
title: "관로조사 상세현황",
defectGroupTitle: "관로",
defectColumns: PIPE_DETAIL_DEFECT_COLUMNS,
},
MNHL: {
jobType: "MNHL",
title: "맨홀조사 상세현황",
defectGroupTitle: "맨홀",
defectColumns: MANHOLE_DETAIL_DEFECT_COLUMNS,
},
};
@@ -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<string, ExmnSeverityCounts> => Object.fromEntries(
EXMN_DETAIL_TABLE_DEFINITIONS[jobType].defectColumns.map((column, columnIndex) => {
const counts = { ...EMPTY_SEVERITY_COUNTS };
EXMN_DETAIL_SEVERITIES.forEach((severity, severityIndex) => {
const seed = columnIndex + rowIndex * 3 + severityIndex;
if (seed % 11 === 0) {
counts[severity] = rowIndex + severityIndex + 1;
}
});
return [column.code, counts];
}),
);
/** 코드별 건수를 합산해 엑셀의 이상항목 합계 대·중·소 샘플을 만든다. */
const sumSampleDefects = (
defectCounts: Record<string, ExmnSeverityCounts>,
): ExmnSeverityCounts => EXMN_DETAIL_SEVERITIES.reduce(
(totals, severity) => {
totals[severity] = Object.values(defectCounts).reduce(
(sum, counts) => sum + counts[severity],
0,
);
return totals;
},
{ ...EMPTY_SEVERITY_COUNTS },
);
const SAMPLE_FACILITIES = [
{
reportNo: "118-0026",
pipeNo: "118-0026",
upstreamManhole: "118-0026M",
downstreamManhole: "118-0027M",
extensionM: 25.5,
drivingDistanceM: 25.5,
},
{
reportNo: "118-0027",
pipeNo: "118-0027",
upstreamManhole: "118-0027M",
downstreamManhole: "118-0028M",
extensionM: 27,
drivingDistanceM: 27,
},
{
reportNo: "118-0027-1",
pipeNo: "118-0027-1",
upstreamManhole: "118-0027-1M",
downstreamManhole: "118-0028M",
extensionM: 13.9,
drivingDistanceM: 13.9,
},
] as const;
/** 상세보기 WindowPopup에서 관로·맨홀 엑셀형 표를 확인하는 샘플 3행이다. */
export const getExmnSampleSurveyDetails = (
jobType: ExmnJobType,
): ExmnSurveyDetailRow[] => SAMPLE_FACILITIES.map((facility, rowIndex) => {
const defectCounts = buildSampleDefectCounts(jobType, rowIndex);
const anomalyTotals = sumSampleDefects(defectCounts);
return {
...facility,
drainageType: "합류",
pipeType: "HP",
specification: 600,
structuralInternalDefect: rowIndex === 1 ? 3 : 1,
operationalInternalDefect: 1,
anomalyTotals,
defectCounts,
};
});
@@ -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,
});
/** 샘플 원본에 실제 조회조건과 조사사업 목록의 공통 비교 규칙을 적용한다. */
@@ -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<string, string | number> {
rowNumber: string | number;
trtdstNm: string;
@@ -74,6 +106,7 @@ interface DisplaySummaryRow extends Record<string, string | number> {
/** 동일 조사현황 화면에서 선택 사업의 관로·맨홀 처리분구 집계를 펼쳐 표시한다. */
export function ExmnStatusDetailPanel({ business, sampleMode }: ExmnStatusDetailPanelProps) {
const [jobType, setJobType] = useState<ExmnJobType>("PPL");
const [summarySort, setSummarySort] = useState<UiTableSort>();
const [summaryRows, setSummaryRows] = useState<ExmnSectionSummaryRow[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [notice, setNotice] = useState<Notice>(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<string, string>,
};
}, [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
))}
</div>
<div className="ml-auto flex items-center gap-2">
<Button className={ACTION_BUTTON_CLASS} size="sm" onClick={() => showPendingNotice("상세보기")}>상세보기</Button>
<Button className={ACTION_BUTTON_CLASS} size="sm" onClick={() => showPendingNotice("다운로드")}>다운로드</Button>
<Button className={ACTION_BUTTON_CLASS} size="sm" onClick={openSurveyDetailPopup}>상세보기</Button>
<Button className={ACTION_BUTTON_CLASS} size="sm" onClick={() => showPendingNotice("지도보기")}>지도보기</Button>
<Button
color="green"
className={DOWNLOAD_BUTTON_CLASS}
size="sm"
onClick={() => showPendingNotice("다운로드")}
>
다운로드
</Button>
</div>
</div>
@@ -254,18 +334,60 @@ export function ExmnStatusDetailPanel({ business, sampleMode }: ExmnStatusDetail
style={{ position: "static", zIndex: "auto" }}
>
<TableRow style={{ height: `${SUMMARY_GRID_ROW_HEIGHT_REM}rem` }}>
{summaryColumns.map((column) => (
<TableHead
key={column.id}
className="sticky top-0 z-20 bg-[#eaf3fb] text-center !py-[0.8rem]"
style={{
borderTopWidth: 0,
boxShadow: "inset 0 1px 0 var(--border)",
}}
>
{column.title}
</TableHead>
))}
{summaryColumns.map((column) => {
const direction = summarySort?.key === column.id
? summarySort.direction
: undefined;
return (
<TableHead
key={column.id}
className="sticky top-0 z-20 bg-[#eaf3fb] text-center !py-[0.8rem]"
style={{
borderTopWidth: 0,
boxShadow: "inset 0 1px 0 var(--border)",
}}
aria-sort={
direction === "asc"
? "ascending"
: direction === "desc"
? "descending"
: "none"
}
>
<button
type="button"
className="inline-flex w-full items-center justify-center gap-1.5"
aria-label={`${column.title} 정렬`}
onClick={() => handleSummarySort(column)}
>
<span>{column.title}</span>
{direction === "asc" ? (
<span
className="inline-flex min-w-2 justify-center text-[8px] leading-none text-[#1d4ed8]"
aria-hidden="true"
>
▲
</span>
) : direction === "desc" ? (
<span
className="inline-flex min-w-2 justify-center text-[8px] leading-none text-[#1d4ed8]"
aria-hidden="true"
>
▼
</span>
) : (
<span
className="inline-flex flex-col items-center gap-px text-[6px] leading-none text-[#475569]"
aria-hidden="true"
>
<span>▲</span>
<span>▼</span>
</span>
)}
</button>
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody className="text-center">
@@ -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<ExmnDetailSeverity, string> = {
L: "대",
M: "중",
S: "소",
};
/** 상세보기 상단 조회 조건의 조사대상 표시 문구다. */
const DETAIL_SCOPE_LABELS: Record<ExmnJobType, string> = {
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<UiTableSort>();
/** 가로 스크롤 위치를 기준으로 요약 라벨과 고정 열 경계선 위치를 함께 보정한다. */
const [tableScrollLeft, setTableScrollLeft] = useState(0);
/** 다운로드 미연결 등 현재 팝업 안내 메시지를 표시한다. */
const [notice, setNotice] = useState<Notice>(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<HTMLDivElement>) => {
setTableScrollLeft(event.currentTarget.scrollLeft);
};
const showStickyDivider =
tableScrollLeft >= FIXED_INFO_SCROLL_THRESHOLD;
const summaryLabelOffset = Math.max(
0,
SUMMARY_LABEL_INITIAL_OFFSET - tableScrollLeft,
);
/** 실제 엑셀 다운로드 API 연동 전 사용자에게 후속 연결 상태를 안내한다. */
const handleDownload = () => {
setNotice({
type: "info",
text: "엑셀 다운로드는 실제 상세 API 연동 단계에서 연결할 예정입니다.",
});
};
const totals = useMemo(
() => rows.reduce(
(sum, row) => ({
extensionM: sum.extensionM + row.extensionM,
drivingDistanceM: sum.drivingDistanceM + row.drivingDistanceM,
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<string, Record<ExmnDetailSeverity, number>>;
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<ExmnDetailSeverity, number>) =>
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<string, number>,
};
}, [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 (
<main className="sn-popup-page">
<header className="sn-popup-header">
<h1 className="sn-popup-title">{definition.title}</h1>
</header>
{notice && (
<div role="status" className="sn-popup-success-bar shrink-0 text-primary">
{notice.text}
</div>
)}
<section className="sn-popup-section overflow-y-hidden">
<div
className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:items-end"
style={{ gridTemplateColumns: "180px 220px 300px minmax(0, 1fr)" }}
>
<label className="sn-popup-field">
<div className="sn-popup-field-label">처리분구</div>
<select
className="sn-popup-input !text-[1.1rem]"
defaultValue={SAMPLE_DISTRICT_NAME}
aria-label="처리분구"
>
<option value={SAMPLE_DISTRICT_NAME}>{SAMPLE_DISTRICT_NAME}</option>
</select>
</label>
<label className="sn-popup-field">
<div className="sn-popup-field-label">조사대상</div>
<select
className="sn-popup-input !text-[1.1rem]"
defaultValue={jobType}
aria-label="조사대상"
>
<option value={jobType}>{DETAIL_SCOPE_LABELS[jobType]}</option>
</select>
</label>
<label className="sn-popup-field">
<div className="sn-popup-field-label">
{jobType === "PPL" ? "관로번호" : "맨홀번호"}
</div>
<Input
className="sn-popup-input !text-[1.1rem]"
value={detailNumberKeyword}
placeholder={`${jobType === "PPL" ? "관로" : "맨홀"}번호 입력`}
onChange={(event) => setDetailNumberKeyword(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") handleSearch();
}}
/>
</label>
<div className="sn-popup-actions">
<Button
type="button"
size="sm"
color="primary"
className="sn-popup-primary-button !text-[1.1rem]"
onClick={handleSearch}
>
조회
</Button>
<Button
type="button"
size="sm"
color="green"
className="sn-popup-primary-button !text-[1.1rem]"
onClick={handleDownload}
>
다운로드
</Button>
</div>
</div>
{!sampleMode && (
<div className="shrink-0 border-b border-border bg-secondary px-4 py-2 text-[1.05rem] text-primary">
실제 상세 데이터 API는 후속 백엔드 연동 단계에서 연결합니다. 화면 샘플은 `sample=Y`에서 확인할 수 있습니다.
</div>
)}
<div className="flex shrink-0 items-center justify-between gap-4 py-3">
<div className="flex min-w-0 items-baseline gap-3">
<h2 className="shrink-0 text-[1.25rem] font-semibold text-title">
{SAMPLE_DISTRICT_NAME} 처리분구 {definition.defectGroupTitle}조사 집계표
</h2>
<span className="truncate text-[1.05rem] text-label">
조회 결과 총 {rows.length.toLocaleString("ko-KR")}건
</span>
</div>
<div className="flex shrink-0 items-center gap-7 whitespace-nowrap text-[1.05rem] text-label">
<span><strong className="mr-2 text-foreground">연장거리 합계</strong>{formatNumber(totals.extensionM)}m</span>
<span><strong className="mr-2 text-foreground">주행거리 합계</strong>{formatNumber(totals.drivingDistanceM)}m</span>
</div>
</div>
<div
className="min-h-0 flex-1 overflow-auto border border-border bg-background"
onScroll={handleTableScroll}
>
<table
className="border-separate border-spacing-0 text-center text-[1.05rem] text-foreground"
style={{ width: `${tableWidth}px`, minWidth: `${tableWidth}px` }}
>
<colgroup>
{FIXED_COLUMNS.map((column) => (
<col key={column.id} style={{ width: `${column.width}px` }} />
))}
{Array.from({
length: (definition.defectColumns.length + 1)
* EXMN_DETAIL_SEVERITIES.length,
}).map((_, index) => (
<col key={`severity-col-${index}`} style={{ width: `${SEVERITY_COLUMN_WIDTH}px` }} />
))}
</colgroup>
<thead className="sticky top-0 z-30 bg-[#eaf3fb] text-[1.3rem] font-medium leading-[1.2] text-[#647784]">
<tr>
{FIXED_COLUMNS.map((column, columnIndex) => {
const sticky = columnIndex < STICKY_COLUMN_COUNT;
const direction = sort?.key === column.id ? sort.direction : undefined;
return (
<th
key={column.id}
rowSpan={4}
className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2"
style={sticky ? {
position: "sticky",
left: columnIndex === 0 ? REPORT_COLUMN_LEFT : PIPE_COLUMN_LEFT,
zIndex: 60,
boxShadow:
columnIndex === STICKY_COLUMN_COUNT - 1 && showStickyDivider
? STICKY_DIVIDER_SHADOW
: undefined,
} : undefined}
aria-sort={
direction === "asc"
? "ascending"
: direction === "desc"
? "descending"
: "none"
}
>
<button
type="button"
className="inline-flex h-full w-full items-center justify-center gap-1.5"
aria-label={`${column.label.replace("\n", " ")} 정렬`}
onClick={() => handleSortChange(column.id)}
>
<span className="whitespace-pre-line">{column.label}</span>
{direction === "asc" ? (
<span
className="inline-flex min-w-2 justify-center text-[8px] leading-none text-[#1d4ed8]"
aria-hidden="true"
>
▲
</span>
) : direction === "desc" ? (
<span
className="inline-flex min-w-2 justify-center text-[8px] leading-none text-[#1d4ed8]"
aria-hidden="true"
>
▼
</span>
) : (
<span
className="inline-flex flex-col items-center gap-px text-[6px] leading-none text-[#475569]"
aria-hidden="true"
>
<span>▲</span>
<span>▼</span>
</span>
)}
</button>
</th>
);
})}
<th
colSpan={(definition.defectColumns.length + 1) * EXMN_DETAIL_SEVERITIES.length}
className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2"
>
이상항목
</th>
</tr>
<tr>
<th
colSpan={EXMN_DETAIL_SEVERITIES.length}
className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2"
>
합계
</th>
<th
colSpan={definition.defectColumns.length * EXMN_DETAIL_SEVERITIES.length}
className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2"
>
{definition.defectGroupTitle}
</th>
</tr>
<tr>
{EXMN_DETAIL_SEVERITIES.map((severity) => (
<th
key={`total-${severity}`}
rowSpan={2}
className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2"
>
{SEVERITY_LABELS[severity]}
</th>
))}
{definition.defectColumns.map((column) => (
<th
key={column.code}
colSpan={EXMN_DETAIL_SEVERITIES.length}
title={`${column.label} (${column.code})`}
className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2 whitespace-nowrap"
>
{column.label}
</th>
))}
</tr>
<tr>
{definition.defectColumns.flatMap((column) =>
EXMN_DETAIL_SEVERITIES.map((severity) => (
<th
key={`${column.code}-${severity}`}
className="border-b border-r border-border bg-[#eaf3fb] px-2 py-1.5 font-medium"
>
{SEVERITY_LABELS[severity]}
</th>
)),
)}
</tr>
</thead>
<tbody>
{rows.length > 0 ? (
<>
{rows.map((row) => (
<tr key={row.reportNo} className="sn-popup-grid-row sn-popup-grid-row-hover">
{FIXED_COLUMNS.map((column, columnIndex) => {
const sticky = columnIndex < STICKY_COLUMN_COUNT;
return (
<td
key={`${row.reportNo}-${column.id}`}
className="border-b border-r border-border bg-background px-2 py-2.5 whitespace-nowrap"
style={sticky ? {
position: "sticky",
left: columnIndex === 0 ? REPORT_COLUMN_LEFT : PIPE_COLUMN_LEFT,
zIndex: 10,
boxShadow:
columnIndex === STICKY_COLUMN_COUNT - 1
&& showStickyDivider
? STICKY_DIVIDER_SHADOW
: undefined,
} : undefined}
>
{getFixedCellValue(row, column.id)}
</td>
);
})}
{EXMN_DETAIL_SEVERITIES.map((severity) => (
<td
key={`${row.reportNo}-total-${severity}`}
className="border-b border-r border-border bg-background px-2 py-2.5"
>
{formatCount(row.anomalyTotals[severity])}
</td>
))}
{definition.defectColumns.flatMap((column) =>
EXMN_DETAIL_SEVERITIES.map((severity) => (
<td
key={`${row.reportNo}-${column.code}-${severity}`}
className="border-b border-r border-border px-2 py-2.5"
>
{formatCount(row.defectCounts[column.code]?.[severity])}
</td>
)),
)}
</tr>
))}
<tr className="bg-background font-medium">
<td
colSpan={STICKY_COLUMN_COUNT}
className="sticky left-0 z-20 border-b border-border bg-background px-2 py-2.5 text-center align-middle"
style={{
boxShadow: showStickyDivider
? STICKY_DIVIDER_SHADOW
: undefined,
}}
>
<span
className="inline-block whitespace-nowrap"
style={{
transform: `translateX(${summaryLabelOffset}px)`,
}}
>
소계
</span>
</td>
<td
aria-hidden="true"
colSpan={SUMMARY_LABEL_COLUMN_COUNT - STICKY_COLUMN_COUNT}
className="border-b border-r border-border bg-background px-2 py-2.5"
/>
{fixedSummaryValues.map((summary) => (
<td
key={`subtotal-fixed-${summary.key}`}
className="border-b border-r border-border bg-background px-2 py-2.5 text-center"
>
{summary.value}
</td>
))}
{EXMN_DETAIL_SEVERITIES.map((severity) => (
<td
key={`subtotal-anomaly-${severity}`}
className="border-b border-r border-border px-2 py-2.5"
>
{formatCount(defectSummary.anomalySubtotals[severity])}
</td>
))}
{definition.defectColumns.flatMap((column) =>
EXMN_DETAIL_SEVERITIES.map((severity) => (
<td
key={`subtotal-${column.code}-${severity}`}
className="border-b border-r border-border px-2 py-2.5"
>
{formatCount(
defectSummary.defectSubtotals[column.code]?.[severity],
)}
</td>
)),
)}
</tr>
<tr className="bg-[#dcdcdc] font-medium">
<td
colSpan={STICKY_COLUMN_COUNT}
className="sticky left-0 z-20 border-b border-border bg-[#dcdcdc] px-2 py-2.5 text-center align-middle"
style={{
boxShadow: showStickyDivider
? STICKY_DIVIDER_SHADOW
: undefined,
}}
>
<span
className="inline-block whitespace-nowrap"
style={{
transform: `translateX(${summaryLabelOffset}px)`,
}}
>
합계
</span>
</td>
<td
aria-hidden="true"
colSpan={SUMMARY_LABEL_COLUMN_COUNT - STICKY_COLUMN_COUNT}
className="border-b border-r border-border bg-[#dcdcdc] px-2 py-2.5"
/>
{fixedSummaryValues.map((summary) => (
<td
key={`total-fixed-${summary.key}`}
className="border-b border-r border-border bg-[#dcdcdc] px-2 py-2.5 text-center"
>
{summary.value}
</td>
))}
<td
colSpan={EXMN_DETAIL_SEVERITIES.length}
className="border-b border-r border-border px-2 py-2.5 text-center"
>
{formatCount(defectSummary.anomalyTotal)}
</td>
{definition.defectColumns.map((column) => (
<td
key={`total-${column.code}`}
colSpan={EXMN_DETAIL_SEVERITIES.length}
className="border-b border-r border-border px-2 py-2.5 text-center"
>
{formatCount(defectSummary.defectTotals[column.code])}
</td>
))}
</tr>
</>
) : (
<tr>
<td
colSpan={FIXED_COLUMNS.length
+ (definition.defectColumns.length + 1)
* EXMN_DETAIL_SEVERITIES.length}
className="sn-popup-empty-cell h-28 border-b border-r border-border text-center"
>
조회된 상세 결과가 없습니다.
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
</main>
);
}
@@ -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: "노후 하수관로 정비사업 등록",
@@ -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,
@@ -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 <div className="min-h-dvh bg-[#f3f7fa]" suppressHydrationWarning />;
}
return (
<ExmnSurveyDetailPopupPage
business={buildBusinessSummary(searchParams)}
jobType={getJobType(searchParams.jobType)}
sampleMode={getParam(searchParams.sample) === "Y"}
/>
);
}