조사사업 관로·맨홀 상세보기 팝업 구현
This commit is contained in:
+24
-2
@@ -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, 엑셀 다운로드, 지도보기는 완료 범위가 아니다.
|
||||
|
||||
+22
-4
@@ -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"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user