조사 상세조회 및 집계 엑셀 다운로드 연동
This commit is contained in:
+27
-13
@@ -44,7 +44,7 @@
|
||||
- 자동: TypeScript, `git diff --check`, Next 빌드
|
||||
- 수동: `/oper/trtdst` 진입, 헤더 정렬, 선택 행 유지, 검색조건 간격, 버튼 우측 정렬·글자색, 모달 입력, 행 상태 색, 메시지 표시
|
||||
- 통합: 실제 처리분구 API와 409 참조 제약, 저장 후 사업등록 후보 즉시 갱신
|
||||
- 백엔드 미구현 동안 실 API 통합 항목은 보류로 기록한다. 사용자 승인 샘플은 `sample=Y`에만 격리하고 일반 화면 성공 데이터로 대체하지 않는다.
|
||||
- 실제 API·DB·JSONB 결과를 기준으로 검증하며 샘플 화면이나 자동 성공 fallback은 사용하지 않는다.
|
||||
|
||||
## 6. 다음 검증 순서
|
||||
|
||||
@@ -91,23 +91,23 @@
|
||||
| 공통 선택 | 사업년도·시도·시군구 | 연도 선택과 I/I 지역 선택 재사용, 지역 기본값 고정 없음 |
|
||||
| 목록 열 | 사용자 확정 열 순서·단위 | NO부터 보고서까지 9개 열과 km·개소 단위 일치 |
|
||||
| 버튼 | 상단 업무 버튼 | `PPL 집계·상세` 직접 버튼 미표시 |
|
||||
| 샘플 격리 | 일반 URL / `?sample=Y` | 일반 URL은 API만 사용, sample URL만 샘플 사업·처리분구·검토결과 표시 |
|
||||
| 데이터 원천 | 일반 URL 진입 | 사업·처리분구·검토결과를 실제 API 응답으로만 표시 |
|
||||
|
||||
## 9. FE-1 수동 확인 경로
|
||||
|
||||
1. 메뉴 연결 URL을 `/oper/exmn/register`로 등록하거나 주소로 직접 진입한다.
|
||||
2. 조회조건과 빈 사업 목록 레이아웃을 확인한다.
|
||||
3. 백엔드 데이터가 없으면 개발 환경의 `샘플 데이터 보기`를 눌러 `sample=Y` 샘플 사업목록을 확인한다.
|
||||
2. 조회조건과 실제 사업 목록 또는 정상 빈 결과 레이아웃을 확인한다.
|
||||
3. 결과가 등록된 실제 사업을 선택해 관로·맨홀 집계와 상세현황을 확인한다.
|
||||
4. `사업등록` WindowPopup을 열어 행정구역·가나다순 처리분구 체크박스·필수값·양식 버튼을 확인한다.
|
||||
5. 사업 행 선택 후 `결과등록` WindowPopup의 파일 5종, 찾기, 데이터 검토, 저장 활성 조건을 확인한다.
|
||||
6. 상단에 `PPL 집계·상세` 직접 버튼이 없고 목록 열 순서·단위가 확정안과 일치하는지 확인한다.
|
||||
7. 일반 URL의 백엔드 미구현 오류는 화면 오류 메시지로 표시되는지 확인하고 샘플 성공으로 오해하지 않는다.
|
||||
7. API 오류는 화면 오류 메시지로 표시되고 빈 결과와 구분되는지 확인한다.
|
||||
|
||||
## 10. 조사현황·집계 1차 검증 계획
|
||||
|
||||
| 구분 | 시나리오 | 기대 결과 |
|
||||
| --- | --- | --- |
|
||||
| 진입 | `/oper/exmn/register`, `?sample=Y` | 기존 통합 화면 유지, 일반 URL은 실 API, sample URL만 샘플 데이터 사용 |
|
||||
| 진입 | `/oper/exmn/register` | 기존 통합 화면을 유지하고 실제 API 데이터만 사용 |
|
||||
| 목록 | 조건 조회·페이지·정렬·행 선택 | 등록 화면과 같은 조건 계약, 선택 행 강조 |
|
||||
| 상세현황 | 사업 행의 어느 데이터 셀 선택 | 하단 조사 상세현황 표시, 상단과 중복되는 선택 사업 요약 띠는 표시하지 않음 |
|
||||
| 선택 전 상태 | 최초 진입·재조회로 선택 해제 | 조사 상세현황 영역 자체가 표시되지 않음 |
|
||||
@@ -128,8 +128,8 @@
|
||||
| 검색조건 | 사업차수·사업연도·시도·시군구를 차례로 변경 후 조회 | 조건 간격이 자연스럽고 기존 연도·행정구역 연동 동작 유지 |
|
||||
| 행 선택 | NO부터 보고서까지 각 열의 빈 공간·텍스트 주변 선택 | 어느 위치에서도 같은 행의 상세현황 표시, 선택 행 강조 |
|
||||
| 정렬 | 각 정렬 헤더를 3회 연속 선택 | 오름차순→내림차순→기본 순환, 공통 아이콘 상태 일치 |
|
||||
| 데이터 원천 | 일반 URL과 `sample=Y`에서 같은 열 정렬 | 서버 결과와 샘플 결과가 같은 값 비교 규칙 사용 |
|
||||
| 목록 높이 | 샘플 사업 4건 확인 | 3행은 하단선까지 온전히 표시, 4번째부터 목록 내부 스크롤 |
|
||||
| 데이터 원천 | 실제 사업목록 정렬 | 서버 결과에 화면 정렬 규칙 적용 |
|
||||
| 목록 높이 | 실제 사업 4건 이상 확인 | 3행은 하단선까지 온전히 표시, 4번째부터 목록 내부 스크롤 |
|
||||
| 폭·경계 | 목록과 상세 좌우 끝선 및 스크롤 상태 비교 | 같은 가로 폭·여백, 상·하단 구분선 잘림 없음 |
|
||||
| 조사유형 | 관로·맨홀 버튼의 가장자리와 중앙 선택 | 보이는 button 전체가 클릭되고 해당 유형 집계 재조회 |
|
||||
| 요청 순서 | 사업·조사유형을 연속으로 빠르게 변경 | 마지막 선택의 응답만 화면에 반영 |
|
||||
@@ -152,7 +152,7 @@
|
||||
| 고정 열 | 표를 오른쪽으로 이동 | 보고서번호·관로/맨홀번호가 고정되고 규격 이후 경계선 유지 |
|
||||
| 정렬 | 고정 정보 헤더를 3회 클릭 | 보고서번호~주행거리만 오름차순→내림차순→기본 순환 |
|
||||
| 조회 | 관로/맨홀번호 입력 후 조회 | 번호 포함 행만 표시되고 빈 조건에서 전체 3행 복원 |
|
||||
| 소계 | 샘플 3행 값 합산 | 연장·주행거리와 코드별 대·중·소 합계 일치 |
|
||||
| 소계 | 실제 시설 행 값 합산 | 연장·주행거리와 코드별 대·중·소 합계 일치 |
|
||||
| 합계 | 이상항목 합계 행 확인 | 각 항목 대·중·소 3열 병합 후 총합 표시 |
|
||||
| 요약 라벨 | 가로 스크롤 전·후 비교 | 규격 전에는 병합영역 중앙, 규격 이후에도 고정열 오른쪽에 소계·합계 라벨 표시 |
|
||||
| 목록 정렬 | 조사사업 `NO` 헤더 3회 클릭 | 정순→역순→기본 순환, 서버에 가상 `rowNumber` 필드 미전달 |
|
||||
@@ -174,7 +174,7 @@
|
||||
| 전체 상세 | 행 선택 후 상단 상세보기 클릭 | `ALL` 기준 구조·운영 이상항목을 함께 표시 |
|
||||
| 조사유형 | 관로·맨홀 탭에서 각각 링크 클릭 | 선택 탭에 맞는 관로/맨홀 상세표 표시 |
|
||||
| 분류 열 필터 | 전체·구조·운영 팝업 비교 | 선택 분류 코드 열만 표시 |
|
||||
| 분류 행 필터 | 분류별 샘플 데이터 확인 | 선택 분류의 결함이 하나 이상인 시설 행만 표시 |
|
||||
| 분류 행 필터 | 분류별 실제 데이터 확인 | 선택 분류의 결함이 하나 이상인 시설 행만 표시 |
|
||||
| 코드 헤더 | 이상항목 헤더 확인 | 모든 헤더가 `명칭 (코드)` 형식 |
|
||||
| 제거 열 | 고정 정보 열 확인 | 구조적·운영적 내부결함 열 없음 |
|
||||
| 집계 | 전체·구조·운영 각각 소계·합계 확인 | 현재 표시 행·열의 대·중·소 값과 일치 |
|
||||
@@ -182,7 +182,7 @@
|
||||
| 결과등록 요약 | 사업정보 영역 확인 | 읽기 전용 텍스트가 한 줄에서 잘리지 않음 |
|
||||
| 회귀 | 사업등록·결과등록·상세보기 팝업 비교 | 파란 헤더와 옅은 파란 본문 배경 일관성 유지 |
|
||||
|
||||
- 브라우저 수동 검증은 로그인된 `SN` 세션과 `/oper/exmn/register?sample=Y` 진입이 선행조건이다.
|
||||
- 브라우저 수동 검증은 로그인된 `SN` 세션과 `/oper/exmn/register` 진입이 선행조건이다.
|
||||
- 개발 서버가 실행 중이면 별도 프로덕션 빌드를 실행하지 않고 정적 검사와 사용자 화면 확인을 우선한다.
|
||||
|
||||
## 14. 관로 본단위구간과 결함별 상세 팝업 회귀 계획
|
||||
@@ -202,17 +202,31 @@
|
||||
| 정적 검사 | 변경 저장소에서 `git diff --check` | 공백 오류 없음 |
|
||||
|
||||
- 실제 다운로드 파일은 Excel에서 열어 헤더, 병합 범위, 소계·합계와 한글 파일명을 대조한다.
|
||||
- 실제 API 연결 후에는 샘플과 동일한 조건으로 JSONB 코드·점수·단위구간 매핑을 재검증한다.
|
||||
- 실제 API 응답과 DB JSONB의 코드·점수·단위구간 매핑을 대조한다.
|
||||
|
||||
## 15. 사업등록 사업차수·행정구역 배치 보완 검증 계획
|
||||
|
||||
| 검증 항목 | 방법 | 기대 결과 |
|
||||
| --- | --- | --- |
|
||||
| 입력 순서 | 사업등록 팝업 첫째·둘째 줄 확인 | 첫 줄 5개 조건과 둘째 줄 사업명이 확정 순서로 표시 |
|
||||
| 사업차수 기본값 | 일반·`sample=Y` 각각 진입 | 일반 1, 샘플 3 표시 |
|
||||
| 사업차수 기본값 | 신규 사업등록 진입 | 기본값 1 표시 |
|
||||
| 사업차수 검증 | 빈 값·0·소수·양의 정수 저장 | 1 이상의 정수만 저장 가능 |
|
||||
| 행정구역 명칭 | 강원특별자치도·대구광역시·긴 시군구 선택 | 선택값이 말줄임·잘림 없이 확인 가능 |
|
||||
| 간격 | 첫 줄 전체 항목 비교 | 항목 묶음 사이 20px 고정 간격, 과도한 균등 분산 없음 |
|
||||
| 공통 회귀 | `compact` 미지정 기존 화면 확인 | 기존 시도·시군구 폭과 간격 유지 |
|
||||
| 팝업 크기 | 새 창 외곽·본문 확인 | `1000×620`, 세로 구성과 처리분구 영역 유지 |
|
||||
| 정적 검사 | 프론트 저장소 `git diff --check` | 공백 오류 없음 |
|
||||
|
||||
## 16. 샘플 보기 제거 후 최종 회귀 계획
|
||||
|
||||
| 검증 항목 | 방법 | 기대 결과 |
|
||||
| --- | --- | --- |
|
||||
| 샘플 코드 | `sampleMode`, `sample=`, `exmn-sample-data` 검색 | 런타임 코드 참조 0건 |
|
||||
| 최초 목록 | `/oper/exmn/register` 진입 | 빈 조건으로 실제 사업목록 자동 조회 |
|
||||
| 등록·수정 | 신규 저장 및 선택 사업 수정 | 실제 API 저장 후 부모 목록 재조회 |
|
||||
| 결과등록 | 파일 검토·최종 저장 | 실제 검토 API와 첨부·commit 호출 |
|
||||
| 종합집계 | 결과 사업 행 선택·관로/맨홀 전환 | 실제 처리분구별 집계 표시 |
|
||||
| 상세보기 | 전체·본단위·구조·운영 진입 | 실제 시설 컬럼과 JSONB 결과 표시 |
|
||||
| 다운로드 | 관로·맨홀 다운로드 | 종합집계 1개와 처리분구 상세 N개 시트 생성 |
|
||||
| 과거 URL | `/oper/exmn/register?sample=Y` 진입 | 파라미터를 무시하고 실제 API 데이터 표시 |
|
||||
| 지도보기 | 버튼 클릭 | 보류 안내 유지 |
|
||||
|
||||
@@ -313,3 +313,168 @@
|
||||
- 사업보고서 링크 클릭 시 첨부 원본 파일명으로 실제 다운로드되는지 최종 확인한다.
|
||||
- 맨홀 PDF 내부 맨홀번호 대조는 검토 기준 협의 후 구현·검증한다.
|
||||
- GIS 기준시설과 조사결과 시설번호 대조 및 지도보기는 후속 범위다.
|
||||
|
||||
## 21. 2026-07-29 조사 상세현황 실제 집계 조회
|
||||
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| 프론트 API URL·파라미터 계약 | 정적 확인 | `/sections?jobType=PPL\|MNHL` |
|
||||
| 관로 집계 필드 매핑 | 정적 확인 | PPL 전체이상·구조·운영·기타, 연장·주행거리 |
|
||||
| 맨홀 집계 필드 매핑 | 정적 확인 | MNHL 전체이상·구조·운영·기타 |
|
||||
| 빈 배열 화면 | 정적 확인 | `조회된 결과가 없습니다.` |
|
||||
| 빠른 사업·탭 전환 보호 | 유지 | request id 기반 이전 응답 차단 |
|
||||
| 실제 서버 화면 검증 | 대기 | 백엔드 재기동 후 사용자 확인 필요 |
|
||||
|
||||
### 남은 검증
|
||||
|
||||
- 결과가 저장된 사업을 선택해 관로·맨홀 탭의 처리분구별 수치와 DB 집계를 대조한다.
|
||||
- 결과 미등록 사업에서 오류 배너가 표시되지 않는지 확인한다.
|
||||
|
||||
## 22. 2026-07-29 조사 상세보기 전체 집계 API 정적 검증
|
||||
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| 상세 API 요청 | 정적 확인 | 사업·처리분구·PPL/MNHL·관리번호 전달 |
|
||||
| 실제/샘플 데이터 분리 | 통과 | `sample=Y`는 기존 샘플, 일반 URL은 서버 응답 |
|
||||
| 기존 표 계약 유지 | 통과 | 고정 열·다단 결함 열·소계·합계·정렬 재사용 |
|
||||
| 요청 순서 보호 | 통과 | effect cleanup 후 이전 응답 무시 |
|
||||
| 후속 기능 격리 | 통과 | UNIT·결함별 화면은 아직 실 API 미호출 |
|
||||
| 변경 공백 검사 | 통과 | `git diff --check` |
|
||||
|
||||
### 잔여 확인
|
||||
|
||||
- 실제 서버 재기동 후 상세보기 팝업의 6개 PPL·6개 MNHL 시설과 JSONB 코드별 대·중·소 값을 확인한다.
|
||||
- 본단위구간, 결함별 조회와 서버 다운로드는 다음 단계에서 검증한다.
|
||||
|
||||
## 23. 2026-07-29 맨홀 전체 상세표 고정정보 정적 검증
|
||||
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| 맨홀 식별 열 용어 | 통과 | `맨홀번호` 대신 `관로번호` 표시 |
|
||||
| 관로 고정 열 | 정적 확인 | 보고서번호부터 주행거리까지 표시 |
|
||||
| 맨홀 구조·운영 등급 | 정적 확인 | 공통 열 뒤에 시설 등급코드 두 열 표시 |
|
||||
| 소계·합계 | 정적 확인 | 관로는 연장·주행거리, 맨홀은 구조·운영 등급까지 합산 |
|
||||
| 조사유형별 결함 열 | 정적 확인 | 고정 열 이후 PPL/MNHL 코드 정의 분기 |
|
||||
| 엑셀 다운로드 열 | 정적 확인 | 관로·맨홀 화면별 고정 열과 결함 집계 생성 |
|
||||
| 변경 화면 ESLint | 통과 | `ExmnSurveyDetailPopupPage.tsx` 오류·경고 없음 |
|
||||
|
||||
### 남은 검증
|
||||
|
||||
- 실제 맨홀 상세 팝업에서 덕풍 3건의 공통 기본정보·등급과 결함별 대·중·소 건수를 확인한다.
|
||||
- 가로 스크롤 시 두 고정 열과 소계·합계 라벨 경계가 유지되는지 확인한다.
|
||||
- 현재 브라우저 세션은 로그인 화면이므로 인증 후 실제 팝업을 재확인한다.
|
||||
|
||||
## 24. 2026-07-29 상세보기 처리분구 선택 정적 검증
|
||||
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| 조사유형별 처리분구 목록 | 정적 확인 | 기존 `/sections` API를 PPL·MNHL로 구분 호출 |
|
||||
| 진입 처리분구 기본값 | 통과 | 조사 상세현황에서 선택한 처리분구 ID 유지 |
|
||||
| 처리분구 변경 재조회 | 정적 확인 | 선택 ID를 시설 상세 API에 전달 |
|
||||
| 이전 조회 상태 초기화 | 통과 | 상세 행·관리번호·정렬·안내 초기화 |
|
||||
| 제목·다운로드 이름 | 통과 | 현재 선택 처리분구명 사용 |
|
||||
| 대상 화면 ESLint | 통과 | `ExmnSurveyDetailPopupPage.tsx` 오류·경고 없음 |
|
||||
| 변경 공백 검사 | 통과 | `git diff --check` |
|
||||
|
||||
### 남은 검증
|
||||
|
||||
- 로그인된 실제 화면에서 덕풍처리분구로 진입한 뒤 초이처리분구를 선택해 표 내용과 제목이 함께 변경되는지 확인한다.
|
||||
- 관로 상세보기에는 PPL 처리분구만, 맨홀 상세보기에는 MNHL 처리분구만 표시되는지 확인한다.
|
||||
|
||||
## 25. 2026-07-29 관로 본단위구간 실데이터 연결 검증
|
||||
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| 실제 API 연결 | 통과 | PPL 단위구간 전용 API 호출 |
|
||||
| 초기 전체 조회 | 정적 확인 | 관리번호 없이 현재 처리분구 전체 |
|
||||
| 관리번호 부분 조회 | 정적 확인 | 조회 버튼 확정값 전달 |
|
||||
| 상태·결함 명칭 | 통과 | 코드 기반 프론트 매핑 |
|
||||
| 빈 상세 단위구간 | 통과 | 결함 필드 하이픈 표시 |
|
||||
| 기존 샘플·다운로드 | 유지 | 현재 14열 화면 계약 재사용 |
|
||||
| 대상 ESLint | 통과 | 관련 4개 파일 오류·경고 없음 |
|
||||
|
||||
### 남은 검증
|
||||
|
||||
- 사용자 환경에서 관로 상세보기 → `본단위구간` 전환 후 덕풍·초이 전체 관로가 표시되는지 확인한다.
|
||||
- 관리번호 일부를 입력해 해당 관로 단위구간만 표시되는지 확인한다.
|
||||
- 구조·운영 결함별 상세와 서버 다운로드는 다음 단계에서 검증한다.
|
||||
|
||||
### 본단위구간 표 너비 회귀
|
||||
|
||||
- 세로 스크롤 유무와 관계없이 표가 스크롤 컨테이너의 가로 폭을 채우도록 너비 계약을 보정했다.
|
||||
- 컨테이너가 1422px보다 작을 때는 기존 내부 가로 스크롤 동작을 유지한다.
|
||||
|
||||
### 조사중단사유·비고 셀 분리
|
||||
|
||||
- 조사중단사유와 비고에 단위구간 `rowSpan`이 적용되지 않는 것을 소스로 확인했다.
|
||||
- 각 상세 행에 두 셀이 각각 렌더링되어 행별 구분선이 유지되는 것을 확인했다.
|
||||
|
||||
## 26. 2026-07-29 구조·운영 결함별 실데이터 연결 검증
|
||||
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| 결함 링크 요청 | 통과 | 선택 `defectType`을 시설 상세 API에 전달 |
|
||||
| 간소화 팝업 | 통과 | 조회조건·모드 없이 결과와 다운로드 표시 |
|
||||
| 선택 코드 열 | 통과 | 구조 또는 운영 코드 정의만 사용 |
|
||||
| 선택 시설 행 | 통과 | 선택 분류 결함이 존재하는 행만 표시 |
|
||||
| 소계·합계 | 통과 | 현재 표시 행·열 기준 계산 |
|
||||
| 대상 ESLint | 통과 | API·팝업 변경 파일 오류 없음 |
|
||||
|
||||
### 남은 검증
|
||||
|
||||
- 사용자 환경에서 관로·맨홀 각각 구조적·운영적 결함 링크를 열어 화면 건수와 DB JSONB를 대조한다.
|
||||
- 현재 화면 기준 엑셀 파일의 열·소계·합계를 팝업과 대조한다.
|
||||
|
||||
## 27. 2026-07-30 조사 상세현황 다중 시트 다운로드 정적 검증
|
||||
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| 프론트 API 연결 | 구현 | `/sections/download?jobType=PPL\|MNHL` |
|
||||
| 다운로드 파일 | 구현 | 서버 Blob 1개 저장 |
|
||||
| 시트 계약 | 정적 확인 | 종합집계 1 + 처리분구 상세 N |
|
||||
| 본단위구간 제외 | 정적 확인 | 서버 생성 계약에서 제외 |
|
||||
| 빈 결과·진행 상태 | 구현 | 버튼 비활성·안내 표시 |
|
||||
| 샘플 모드 | 제거 | 실제 API만 사용하도록 단일화 |
|
||||
| ESLint | 미실행 | pnpm TTY 오류와 로컬 eslint 파일 접근 `EPERM` |
|
||||
|
||||
### 남은 검증
|
||||
|
||||
- 로그인된 실제 화면에서 관로·맨홀 탭별 다운로드를 실행한다.
|
||||
- Excel에서 처리분구 10개 기준 11개 시트인지 확인한다.
|
||||
- 첫 시트 종합집계와 각 처리분구 상세의 소계·합계를 화면 값과 대조한다.
|
||||
|
||||
## 28. 2026-07-30 샘플 데이터 보기 제거 정적 검증
|
||||
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| 샘플 데이터 파일 | 제거 | `exmn-sample-data.ts` 삭제 |
|
||||
| 샘플 URL·상태 분기 | 통과 | 기능 범위에서 `sample=`, `sampleMode` 참조 0건 |
|
||||
| 목록·팝업 실제 API | 통과 | 사업·처리분구·검토·집계·상세 API 호출 유지 |
|
||||
| 양식 생성 예시행 정리 | 영향 없음 | 백엔드 실제 양식 생성 로직 유지 |
|
||||
| 전체 빌드 | 미실행 | 사용자 요청에 따라 정적 확인만 수행 |
|
||||
|
||||
### 남은 검증
|
||||
|
||||
- 조사관리 최초 진입과 사업 행 선택 시 실제 목록·상세현황이 조회되는지 확인한다.
|
||||
- 사업등록/수정, 결과등록, 전체 상세와 본단위구간 WindowPopup이 실제 데이터로 열리는지 확인한다.
|
||||
- 과거 `sample=Y` 주소로 진입해도 샘플 화면이 아닌 실제 데이터가 표시되는지 확인한다.
|
||||
|
||||
## 29. 2026-07-30 샘플 제거 변경분 코드 리뷰 결과
|
||||
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| 목록 최초 조회 | 통과 | 빈 조회조건으로 실제 목록 API 호출 |
|
||||
| 등록·수정 후 동기화 | 통과 | popup 메시지 수신 후 현재 조건으로 목록 재조회 |
|
||||
| 결과등록 | 통과 | 실제 상세조회·검토·첨부 연결·commit 흐름 유지 |
|
||||
| 종합집계 | 통과 | 선택 사업·조사유형으로 실제 집계 API 호출 |
|
||||
| 전체·본단위·결함별 상세 | 통과 | 사업·처리분구·유형 파라미터와 실제 API 호출 유지 |
|
||||
| WindowPopup props | 통과 | 제거된 샘플 props 참조 없음 |
|
||||
| 런타임 샘플 참조 | 통과 | 기능 범위 검색 결과 0건 |
|
||||
| diff 형식 | 통과 | 프론트·공통 문서 `git diff --check` 오류 없음 |
|
||||
| TypeScript/전체 빌드 | 미실행 | 로컬 TypeScript 접근 `EPERM`, 사용자 요청에 따라 장시간 빌드 생략 |
|
||||
|
||||
### 남은 수동 회귀
|
||||
|
||||
- 로그인된 실제 화면에서 최초 목록, 등록·수정, 결과등록과 상세 팝업을 한 번씩 진입한다.
|
||||
- 관로·맨홀 다운로드 파일의 시트 수와 화면·DB 집계값을 대조한다.
|
||||
- 과거 `sample=Y` 북마크가 존재하더라도 실제 API 화면으로 동작하는지 확인한다.
|
||||
|
||||
+37
-12
@@ -4,7 +4,7 @@
|
||||
|
||||
- 작업 ID: `SFR-010-004~007-EXMN-NEW-FE-FIRST`
|
||||
- 작업명: CCTV·맨홀 조사사업 프론트엔드 선행 구현
|
||||
- 상태: 화면 1~3단계와 관로·맨홀 상세보기 팝업 완료·커밋 대기(실 API·엑셀 다운로드 후속, 지도보기 보류)
|
||||
- 상태: 처리분구·사업·결과등록·집계·전체/본단위/결함별 상세·다중 시트 다운로드 실 API 연동 완료(지도보기 보류, 샘플 보기 제거)
|
||||
- 작성일: 2026-07-22
|
||||
- 담당 역할: FE
|
||||
- 관련 역할: BE, QA
|
||||
@@ -22,9 +22,9 @@
|
||||
| 단계 | 범위 | 현재 상태 | 다음 단계 진입 조건 |
|
||||
| --- | --- | --- | --- |
|
||||
| FE-0 | 공통 타입·API, 처리분구관리 `/oper/trtdst` | 완료(사용자 화면·UI 검증 통과) | 충족 |
|
||||
| FE-1 | `SFR-010-004` 사업등록·파일검토·PPL 집계/상세 | 화면 완료 | 사업등록·결과등록·종합집계·관로 상세보기 UI 확인 완료, 실 API·다운로드 후속 |
|
||||
| FE-1 | `SFR-010-004` 사업등록·파일검토·PPL 집계/상세 | 완료 | 실 API·JSONB·다운로드 연동 완료 |
|
||||
| FE-2 | `SFR-010-005` 조사현황·지도 진입 | 부분 완료 | 같은 화면 조사현황 완료, 지도 진입 보류 |
|
||||
| FE-3 | `SFR-010-006` MNHL 집계·상세 | 화면 완료 | 맨홀 종합집계·상세보기 UI 확인 완료, 실 API·다운로드 후속 |
|
||||
| FE-3 | `SFR-010-006` MNHL 집계·상세 | 완료 | 실 API·JSONB·다운로드 연동 완료 |
|
||||
| FE-4 | `SFR-010-007` GIS 레이어·등급 필터·상세 | 대기 | FE-3 완료 |
|
||||
|
||||
### 3.1 사용자 확인 기준 화면 단계
|
||||
@@ -32,7 +32,7 @@
|
||||
| 화면 단계 | 완료 내용 | 판정 |
|
||||
| --- | --- | --- |
|
||||
| 1단계 | 처리분구관리 조회·추가·수정·삭제, 시도·시군구 조건, 그리드 UI | 완료 |
|
||||
| 2단계 | 조사사업 검색·정렬·내부 스크롤, 행 선택·강조, 사업등록·결과등록 WindowPopup, `sample=Y` 데이터 | 완료 |
|
||||
| 2단계 | 조사사업 검색·정렬·내부 스크롤, 행 선택·강조, 사업등록·결과등록 WindowPopup, 실제 API 데이터 | 완료 |
|
||||
| 3단계 | 같은 화면 하단의 관로·맨홀 조사 전환, 처리분구별 종합집계·합계, 상세 내부 스크롤, 요청 순서 보호 | 완료 |
|
||||
|
||||
- 위 1~3단계는 화면 구현 체크포인트다. 공식 요구사항 단계는 실제 상세보기·다운로드·지도 및 백엔드 연동 범위를 포함하므로 `FE-1~FE-3`을 모두 완료로 판정하지 않는다.
|
||||
@@ -89,7 +89,7 @@
|
||||
| 사업등록 | `src/features/exmn/ui/ExmnBusinessRegisterPopupPage.tsx` | 처리분구 체크 선택·가나다순·저장·양식 |
|
||||
| 파일검토 | `src/features/exmn/ui/ExmnResultRegisterPopupPage.tsx` | 파일 5종·validate·Tus 첨부·commit |
|
||||
| 팝업 공통 | `src/features/windowPopup/{model,ui,registry.tsx}` | popup key·크기·메시지·content 등록 |
|
||||
| 샘플 검증 | `src/features/exmn/model/exmn-sample-data.ts` | `sample=Y` 전용 사업·처리분구·검토결과 |
|
||||
| 데이터 원천 | `src/features/exmn/api/exmn-business-api.ts` | 사업·처리분구·파일검토·저장 실제 API |
|
||||
| PPL | `src/features/exmn/ui/PplSummaryModal.tsx`, `PplFacilityDetailModal.tsx` | 집계·시설·단위구간 상세 |
|
||||
| 타입/API/검증 | `src/features/exmn/{model,api,lib}/exmn-business-*` | 승인 계약 구현 |
|
||||
| 첨부 오류 | `app/components/lib/utils/fileUploadUtil.tsx` | 실패 콜백 추가 |
|
||||
@@ -103,7 +103,7 @@
|
||||
- 사업등록 WindowPopup의 행정구역 연동 처리분구 체크 선택·가나다순과 저장 전 검증을 확인한다.
|
||||
- 사업등록 WindowPopup은 콘텐츠 창 `1000×620` 안에서 첫 줄에 `사업차수·사업년도·시도·시군구·정밀조사연장`, 둘째 줄에 `사업명`을 표시하고, 별도 닫기·양식 생성 버튼 없이 저장·양식 받기·조사보고서 양식을 제공하는지 확인한다.
|
||||
- 결과등록 WindowPopup의 파일 5종, 행별 찾기·데이터 검토, 검토 정상 전 저장 비활성을 확인한다.
|
||||
- `/oper/exmn/register?sample=Y`에서만 샘플 사업과 팝업 샘플 상태가 보이고 일반 URL은 실제 API 계약을 유지하는지 확인한다.
|
||||
- `/oper/exmn/register`에서 실제 사업목록과 팝업 상세·검토 상태가 API 응답대로 표시되는지 확인한다.
|
||||
- 상단에 설계되지 않은 `PPL 집계·상세` 직접 버튼이 표시되지 않는지 확인한다.
|
||||
- 사업·결과 popup 확인 후 PPT 3·4의 동일 목록 화면에 조사 상세현황을 결합하고 사용자 확인을 받는다.
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
| 호환 라우트 | `app/(with-base-layout)/oper/exmn/status/page.tsx` | 기존 통합 화면을 그대로 렌더링하는 별칭 |
|
||||
| 타입 | `src/features/exmn/model/exmn-business-types.ts` | `ExmnJobType`, `ExmnSectionSummaryRow` 추가 |
|
||||
| API | `src/features/exmn/api/exmn-business-api.ts` | `GET /api/v1/exmnBusinesses/{id}/sections?jobType=PPL|MNHL` 추가 |
|
||||
| 샘플 | `src/features/exmn/model/exmn-sample-data.ts` | `sample=Y` 전용 관로·맨홀 처리분구 집계 |
|
||||
| 데이터 원천 | `src/features/exmn/api/exmn-business-api.ts` | 실제 관로·맨홀 처리분구 집계 API |
|
||||
|
||||
- 원본 화면설계서 슬라이드 3·4처럼 기존 사업등록·결과등록 버튼과 사업목록이 있는 화면에서 행 선택 시 바로 아래에 조사 상세현황을 펼친다. 별도 상세 페이지로 이동하지 않는다.
|
||||
- 사용자 표출 명칭은 `CCTV조사` 대신 `관로조사`, `맨홀조사`를 사용하고 서버 구분값은 `PPL`, `MNHL`을 유지한다.
|
||||
@@ -126,7 +126,7 @@
|
||||
|
||||
## 12. 조사현황·집계 1차 확인 게이트
|
||||
|
||||
- `/oper/exmn/register?sample=Y`의 기존 사업목록에서 샘플 사업을 선택하면 같은 화면 하단 상세현황이 표시되는지 확인한다.
|
||||
- `/oper/exmn/register`의 실제 사업목록에서 결과가 등록된 사업을 선택하면 같은 화면 하단 상세현황이 표시되는지 확인한다.
|
||||
- 관로조사 탭은 대상연장·주행거리·전체·구조·운영·기타 열, 맨홀조사 탭은 전체·구조·운영·기타 열을 표시하는지 확인한다.
|
||||
- 탭 이름에 `CCTV조사`가 남지 않고 `관로조사`, `맨홀조사`가 표시되는지 확인한다.
|
||||
- 세 기능 버튼은 선택 사업이 없을 때 비활성이고 선택 후 후속 연결 안내를 표시하는지 확인한다.
|
||||
@@ -152,8 +152,8 @@
|
||||
|
||||
- 검색조건 간격을 공통 조회영역에 맞추고 사업차수·사업연도·시도·시군구의 선택 동작을 확인했다.
|
||||
- 목록 전체 행 영역에서 사업을 선택할 수 있으며 선택 행을 강조한다.
|
||||
- 정렬 헤더는 공통 디자인을 따르고 `오름차순 → 내림차순 → 기본`으로 순환한다. 서버 응답과 `sample=Y` 데이터에 같은 정렬 규칙을 적용한다.
|
||||
- 검증용 사업 샘플을 4건으로 늘렸다. 목록에는 3행이 하단선까지 온전히 보이고 4번째부터 내부 세로 스크롤로 확인한다.
|
||||
- 정렬 헤더는 공통 디자인을 따르고 `오름차순 → 내림차순 → 기본`으로 순환하며 실제 서버 응답에 동일한 표시 정렬 규칙을 적용한다.
|
||||
- 실제 사업 목록에는 3행이 하단선까지 온전히 보이고 4번째부터 내부 세로 스크롤로 확인한다.
|
||||
- 상세현황 표와 동일한 좌우 여백·가로 폭·스크롤바 공간을 사용하고 스크롤 중 상·하단선이 잘리지 않게 보정했다. 스크롤바 공간 예약은 두 조사 그리드의 인라인 스타일로 격리해 WindowPopup 디자인에 영향을 주지 않는다.
|
||||
|
||||
### 14.2 조사 상세현황
|
||||
@@ -175,7 +175,7 @@
|
||||
| 구분 | 경로 | 완료 내용 |
|
||||
| --- | --- | --- |
|
||||
| 화면 정의 | `src/features/exmn/model/exmn-detail-table.ts` | 관로·맨홀 고정 열, 이상항목 코드·명칭, 구조·운영 구분 |
|
||||
| 샘플 | `src/features/exmn/model/exmn-sample-data.ts` | 조사유형별 3개 상세 행과 코드별 대·중·소 건수 |
|
||||
| 데이터 원천 | `src/features/exmn/api/exmn-business-api.ts` | 시설 컬럼·JSONB 기반 관로·맨홀 전체/결함별 상세 |
|
||||
| 상세 팝업 | `src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx` | 조회조건, 다단표, 정렬, 고정열, 소계·합계, 표 내부 스크롤 |
|
||||
| WindowPopup content | `src/features/windowPopup/ui/ExmnSurveyDetailPopupContent.tsx` | query 파라미터를 화면 사업·조사유형으로 변환 |
|
||||
| 팝업 등록 | `src/features/windowPopup/{model/popupDefinitions.ts,registry.tsx}` | `exmnSurveyDetail`, `1500×720`, 필수 파라미터 등록 |
|
||||
@@ -230,7 +230,7 @@
|
||||
| 구분 | 확정 내용 |
|
||||
| --- | --- |
|
||||
| 입력 순서 | 첫 줄 `사업차수 → 사업년도 → 시도 → 시군구 → 정밀조사연장`, 둘째 줄 `사업명` |
|
||||
| 사업차수 | 사용자 입력, 1 이상의 정수 검증, 일반 기본값 1·샘플 기본값 3 |
|
||||
| 사업차수 | 사용자 입력, 1 이상의 정수 검증, 신규 등록 기본값 1 |
|
||||
| 행정구역 | 기존 `CtpvSggPrcsFcltSelectBox`에 기본값 `false`인 `compact` 옵션 추가 |
|
||||
| 입력 폭 | 시도 180px, 시군구 144px, 정밀조사연장 96px |
|
||||
| 입력 간격 | 첫 줄 입력 묶음 사이 20px, 묶음 내부 라벨·입력 사이 8px |
|
||||
@@ -238,3 +238,28 @@
|
||||
|
||||
- `compact`를 지정하지 않은 기존 시도·시군구 사용 화면은 기존 폭과 간격을 유지한다.
|
||||
- 이번 변경은 기존 `bizCycl` 요청 필드를 실제 사용자 입력값으로 전환한 프론트 보완이며 API 필드명과 DB 컬럼은 변경하지 않는다.
|
||||
|
||||
## 19. 2026-07-30 조사 상세현황 다중 시트 다운로드
|
||||
|
||||
| 작업 단위 | 확정 내용 |
|
||||
|---|---|
|
||||
| 다운로드 대상 | 현재 선택한 `관로조사(PPL)` 또는 `맨홀조사(MNHL)` |
|
||||
| 파일 개수 | XLSX 1개 |
|
||||
| 시트 구성 | 종합집계 1개 + 처리분구별 전체 상세 N개 |
|
||||
| 처리분구 10개 예시 | 총 11개 시트 |
|
||||
| 관로 본단위구간 | 제외 |
|
||||
| 데이터 원천 | 실제 서버 집계·상세 조회 결과 |
|
||||
|
||||
- 조사 상세현황의 초록색 다운로드 버튼은 서버 Blob API를 호출한다.
|
||||
- 브라우저에서 화면 집계 한 장을 직접 만들던 임시 구현은 제거한다.
|
||||
- 화면에 조회된 결과가 없거나 다운로드 중이면 버튼을 비활성화한다.
|
||||
- 지도보기는 기존 보류 범위를 유지한다.
|
||||
|
||||
## 20. 2026-07-30 현재 구현 상태와 샘플 보기 제거
|
||||
|
||||
- 처리분구 CRUD, 조사사업 조회·등록·수정, 조사양식·보고서 양식 다운로드를 실제 API로 연결했다.
|
||||
- 조사결과 Excel과 관로 PDF 검토, 첨부 연결, 최종 저장과 PPL/MNHL 집계를 실제 API로 연결했다.
|
||||
- 관로·맨홀 종합집계, 전체 상세, 관로 본단위구간, 구조·운영 결함별 상세를 실제 DB·JSONB 조회 결과로 표시한다.
|
||||
- 종합집계 1개와 처리분구별 전체 상세 N개를 한 XLSX로 내려받으며 본단위구간은 통합 다운로드에서 제외한다.
|
||||
- 개발환경용 샘플 전환 버튼, `sample=Y` 분기와 `exmn-sample-data.ts`를 제거해 모든 화면이 실제 API만 사용한다.
|
||||
- 지도보기는 기존 결정대로 보류하며 현재 남은 프론트 기능 범위다.
|
||||
|
||||
+112
-4
@@ -26,13 +26,16 @@
|
||||
| 2026-07-23 | 화면 3단계 완료 | 같은 화면의 관로·맨홀 종합집계, 합계, 내부 스크롤, 요청 순서 보호 사용자 확인 완료 |
|
||||
| 2026-07-23 | 팝업 회귀 복구 | Tailwind `src` 스캔 복구와 핵심 열·폭 인라인 고정 후 완료 디자인 재확인 |
|
||||
| 2026-07-24 | 상세보기 화면 완료 | 관로·맨홀 WindowPopup 다단표, 조회·정렬, 고정열, 소계·합계 사용자 확인 완료 |
|
||||
| 2026-07-27~29 | 실 API 통합 | 처리분구·사업·파일검토/저장·집계·전체/본단위/결함별 상세 API 연결 |
|
||||
| 2026-07-30 | 다운로드·샘플 제거 | 다중 시트 XLSX 다운로드 연결 및 샘플 전환·데이터·분기 제거 |
|
||||
|
||||
### 3.1 현재 구현 경계
|
||||
|
||||
- 화면 1단계 `처리분구관리`, 화면 2단계 `조사사업 관리`, 화면 3단계 `조사 상세현황`까지 프론트 UI와 `sample=Y` 검증 흐름을 완료했다.
|
||||
- 화면 3단계의 관로·맨홀 처리분구별 종합집계와 후속 관로·맨홀 상세보기 팝업까지 완료했다.
|
||||
- 상세보기는 `sample=Y` 데이터와 화면 동작 기준으로 완료했으며 실 상세 API와 엑셀 다운로드 연동은 다음 프론트·백엔드 통합 작업이다. `지도보기`는 보류한다.
|
||||
- 백엔드 API·서비스·Repository·DB와 실데이터 통합 검증은 아직 시작하지 않았다.
|
||||
- 처리분구관리, 조사사업 관리, 결과등록과 관로·맨홀 조사 상세현황을 실제 API로 연결했다.
|
||||
- 관로·맨홀 처리분구별 종합집계, 전체 상세, 관로 본단위구간과 구조·운영 결함별 상세를 실제 DB·JSONB 결과로 표시한다.
|
||||
- 조사 상세현황 다운로드는 종합집계 1개와 처리분구별 전체 상세 N개 시트를 서버 XLSX로 제공한다.
|
||||
- 샘플 전환 버튼과 `sample=Y` 분기·데이터 파일은 제거했으며 모든 조사관리 화면은 실제 API만 사용한다.
|
||||
- 지도보기는 보류 상태이며 현재 남은 프론트 기능 범위다.
|
||||
|
||||
## 4. FE-0 처리분구관리
|
||||
|
||||
@@ -566,3 +569,108 @@
|
||||
- 보고서 파일명이 잘리지 않도록 목록 열 비율을 `사업차수 6%`, `처리구역·분구 8%`, `정밀조사 연장 8%`, `보고서 22%`로 최종 조정했다.
|
||||
- 좁아진 두 숫자 열의 헤더는 좌우 여백과 글자 크기를 보정하고 줄바꿈을 금지해 한 줄로 표시한다.
|
||||
- 매우 긴 보고서명은 셀 오른쪽에서 말줄임하고 `title`로 전체 이름을 확인할 수 있다.
|
||||
|
||||
## 25. 2026-07-29 조사 상세현황 실제 집계 API 연결
|
||||
|
||||
- 기존 화면이 호출하던 `GET /api/v1/exmnBusinesses/{id}/sections?jobType=PPL|MNHL` 백엔드 API가 구현됐다.
|
||||
- 관로조사는 처리분구별 대상연장·주행거리와 PPL 전체이상·구조·운영·기타 집계를 표시한다.
|
||||
- 맨홀조사는 처리분구별 MNHL 전체이상·구조·운영·기타 집계를 표시한다.
|
||||
- 결과 미등록 사업은 서버가 빈 배열을 반환하므로 오류 배너 없이 기존 빈 그리드 문구를 유지한다.
|
||||
- 사업·탭을 빠르게 전환했을 때 이전 응답을 무시하는 기존 요청 순서 보호는 그대로 사용한다.
|
||||
- 이번 단계에서는 프론트 소스 계약 변경이 없으며 상세보기·본단위구간·결함별 조회·다운로드는 연결하지 않았다.
|
||||
|
||||
## 26. 2026-07-29 조사 상세보기 전체 집계 API 연결
|
||||
|
||||
- 처리분구 행 선택 후 여는 기존 상세보기 팝업의 관로 `PIPE` 모드와 맨홀 전체 표를 실제 `/api/v1/exmnFacilities` 응답에 연결했다.
|
||||
- 선택 사업 ID, 처리분구 ID, 조사유형과 조회 버튼으로 확정한 관리번호를 서버에 전달한다.
|
||||
- 서버의 시설 기본 컬럼과 `defectCounts[code].L/M/S`를 기존 다단표·소계·합계·정렬 화면 모델에 그대로 사용한다.
|
||||
- 요청 중 안내와 조회 실패 메시지를 구분하고, 빠른 조건 변경 시 이전 요청 응답을 화면에 반영하지 않는다.
|
||||
- 샘플 모드는 기존 데이터를 유지한다.
|
||||
- 본단위구간과 구조·운영 결함별 실데이터 연결은 후속 단계 안내를 유지한다.
|
||||
|
||||
## 27. 2026-07-29 맨홀 전체 상세표 표시 보정
|
||||
|
||||
- 맨홀조사의 시설 식별자를 별도 맨홀번호가 아닌 관로번호로 통일했다.
|
||||
- 관로 전체표는 보고서번호부터 주행거리까지만 표시하고 구조적·운영적 등급은 본단위구간 화면에 유지한다.
|
||||
- 맨홀 전체표는 관로 공통 열 다음에 구조적 내부결함, 운영적 내부결함 등급 열을 추가하고 소계·합계에도 숫자 합산값을 표시한다.
|
||||
- 고정 열 뒤의 결함 코드 열만 PPL·MNHL 정의에 따라 다르게 표시한다.
|
||||
- 화면 다운로드도 관로는 주행거리까지, 맨홀은 구조·운영 등급까지 고정 열을 구성한 뒤 조사유형별 결함 코드 열을 추가한다.
|
||||
|
||||
## 28. 2026-07-29 상세보기 처리분구 조회 조건 연계
|
||||
|
||||
- 상세보기 팝업의 처리분구 선택값을 URL로 전달받은 한 건의 고정 옵션에서 사업·조사유형별 실제 상세현황 처리분구 목록으로 변경했다.
|
||||
- 관로 상세보기는 PPL 결과가 있는 처리분구, 맨홀 상세보기는 MNHL 결과가 있는 처리분구만 기존 `/sections?jobType=PPL|MNHL` API로 조회한다.
|
||||
- 조사 상세현황에서 선택한 처리분구를 팝업 최초 기본값으로 유지하고, 목록에 없을 때만 해당 조사유형의 첫 처리분구로 대체한다.
|
||||
- 처리분구를 변경하면 관리번호·정렬·안내와 이전 상세 행을 초기화한 뒤 선택한 처리분구의 시설 상세를 다시 조회한다.
|
||||
- 표 제목과 화면 엑셀 다운로드 파일명도 현재 선택한 처리분구명으로 갱신한다.
|
||||
|
||||
## 29. 2026-07-29 관로 본단위구간 실데이터 연결
|
||||
|
||||
- 관로 상세보기의 `본단위구간` 모드를 신규 PPL 단위구간 조회 API에 연결했다.
|
||||
- 관리번호가 비어 있으면 현재 처리분구의 전체 관로 단위구간을 표시하고, 입력 후 조회하면 부분 일치 결과를 표시한다.
|
||||
- 서버의 상태·결함 코드를 기존 관로 결함 코드표와 상태 코드표에 매핑해 엑셀형 14열 표에 표시한다.
|
||||
- 상세 기록이 없는 단위구간은 결함 필드를 하이픈으로 표시하되 관로번호·단위구간·단위길이는 유지한다.
|
||||
- 처리분구·모드·관리번호를 빠르게 바꿨을 때 이전 요청이 새 화면을 덮어쓰지 않도록 기존 effect 취소 흐름을 적용했다.
|
||||
- 실제 API 조회와 현재 화면 기준 엑셀 다운로드를 유지한다.
|
||||
|
||||
### 본단위구간 표 너비 보정
|
||||
|
||||
- 표의 기존 1422px 최소 너비는 유지하면서 실제 너비를 스크롤 컨테이너의 100%로 확장했다.
|
||||
- 데이터 양에 따라 세로 스크롤바가 생기거나 사라져도 오른쪽에 스크롤바 폭만큼 빈 영역이 남지 않는다.
|
||||
|
||||
### 조사중단사유·비고 셀 분리
|
||||
|
||||
- 조사중단사유와 비고는 단위구간 기준 병합 대상에서 제외했다.
|
||||
- 두 열은 본단위구간의 각 상세 행마다 독립된 셀과 구분선을 표시한다.
|
||||
|
||||
## 30. 2026-07-29 구조·운영 결함별 실데이터 연결
|
||||
|
||||
- 조사 상세현황의 구조적·운영적 결함 건수 링크에서 전달한 `defectType`을 실제 시설 상세 API에 연결했다.
|
||||
- 결함별 팝업은 기존 설계대로 처리분구·관리번호·모드·조회 영역을 표시하지 않고 선택 분류 결과를 즉시 조회한다.
|
||||
- 서버가 반환한 선택 분류의 시설 행과 코드 열만 표시하고 화면 행·열 기준 소계와 합계를 다시 계산한다.
|
||||
- 후속 구현 안내 문구를 제거하고 조회 중·오류 상태를 실제 API 요청 상태로 표시한다.
|
||||
- 결함별 다운로드는 현재 표시 행·열과 코드 헤더, 소계·합계 구조를 그대로 사용한다.
|
||||
|
||||
## 31. 2026-07-30 조사 상세현황 다중 시트 다운로드 연결
|
||||
|
||||
- `downloadExmnSectionSummary` API 함수를 추가해 현재 선택한 조사유형을 서버 다운로드 파라미터로 전달한다.
|
||||
- 조사 상세현황 다운로드 버튼은 서버가 생성한 XLSX Blob을 사업명·조사유형 기반 파일명으로 저장한다.
|
||||
- 다운로드 중에는 버튼을 비활성화하고 `다운로드 중...` 상태를 표시한다.
|
||||
- 조회 결과가 없으면 다운로드를 막고 기존 안내 영역에 메시지를 표시한다.
|
||||
- 조사 상세현황 다운로드는 샘플 분기 없이 실제 서버 API만 호출한다.
|
||||
- 브라우저에서 종합집계 1개 시트만 만들던 임시 공통 Excel 함수 호출은 제거했다.
|
||||
- 관로 본단위구간은 이번 통합 다운로드 대상에 포함하지 않는다.
|
||||
|
||||
### 수정 파일
|
||||
|
||||
- `src/features/exmn/api/exmn-business-api.ts`
|
||||
- `src/features/exmn/ui/ExmnStatusDetailPanel.tsx`
|
||||
|
||||
## 32. 2026-07-30 샘플 데이터 보기 기능 제거
|
||||
|
||||
- 조사사업 목록의 개발환경용 `샘플 데이터 보기`·`실데이터 보기` 버튼을 제거했다.
|
||||
- `sample=Y` URL 파라미터와 목록·사업등록/수정·결과등록·상세현황·본단위구간의 샘플 분기를 제거했다.
|
||||
- 샘플 사업, 처리분구, 검토 결과, 집계, 시설 상세와 본단위구간을 제공하던 `exmn-sample-data.ts`를 삭제했다.
|
||||
- 조사관리 화면과 WindowPopup은 모두 실제 API 데이터만 조회·저장하도록 단일화했다.
|
||||
- 백엔드 양식 생성기의 예시행 정리 로직은 실제 조사입력 양식 생성에 필요한 기능이므로 이번 제거 대상에 포함하지 않았다.
|
||||
|
||||
### 수정 파일
|
||||
|
||||
- `src/features/exmn/model/exmn-sample-data.ts` 삭제
|
||||
- `src/features/exmn/ui/ExmnRegistrationPage.tsx`
|
||||
- `src/features/exmn/ui/ExmnStatusDetailPanel.tsx`
|
||||
- `src/features/exmn/ui/ExmnBusinessRegisterPopupPage.tsx`
|
||||
- `src/features/exmn/ui/ExmnResultRegisterPopupPage.tsx`
|
||||
- `src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx`
|
||||
- `src/features/windowPopup/ui/ExmnBusinessRegisterPopupContent.tsx`
|
||||
- `src/features/windowPopup/ui/ExmnResultRegisterPopupContent.tsx`
|
||||
- `src/features/windowPopup/ui/ExmnSurveyDetailPopupContent.tsx`
|
||||
|
||||
## 33. 2026-07-30 샘플 제거 변경분 코드 리뷰
|
||||
|
||||
- 목록 최초 조회, 등록·수정 완료 후 목록 재조회, 결과등록 검토·저장, 상세현황·본단위구간 실제 API 호출 흐름을 다시 확인했다.
|
||||
- WindowPopup content와 화면 컴포넌트 사이에서 제거된 `sampleMode`·샘플 사업 props가 남아 있지 않음을 확인했다.
|
||||
- 실제 사업 요약 query, 사업 ID, 처리분구 ID, 조사유형과 결함유형 전달 계약은 유지했다.
|
||||
- 기능 범위의 `sampleMode`, `sample=`, `exmn-sample-data`, 샘플 식별자 런타임 참조는 0건이다.
|
||||
- 코드 추가 수정이 필요한 결함은 발견되지 않아 샘플 제거 변경분을 유지했다.
|
||||
- `git diff --check`는 통과했다. 로컬 TypeScript 모듈 접근은 기존 `EPERM` 환경 문제로 실행하지 못했고 전체 빌드는 사용자 요청에 따라 수행하지 않았다.
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
import {
|
||||
getBlobData,
|
||||
getData,
|
||||
getRawJson,
|
||||
postMutiPart,
|
||||
postRawJson,
|
||||
putData,
|
||||
} from "@/components/lib/api";
|
||||
import type {
|
||||
ExmnAttachmentKey,
|
||||
ExmnBusiness,
|
||||
@@ -23,6 +15,22 @@ import type {
|
||||
PplFacilitySearchParams,
|
||||
PplSummaryRow,
|
||||
} from "../model/exmn-business-types";
|
||||
import {
|
||||
type ExmnDefectGroup,
|
||||
type ExmnDetailSeverity,
|
||||
type ExmnPplUnitDetailRow,
|
||||
type ExmnSurveyDetailRow,
|
||||
PIPE_DETAIL_DEFECT_COLUMNS,
|
||||
} from "../model/exmn-detail-table";
|
||||
|
||||
import {
|
||||
getBlobData,
|
||||
getData,
|
||||
getRawJson,
|
||||
postMutiPart,
|
||||
postRawJson,
|
||||
putData,
|
||||
} from "@/components/lib/api";
|
||||
|
||||
/** 조사사업 API의 공통 기준 경로다. */
|
||||
const EXMN_BUSINESS_API_PATH = "v1/exmnBusinesses";
|
||||
@@ -132,6 +140,109 @@ export const getExmnSectionSummaries = async (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 현재 조사유형의 종합집계 1개와 처리분구별 전체 상세 시트를 하나의 XLSX로 내려받는다.
|
||||
* 관로 본단위구간은 이 다운로드 계약에 포함하지 않는다.
|
||||
*/
|
||||
export const downloadExmnSectionSummary = async (
|
||||
exmnBizId: string,
|
||||
jobType: ExmnJobType,
|
||||
): Promise<Blob> => {
|
||||
const query = new URLSearchParams({ jobType });
|
||||
return getBlobData(
|
||||
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/sections/download?${query}`,
|
||||
);
|
||||
};
|
||||
|
||||
/** 선택 사업·처리분구의 관로·맨홀 전체 상세 집계 행을 조회한다. */
|
||||
export const getExmnFacilityDetails = async (params: {
|
||||
exmnBizId: string;
|
||||
jobType: ExmnJobType;
|
||||
trtdstId: string;
|
||||
gisManagementNo?: string;
|
||||
defectType?: ExmnDefectGroup | "ALL";
|
||||
}): Promise<ExmnSurveyDetailRow[]> => {
|
||||
const query = new URLSearchParams({
|
||||
exmnBizId: params.exmnBizId,
|
||||
jobType: params.jobType,
|
||||
trtdstId: params.trtdstId,
|
||||
});
|
||||
if (params.gisManagementNo?.trim()) {
|
||||
query.set("gisManagementNo", params.gisManagementNo.trim());
|
||||
}
|
||||
if (params.defectType && params.defectType !== "ALL") {
|
||||
query.set("defectType", params.defectType);
|
||||
}
|
||||
const response = await getData(`v1/exmnFacilities?${query}`) as
|
||||
ExmnSurveyDetailRow[] | { data?: ExmnSurveyDetailRow[] };
|
||||
const payload = unwrapData<ExmnSurveyDetailRow[]>(response);
|
||||
return Array.isArray(payload) ? payload : [];
|
||||
};
|
||||
|
||||
interface ExmnPplUnitDetailPayload {
|
||||
pipeNo: string;
|
||||
unitSeq: number;
|
||||
unitLengthM: number;
|
||||
defectPositionM: number | null;
|
||||
code: string;
|
||||
recordType: "STATUS" | "DEFECT" | null;
|
||||
defectType: ExmnDefectGroup | null;
|
||||
severity: ExmnDetailSeverity | null;
|
||||
occurrencePosition: string;
|
||||
infiltrationBonus: number | null;
|
||||
continuous: boolean | null;
|
||||
structuralScore: number | null;
|
||||
operationalScore: number | null;
|
||||
surveyStopReason: string | null;
|
||||
remark: string | null;
|
||||
}
|
||||
|
||||
const PPL_STATUS_LABELS: Record<string, string> = {
|
||||
IS: "조사시작(순주행)",
|
||||
ISR: "조사시작(역주행)",
|
||||
IE: "조사완료(순주행)",
|
||||
IER: "조사완료(역주행)",
|
||||
IA: "조사중단",
|
||||
JE: "이음부(접합부)존재",
|
||||
WD: "이상 없음",
|
||||
};
|
||||
|
||||
const getPplUnitDetailName = (row: ExmnPplUnitDetailPayload) => {
|
||||
const code = row.code?.trim();
|
||||
if (!code) return "";
|
||||
const normalizedCode = code.toUpperCase();
|
||||
if (row.recordType === "STATUS") {
|
||||
return `(상태)${PPL_STATUS_LABELS[normalizedCode] ?? code}`;
|
||||
}
|
||||
const defect = PIPE_DETAIL_DEFECT_COLUMNS.find(
|
||||
(column) => column.code.toUpperCase() === normalizedCode,
|
||||
);
|
||||
return `(관로)${defect?.label ?? code}`;
|
||||
};
|
||||
|
||||
/** 선택 처리분구의 PPL_UNIT_V1 본단위구간 상태·결함 행을 조회한다. */
|
||||
export const getExmnPplUnitDetails = async (params: {
|
||||
exmnBizId: string;
|
||||
trtdstId: string;
|
||||
gisManagementNo?: string;
|
||||
}): Promise<ExmnPplUnitDetailRow[]> => {
|
||||
const query = new URLSearchParams({
|
||||
exmnBizId: params.exmnBizId,
|
||||
trtdstId: params.trtdstId,
|
||||
});
|
||||
if (params.gisManagementNo?.trim()) {
|
||||
query.set("gisManagementNo", params.gisManagementNo.trim());
|
||||
}
|
||||
const response = await getData(`v1/exmnFacilities/pplUnitSections?${query}`) as
|
||||
ExmnPplUnitDetailPayload[] | { data?: ExmnPplUnitDetailPayload[] };
|
||||
const payload = unwrapData<ExmnPplUnitDetailPayload[]>(response);
|
||||
if (!Array.isArray(payload)) return [];
|
||||
return payload.map((row) => ({
|
||||
...row,
|
||||
defectName: getPplUnitDetailName(row),
|
||||
}));
|
||||
};
|
||||
|
||||
/** 선택한 조사사업의 처리분구·첨부를 포함한 상세정보를 조회한다. */
|
||||
export const getExmnBusiness = async (exmnBizId: string): Promise<ExmnBusinessDetail> =>
|
||||
getData(`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}`) as Promise<ExmnBusinessDetail>;
|
||||
|
||||
@@ -23,9 +23,11 @@ export interface ExmnSurveyDetailRow {
|
||||
downstreamManhole: string;
|
||||
drainageType: string;
|
||||
pipeType: string;
|
||||
specification: number;
|
||||
specification: string | number;
|
||||
extensionM: number;
|
||||
drivingDistanceM: number;
|
||||
structuralGrade: string;
|
||||
operationalGrade: string;
|
||||
anomalyTotals: ExmnSeverityCounts;
|
||||
defectCounts: Record<string, ExmnSeverityCounts>;
|
||||
}
|
||||
@@ -38,14 +40,14 @@ export interface ExmnPplUnitDetailRow {
|
||||
pipeNo: string;
|
||||
unitSeq: number;
|
||||
unitLengthM: number;
|
||||
defectPositionM: number;
|
||||
defectPositionM: number | null;
|
||||
defectName: string;
|
||||
code: string;
|
||||
recordType: "STATUS" | "DEFECT";
|
||||
recordType: "STATUS" | "DEFECT" | null;
|
||||
defectType: ExmnDefectGroup | null;
|
||||
severity: ExmnDetailSeverity | null;
|
||||
occurrencePosition: string;
|
||||
infiltrationBonus: number;
|
||||
infiltrationBonus: number | null;
|
||||
continuous: boolean | null;
|
||||
structuralScore: number | null;
|
||||
operationalScore: number | null;
|
||||
|
||||
@@ -1,462 +0,0 @@
|
||||
import type {
|
||||
ExmnBusiness,
|
||||
ExmnBusinessDetail,
|
||||
ExmnImportValidationResult,
|
||||
ExmnJobType,
|
||||
ExmnSectionSummaryRow,
|
||||
} from "./exmn-business-types";
|
||||
import {
|
||||
EXMN_DETAIL_SEVERITIES,
|
||||
EXMN_DETAIL_TABLE_DEFINITIONS,
|
||||
type ExmnPplUnitDetailRow,
|
||||
type ExmnSeverityCounts,
|
||||
type ExmnSurveyDetailRow,
|
||||
} from "./exmn-detail-table";
|
||||
import type { TrtdstSettingDto } from "./trtdst-types";
|
||||
|
||||
/** 백엔드 데이터가 준비되기 전 명시적 sample=Y 화면 검증에서만 사용하는 조사사업 목록이다. */
|
||||
export const EXMN_SAMPLE_BUSINESSES: ExmnBusiness[] = [
|
||||
{
|
||||
exmnBizId: "SAMPLE-BIZ-001",
|
||||
bizYr: "2026",
|
||||
bizNm: "2026년 양평군 노후 하수관로 정밀조사사업",
|
||||
bizCycl: 3,
|
||||
ctpvNm: "경기도",
|
||||
sggNm: "양평군",
|
||||
trtdstCnt: 10,
|
||||
prcisExmnPrlg: 18.42,
|
||||
tpco: 1_250_000_000,
|
||||
reportYn: "Y",
|
||||
bizRptpAtchFileCd: "SAMPLE",
|
||||
bizRptpAtchFileId: "SAMPLE-REPORT-001",
|
||||
bizRptpFileName: "2026년_양평군_정밀조사_사업보고서.pdf",
|
||||
},
|
||||
{
|
||||
exmnBizId: "SAMPLE-BIZ-002",
|
||||
bizYr: "2025",
|
||||
bizNm: "2025년 이천시 하수관로 CCTV 조사사업",
|
||||
bizCycl: 2,
|
||||
ctpvNm: "경기도",
|
||||
sggNm: "이천시",
|
||||
trtdstCnt: 10,
|
||||
prcisExmnPrlg: 12.75,
|
||||
tpco: 890_000_000,
|
||||
reportYn: "N",
|
||||
},
|
||||
{
|
||||
exmnBizId: "SAMPLE-BIZ-003",
|
||||
bizYr: "2024",
|
||||
bizNm: "2024년 원주시 관로·맨홀 정밀조사사업",
|
||||
bizCycl: 1,
|
||||
ctpvNm: "강원특별자치도",
|
||||
sggNm: "원주시",
|
||||
trtdstCnt: 10,
|
||||
prcisExmnPrlg: 15.1,
|
||||
tpco: 1_030_000_000,
|
||||
reportYn: "Y",
|
||||
bizRptpAtchFileCd: "SAMPLE",
|
||||
bizRptpAtchFileId: "SAMPLE-REPORT-003",
|
||||
bizRptpFileName: "2024년_원주시_관로맨홀_사업보고서.pdf",
|
||||
},
|
||||
{
|
||||
exmnBizId: "SAMPLE-BIZ-004",
|
||||
bizYr: "2023",
|
||||
bizNm: "2023년 청주시 하수관로 정밀조사사업",
|
||||
bizCycl: 4,
|
||||
ctpvNm: "충청북도",
|
||||
sggNm: "청주시",
|
||||
trtdstCnt: 10,
|
||||
prcisExmnPrlg: 9.86,
|
||||
tpco: 760_000_000,
|
||||
reportYn: "N",
|
||||
},
|
||||
];
|
||||
|
||||
/** 사업등록 팝업의 체크박스·가나다순 정렬을 검증하는 활성 처리분구 샘플이다. */
|
||||
export const EXMN_SAMPLE_TRTDST_SETTINGS: TrtdstSettingDto[] = [
|
||||
"갈산처리분구",
|
||||
"개군처리분구",
|
||||
"공흥처리분구",
|
||||
"국수처리분구",
|
||||
"단월처리분구",
|
||||
"덕평처리분구",
|
||||
"문호처리분구",
|
||||
"백안처리분구",
|
||||
"서종처리분구",
|
||||
"양근처리분구",
|
||||
"양동처리분구",
|
||||
"양서처리분구",
|
||||
"오빈처리분구",
|
||||
"용문처리분구",
|
||||
"용천처리분구",
|
||||
"지평처리분구",
|
||||
"청운처리분구",
|
||||
"회현처리분구",
|
||||
"강상처리분구",
|
||||
"강하처리분구",
|
||||
"옥천처리분구",
|
||||
].map((trtdstNm, index) => ({
|
||||
trtdstId: `SAMPLE-TRTDST-${String(index + 1).padStart(2, "0")}`,
|
||||
ctpvNm: "경기도",
|
||||
sggNm: "양평군",
|
||||
trtdstNm,
|
||||
useYn: "Y",
|
||||
}));
|
||||
|
||||
/** 선택 사업의 처리분구와 첨부 영역을 팝업에서 확인할 수 있도록 상세 샘플을 만든다. */
|
||||
export const getExmnSampleBusinessDetail = (
|
||||
exmnBizId: string,
|
||||
fallbackBusiness?: ExmnBusiness,
|
||||
): ExmnBusinessDetail => {
|
||||
const business = EXMN_SAMPLE_BUSINESSES.find((item) => item.exmnBizId === exmnBizId)
|
||||
?? fallbackBusiness
|
||||
?? EXMN_SAMPLE_BUSINESSES[0];
|
||||
const districts = EXMN_SAMPLE_TRTDST_SETTINGS.slice(0, business.trtdstCnt).map((item) => ({
|
||||
trtdstId: item.trtdstId,
|
||||
trtdstNm: item.trtdstNm,
|
||||
pplExmnSheetNm: `${item.trtdstNm}_PPL`,
|
||||
mnhlExmnSheetNm: `${item.trtdstNm}_MNHL`,
|
||||
}));
|
||||
|
||||
return {
|
||||
...business,
|
||||
trtdstIds: districts.map((item) => item.trtdstId),
|
||||
districts,
|
||||
};
|
||||
};
|
||||
|
||||
/** 결과등록 팝업의 데이터 검토·저장 활성 상태를 확인하는 정상 샘플 결과다. */
|
||||
export const EXMN_SAMPLE_IMPORT_VALIDATION: ExmnImportValidationResult = {
|
||||
valid: true,
|
||||
fileHash: "SAMPLE-FILE-HASH-PREVIEW",
|
||||
summary: {
|
||||
pplCount: 24,
|
||||
mnhlCount: 12,
|
||||
},
|
||||
errors: [],
|
||||
};
|
||||
|
||||
/** 조사현황의 사업 행 선택과 관로·맨홀 탭 전환을 검증하는 처리분구 집계 샘플이다. */
|
||||
export const getExmnSampleSectionSummaries = (
|
||||
exmnBizId: string,
|
||||
jobType: ExmnJobType,
|
||||
): ExmnSectionSummaryRow[] => {
|
||||
const business = EXMN_SAMPLE_BUSINESSES.find((item) => item.exmnBizId === exmnBizId)
|
||||
?? EXMN_SAMPLE_BUSINESSES[0];
|
||||
const districts = EXMN_SAMPLE_TRTDST_SETTINGS.slice(0, business.trtdstCnt);
|
||||
|
||||
return districts.map((district, index) => {
|
||||
const scale = index + 1;
|
||||
const pplStructural = 21 * scale;
|
||||
const pplOperational = 7 * scale;
|
||||
const pplEtc = 2 * scale;
|
||||
const mnhlStructural = 5 * scale;
|
||||
const mnhlOperational = 3 * scale;
|
||||
const mnhlEtc = scale;
|
||||
|
||||
return {
|
||||
trtdstId: district.trtdstId,
|
||||
trtdstNm: district.trtdstNm,
|
||||
trgtPrlg: Number((business.prcisExmnPrlg * 1000 / districts.length).toFixed(1)),
|
||||
drvngDstnc: Number((business.prcisExmnPrlg * 930 / districts.length).toFixed(1)),
|
||||
pplExmnWholAnmlyArtclCnt: jobType === "PPL" ? pplStructural + pplOperational + pplEtc : 0,
|
||||
pplExmnStrctDfcCnt: jobType === "PPL" ? pplStructural : 0,
|
||||
pplExmnOperDfcCnt: jobType === "PPL" ? pplOperational : 0,
|
||||
pplExmnEtcCnt: jobType === "PPL" ? pplEtc : 0,
|
||||
mnhlExmnWholAnmlyArtclCnt: jobType === "MNHL" ? mnhlStructural + mnhlOperational + mnhlEtc : 0,
|
||||
mnhlExmnStrctDfcCnt: jobType === "MNHL" ? mnhlStructural : 0,
|
||||
mnhlExmnOperDfcCnt: jobType === "MNHL" ? mnhlOperational : 0,
|
||||
mnhlExmnEtcCnt: jobType === "MNHL" ? mnhlEtc : 0,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const EMPTY_SEVERITY_COUNTS: ExmnSeverityCounts = { L: 0, M: 0, S: 0 };
|
||||
|
||||
/** 열 코드와 행 순번으로 화면 확인용 희소 결함 건수를 일관되게 만든다. */
|
||||
const buildSampleDefectCounts = (
|
||||
jobType: ExmnJobType,
|
||||
rowIndex: number,
|
||||
): Record<string, ExmnSeverityCounts> => Object.fromEntries(
|
||||
EXMN_DETAIL_TABLE_DEFINITIONS[jobType].defectColumns.map((column, columnIndex) => {
|
||||
const counts = { ...EMPTY_SEVERITY_COUNTS };
|
||||
EXMN_DETAIL_SEVERITIES.forEach((severity, severityIndex) => {
|
||||
const seed = columnIndex + rowIndex * 3 + severityIndex;
|
||||
if (seed % 11 === 0) {
|
||||
counts[severity] = rowIndex + severityIndex + 1;
|
||||
}
|
||||
});
|
||||
return [column.code, counts];
|
||||
}),
|
||||
);
|
||||
|
||||
/** 코드별 건수를 합산해 엑셀의 이상항목 합계 대·중·소 샘플을 만든다. */
|
||||
const sumSampleDefects = (
|
||||
defectCounts: Record<string, ExmnSeverityCounts>,
|
||||
): ExmnSeverityCounts => EXMN_DETAIL_SEVERITIES.reduce(
|
||||
(totals, severity) => {
|
||||
totals[severity] = Object.values(defectCounts).reduce(
|
||||
(sum, counts) => sum + counts[severity],
|
||||
0,
|
||||
);
|
||||
return totals;
|
||||
},
|
||||
{ ...EMPTY_SEVERITY_COUNTS },
|
||||
);
|
||||
|
||||
const SAMPLE_FACILITIES = [
|
||||
{
|
||||
reportNo: "118-0026",
|
||||
pipeNo: "118-0026",
|
||||
upstreamManhole: "118-0026M",
|
||||
downstreamManhole: "118-0027M",
|
||||
extensionM: 25.5,
|
||||
drivingDistanceM: 25.5,
|
||||
},
|
||||
{
|
||||
reportNo: "118-0027",
|
||||
pipeNo: "118-0027",
|
||||
upstreamManhole: "118-0027M",
|
||||
downstreamManhole: "118-0028M",
|
||||
extensionM: 27,
|
||||
drivingDistanceM: 27,
|
||||
},
|
||||
{
|
||||
reportNo: "118-0027-1",
|
||||
pipeNo: "118-0027-1",
|
||||
upstreamManhole: "118-0027-1M",
|
||||
downstreamManhole: "118-0028M",
|
||||
extensionM: 13.9,
|
||||
drivingDistanceM: 13.9,
|
||||
},
|
||||
] as const;
|
||||
|
||||
/** 상세보기 WindowPopup에서 관로·맨홀 엑셀형 표를 확인하는 샘플 3행이다. */
|
||||
export const getExmnSampleSurveyDetails = (
|
||||
jobType: ExmnJobType,
|
||||
): ExmnSurveyDetailRow[] => SAMPLE_FACILITIES.map((facility, rowIndex) => {
|
||||
const defectCounts = buildSampleDefectCounts(jobType, rowIndex);
|
||||
const anomalyTotals = sumSampleDefects(defectCounts);
|
||||
return {
|
||||
...facility,
|
||||
drainageType: "합류",
|
||||
pipeType: "HP",
|
||||
specification: 600,
|
||||
anomalyTotals,
|
||||
defectCounts,
|
||||
};
|
||||
});
|
||||
|
||||
/** 엑셀 `본단위등급표`의 한 관로를 단위구간별 병합 표로 확인하는 샘플 원형이다. */
|
||||
const EXMN_SAMPLE_PPL_UNIT_DETAIL_TEMPLATE: ExmnPplUnitDetailRow[] = [
|
||||
{
|
||||
pipeNo: "118-0026",
|
||||
unitSeq: 1,
|
||||
unitLengthM: 6,
|
||||
defectPositionM: 0,
|
||||
defectName: "(상태)조사시작(순주행)",
|
||||
code: "IS",
|
||||
recordType: "STATUS",
|
||||
defectType: null,
|
||||
severity: null,
|
||||
occurrencePosition: "0/0",
|
||||
infiltrationBonus: 0,
|
||||
continuous: null,
|
||||
structuralScore: null,
|
||||
operationalScore: null,
|
||||
surveyStopReason: null,
|
||||
remark: null,
|
||||
},
|
||||
{
|
||||
pipeNo: "118-0026",
|
||||
unitSeq: 1,
|
||||
unitLengthM: 6,
|
||||
defectPositionM: 5.4,
|
||||
defectName: "(관로)변형",
|
||||
code: "DF",
|
||||
recordType: "DEFECT",
|
||||
defectType: "STRUCTURAL",
|
||||
severity: "S",
|
||||
occurrencePosition: "10/2",
|
||||
infiltrationBonus: 0,
|
||||
continuous: false,
|
||||
structuralScore: 25,
|
||||
operationalScore: null,
|
||||
surveyStopReason: null,
|
||||
remark: null,
|
||||
},
|
||||
{
|
||||
pipeNo: "118-0026",
|
||||
unitSeq: 1,
|
||||
unitLengthM: 6,
|
||||
defectPositionM: 6,
|
||||
defectName: "(관로)이음부이탈",
|
||||
code: "JS",
|
||||
recordType: "DEFECT",
|
||||
defectType: "STRUCTURAL",
|
||||
severity: "L",
|
||||
occurrencePosition: "4/6",
|
||||
infiltrationBonus: 0,
|
||||
continuous: null,
|
||||
structuralScore: 35,
|
||||
operationalScore: null,
|
||||
surveyStopReason: null,
|
||||
remark: null,
|
||||
},
|
||||
{
|
||||
pipeNo: "118-0026",
|
||||
unitSeq: 1,
|
||||
unitLengthM: 6,
|
||||
defectPositionM: 6,
|
||||
defectName: "(상태)이음부(접합부)존재",
|
||||
code: "JE",
|
||||
recordType: "STATUS",
|
||||
defectType: null,
|
||||
severity: null,
|
||||
occurrencePosition: "0/0",
|
||||
infiltrationBonus: 0,
|
||||
continuous: null,
|
||||
structuralScore: null,
|
||||
operationalScore: null,
|
||||
surveyStopReason: null,
|
||||
remark: null,
|
||||
},
|
||||
{
|
||||
pipeNo: "118-0026",
|
||||
unitSeq: 2,
|
||||
unitLengthM: 6,
|
||||
defectPositionM: 6,
|
||||
defectName: "(상태)이음부(접합부)존재",
|
||||
code: "JE",
|
||||
recordType: "STATUS",
|
||||
defectType: null,
|
||||
severity: null,
|
||||
occurrencePosition: "0/0",
|
||||
infiltrationBonus: 0,
|
||||
continuous: null,
|
||||
structuralScore: null,
|
||||
operationalScore: null,
|
||||
surveyStopReason: null,
|
||||
remark: null,
|
||||
},
|
||||
{
|
||||
pipeNo: "118-0026",
|
||||
unitSeq: 2,
|
||||
unitLengthM: 6,
|
||||
defectPositionM: 6,
|
||||
defectName: "(관로)이음부이탈",
|
||||
code: "JS",
|
||||
recordType: "DEFECT",
|
||||
defectType: "STRUCTURAL",
|
||||
severity: "L",
|
||||
occurrencePosition: "4/6",
|
||||
infiltrationBonus: 0,
|
||||
continuous: null,
|
||||
structuralScore: 35,
|
||||
operationalScore: null,
|
||||
surveyStopReason: null,
|
||||
remark: null,
|
||||
},
|
||||
{
|
||||
pipeNo: "118-0026",
|
||||
unitSeq: 2,
|
||||
unitLengthM: 6,
|
||||
defectPositionM: 7,
|
||||
defectName: "(관로)균열원주",
|
||||
code: "CC",
|
||||
recordType: "DEFECT",
|
||||
defectType: "STRUCTURAL",
|
||||
severity: "L",
|
||||
occurrencePosition: "11/12",
|
||||
infiltrationBonus: 0,
|
||||
continuous: null,
|
||||
structuralScore: 40,
|
||||
operationalScore: null,
|
||||
surveyStopReason: null,
|
||||
remark: null,
|
||||
},
|
||||
{
|
||||
pipeNo: "118-0026",
|
||||
unitSeq: 2,
|
||||
unitLengthM: 6,
|
||||
defectPositionM: 9.7,
|
||||
defectName: "(관로)균열원주",
|
||||
code: "CC",
|
||||
recordType: "DEFECT",
|
||||
defectType: "STRUCTURAL",
|
||||
severity: "L",
|
||||
occurrencePosition: "7/8",
|
||||
infiltrationBonus: 0,
|
||||
continuous: null,
|
||||
structuralScore: 40,
|
||||
operationalScore: null,
|
||||
surveyStopReason: null,
|
||||
remark: null,
|
||||
},
|
||||
{
|
||||
pipeNo: "118-0026",
|
||||
unitSeq: 2,
|
||||
unitLengthM: 6,
|
||||
defectPositionM: 11.6,
|
||||
defectName: "(관로)변형",
|
||||
code: "DF",
|
||||
recordType: "DEFECT",
|
||||
defectType: "STRUCTURAL",
|
||||
severity: "S",
|
||||
occurrencePosition: "10/12",
|
||||
infiltrationBonus: 0,
|
||||
continuous: false,
|
||||
structuralScore: 25,
|
||||
operationalScore: null,
|
||||
surveyStopReason: null,
|
||||
remark: null,
|
||||
},
|
||||
{
|
||||
pipeNo: "118-0026",
|
||||
unitSeq: 2,
|
||||
unitLengthM: 6,
|
||||
defectPositionM: 12,
|
||||
defectName: "(관로)이음부이탈",
|
||||
code: "JS",
|
||||
recordType: "DEFECT",
|
||||
defectType: "STRUCTURAL",
|
||||
severity: "L",
|
||||
occurrencePosition: "5/7",
|
||||
infiltrationBonus: 0,
|
||||
continuous: null,
|
||||
structuralScore: 35,
|
||||
operationalScore: null,
|
||||
surveyStopReason: null,
|
||||
remark: null,
|
||||
},
|
||||
{
|
||||
pipeNo: "118-0026",
|
||||
unitSeq: 2,
|
||||
unitLengthM: 6,
|
||||
defectPositionM: 12,
|
||||
defectName: "(상태)이음부(접합부)존재",
|
||||
code: "JE",
|
||||
recordType: "STATUS",
|
||||
defectType: null,
|
||||
severity: null,
|
||||
occurrencePosition: "0/0",
|
||||
infiltrationBonus: 0,
|
||||
continuous: null,
|
||||
structuralScore: null,
|
||||
operationalScore: null,
|
||||
surveyStopReason: null,
|
||||
remark: null,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 본단위구간 모드 최초 진입 시 처리분구의 관리번호 전체가 보이도록
|
||||
* 관로 상세 샘플 3건에 동일한 단위구간 원형을 적용한다.
|
||||
*/
|
||||
export const EXMN_SAMPLE_PPL_UNIT_DETAILS: ExmnPplUnitDetailRow[] =
|
||||
SAMPLE_FACILITIES.flatMap((facility) =>
|
||||
EXMN_SAMPLE_PPL_UNIT_DETAIL_TEMPLATE.map((row) => ({
|
||||
...row,
|
||||
pipeNo: facility.pipeNo,
|
||||
})),
|
||||
);
|
||||
@@ -19,9 +19,7 @@ import {
|
||||
} from "../api/exmn-business-api";
|
||||
import { getTrtdstSettings } from "../api/trtdst-api";
|
||||
import { validateExmnBusiness } from "../lib/exmn-business-validation";
|
||||
import { EXMN_SAMPLE_TRTDST_SETTINGS } from "../model/exmn-sample-data";
|
||||
import type {
|
||||
ExmnBusiness,
|
||||
ExmnBusinessDetail,
|
||||
ExmnBusinessSaveRequest,
|
||||
} from "../model/exmn-business-types";
|
||||
@@ -33,9 +31,7 @@ const DOWNLOAD_BUTTON_CLASS =
|
||||
type Notice = { type: "success" | "error" | "info"; text: string } | null;
|
||||
|
||||
interface ExmnBusinessRegisterPopupPageProps {
|
||||
sampleMode: boolean;
|
||||
exmnBizId?: string;
|
||||
initialBusiness?: ExmnBusiness;
|
||||
}
|
||||
|
||||
/** 처리분구 이름을 공백 제거 후 한글 가나다순으로 정렬한다. */
|
||||
@@ -54,9 +50,7 @@ const saveBlob = (blob: Blob, fileName: string) => {
|
||||
|
||||
/** 공통 WindowPopup 안에서 사업정보·대상 처리분구·조사양식을 등록·수정하는 화면이다. */
|
||||
export function ExmnBusinessRegisterPopupPage({
|
||||
sampleMode,
|
||||
exmnBizId,
|
||||
initialBusiness,
|
||||
}: ExmnBusinessRegisterPopupPageProps) {
|
||||
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
|
||||
const isEditMode = Boolean(exmnBizId);
|
||||
@@ -65,14 +59,14 @@ export function ExmnBusinessRegisterPopupPage({
|
||||
/** 개발 모드 재실행이나 사업 변경 시 이전 상세조회 응답이 현재 화면을 덮지 않게 한다. */
|
||||
const businessRequestIdRef = useRef(0);
|
||||
/** 사업 확장 시 등록자가 직접 지정할 수 있도록 저장 요청에 전달하는 사업차수다. */
|
||||
const [bizCycl, setBizCycl] = useState(sampleMode ? "3" : "1");
|
||||
const [bizCycl, setBizCycl] = useState("1");
|
||||
const [bizYr, setBizYr] = useState(String(new Date().getFullYear()));
|
||||
const [bizNm, setBizNm] = useState(sampleMode ? "2026년 양평군 노후 하수관로 정밀조사사업" : "");
|
||||
const [bizNm, setBizNm] = useState("");
|
||||
const [ctpvCd, setCtpvCd] = useState("0");
|
||||
const [sggCd, setSggCd] = useState("0");
|
||||
const [ctpvNm, setCtpvNm] = useState(sampleMode ? "경기도" : "");
|
||||
const [sggNm, setSggNm] = useState(sampleMode ? "양평군" : "");
|
||||
const [prcisExmnPrlg, setPrcisExmnPrlg] = useState(sampleMode ? "18.42" : "");
|
||||
const [ctpvNm, setCtpvNm] = useState("");
|
||||
const [sggNm, setSggNm] = useState("");
|
||||
const [prcisExmnPrlg, setPrcisExmnPrlg] = useState("");
|
||||
const [districts, setDistricts] = useState<TrtdstSettingDto[]>([]);
|
||||
/** 등록 후보가 많을 때 처리분구명으로 화면 목록을 좁히는 검색어다. */
|
||||
const [districtKeyword, setDistrictKeyword] = useState("");
|
||||
@@ -104,25 +98,7 @@ export function ExmnBusinessRegisterPopupPage({
|
||||
setIsLoading(true);
|
||||
setNotice(null);
|
||||
|
||||
const sampleDetail = initialBusiness
|
||||
? {
|
||||
...initialBusiness,
|
||||
trtdstIds: EXMN_SAMPLE_TRTDST_SETTINGS
|
||||
.slice(0, initialBusiness.trtdstCnt)
|
||||
.map((district) => district.trtdstId),
|
||||
districts: EXMN_SAMPLE_TRTDST_SETTINGS
|
||||
.slice(0, initialBusiness.trtdstCnt)
|
||||
.map((district) => ({
|
||||
trtdstId: district.trtdstId,
|
||||
trtdstNm: district.trtdstNm,
|
||||
})),
|
||||
} satisfies ExmnBusinessDetail
|
||||
: null;
|
||||
|
||||
const request = sampleMode
|
||||
? Promise.resolve(sampleDetail)
|
||||
: getExmnBusiness(exmnBizId);
|
||||
request
|
||||
getExmnBusiness(exmnBizId)
|
||||
.then((business) => {
|
||||
if (businessRequestIdRef.current !== requestId) return;
|
||||
if (!business) {
|
||||
@@ -155,7 +131,7 @@ export function ExmnBusinessRegisterPopupPage({
|
||||
businessRequestIdRef.current += 1;
|
||||
}
|
||||
};
|
||||
}, [exmnBizId, initialBusiness, sampleMode]);
|
||||
}, [exmnBizId]);
|
||||
|
||||
/** 선택 행정구역의 활성 처리분구를 읽고 화면 요구사항대로 가나다순으로 제공한다. */
|
||||
useEffect(() => {
|
||||
@@ -166,21 +142,6 @@ export function ExmnBusinessRegisterPopupPage({
|
||||
return;
|
||||
}
|
||||
|
||||
if (sampleMode) {
|
||||
const items = sortDistricts(EXMN_SAMPLE_TRTDST_SETTINGS.map((item) => ({
|
||||
...item,
|
||||
ctpvNm,
|
||||
sggNm,
|
||||
})));
|
||||
setDistricts(items);
|
||||
const pendingIds = pendingSelectedDistrictIdsRef.current;
|
||||
setSelectedDistrictIds(pendingIds
|
||||
? pendingIds.filter((id) => items.some((item) => item.trtdstId === id))
|
||||
: []);
|
||||
pendingSelectedDistrictIdsRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
setIsLoading(true);
|
||||
getTrtdstSettings({ ctpvNm, sggNm, useYn: "Y" })
|
||||
@@ -209,11 +170,7 @@ export function ExmnBusinessRegisterPopupPage({
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [ctpvNm, sampleMode, sggNm]);
|
||||
|
||||
/** 선택 개수와 이름을 저장 요청과 양식 생성에서 동일하게 사용한다. */
|
||||
const selectedDistricts = useMemo(() => districts.filter((district) =>
|
||||
selectedDistrictIds.includes(district.trtdstId)), [districts, selectedDistrictIds]);
|
||||
}, [ctpvNm, sggNm]);
|
||||
|
||||
/** 수정 후 저장하지 않은 입력이 있으면 기존 사업 기준 양식 다운로드를 차단한다. */
|
||||
const hasUnsavedChanges = useMemo(() => {
|
||||
@@ -279,7 +236,7 @@ export function ExmnBusinessRegisterPopupPage({
|
||||
trtdstIds: selectedDistrictIds,
|
||||
});
|
||||
|
||||
/** 실제 모드에서는 API를 호출하고 샘플 모드에서는 명시적 미리보기 사업만 부모 목록에 전달한다. */
|
||||
/** 입력값을 검증해 조사사업을 신규 등록하거나 수정한다. */
|
||||
const handleSave = async () => {
|
||||
if (!prcisExmnPrlg.trim()) {
|
||||
setNotice({ type: "error", text: "정밀조사 연장을 입력해주세요." });
|
||||
@@ -295,21 +252,9 @@ export function ExmnBusinessRegisterPopupPage({
|
||||
setIsLoading(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const persisted: ExmnBusinessDetail = sampleMode
|
||||
? {
|
||||
exmnBizId: exmnBizId ?? `SAMPLE-BIZ-${Date.now()}`,
|
||||
...request,
|
||||
trtdstCnt: selectedDistrictIds.length,
|
||||
reportYn: "N",
|
||||
trtdstIds: [...selectedDistrictIds],
|
||||
districts: selectedDistricts.map((district) => ({
|
||||
trtdstId: district.trtdstId,
|
||||
trtdstNm: district.trtdstNm,
|
||||
})),
|
||||
}
|
||||
: isEditMode && exmnBizId
|
||||
? await updateExmnBusiness(exmnBizId, request)
|
||||
: await createExmnBusiness(request);
|
||||
const persisted: ExmnBusinessDetail = isEditMode && exmnBizId
|
||||
? await updateExmnBusiness(exmnBizId, request)
|
||||
: await createExmnBusiness(request);
|
||||
|
||||
setSavedBusiness(persisted);
|
||||
postPopupMessage(
|
||||
@@ -318,17 +263,13 @@ export function ExmnBusinessRegisterPopupPage({
|
||||
: WINDOW_POPUP_MESSAGE_TYPE.EXMN_BUSINESS_CREATED,
|
||||
{
|
||||
exmnBizId: persisted.exmnBizId,
|
||||
sample: sampleMode ? "Y" : "N",
|
||||
business: sampleMode ? JSON.stringify(persisted) : "",
|
||||
},
|
||||
);
|
||||
setNotice({
|
||||
type: "success",
|
||||
text: isEditMode
|
||||
? "사업정보를 수정했습니다. 변경된 처리분구 기준으로 양식을 다시 받을 수 있습니다."
|
||||
: sampleMode
|
||||
? "샘플 사업정보를 저장했습니다. 부모 목록에서 바로 확인할 수 있습니다."
|
||||
: "사업정보와 처리분구별 기본 요약행을 저장했습니다.",
|
||||
: "사업정보와 처리분구별 기본 요약행을 저장했습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
setNotice({
|
||||
@@ -350,10 +291,6 @@ export function ExmnBusinessRegisterPopupPage({
|
||||
setNotice({ type: "info", text: "수정한 사업정보를 먼저 저장한 뒤 양식을 받아주세요." });
|
||||
return;
|
||||
}
|
||||
if (sampleMode) {
|
||||
setNotice({ type: "info", text: "샘플 모드에서는 실제 양식 파일을 다운로드하지 않습니다." });
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
|
||||
@@ -76,7 +76,7 @@ export function ExmnPplUnitDetailTable({
|
||||
<div className="min-h-0 flex-1 overflow-auto border border-border bg-background">
|
||||
<table
|
||||
className="border-separate border-spacing-0 text-center text-[1.05rem] text-foreground"
|
||||
style={{ width: `${tableWidth}px`, minWidth: `${tableWidth}px` }}
|
||||
style={{ width: "100%", minWidth: `${tableWidth}px` }}
|
||||
>
|
||||
<colgroup>
|
||||
{COLUMN_WIDTHS.map((width, index) => (
|
||||
@@ -142,35 +142,29 @@ export function ExmnPplUnitDetailTable({
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
<td className="border-b border-r border-border px-2 py-2.5">{row.defectPositionM}</td>
|
||||
<td className="border-b border-r border-border px-2 py-2.5">
|
||||
{formatOptionalValue(row.defectPositionM)}
|
||||
</td>
|
||||
<td className="border-b border-r border-border px-2 py-2.5 text-left">{row.defectName}</td>
|
||||
<td className="border-b border-r border-border px-2 py-2.5">{row.code}</td>
|
||||
<td className="border-b border-r border-border px-2 py-2.5">
|
||||
{row.severity ? SEVERITY_LABELS[row.severity] : "-"}
|
||||
</td>
|
||||
<td className="border-b border-r border-border px-2 py-2.5">{formatOptionalValue(row.occurrencePosition)}</td>
|
||||
<td className="border-b border-r border-border px-2 py-2.5">{row.infiltrationBonus}</td>
|
||||
<td className="border-b border-r border-border px-2 py-2.5">
|
||||
{formatOptionalValue(row.infiltrationBonus)}
|
||||
</td>
|
||||
<td className="border-b border-r border-border px-2 py-2.5">
|
||||
{row.continuous == null ? "-" : row.continuous ? "Y" : "N"}
|
||||
</td>
|
||||
<td className="border-b border-r border-border px-2 py-2.5">{formatOptionalValue(row.structuralScore)}</td>
|
||||
<td className="border-b border-r border-border px-2 py-2.5">{formatOptionalValue(row.operationalScore)}</td>
|
||||
{showUnitCells && (
|
||||
<>
|
||||
<td
|
||||
rowSpan={unitSpan?.count}
|
||||
className="border-b border-r border-border bg-background px-2 py-2.5 align-middle"
|
||||
>
|
||||
{formatOptionalValue(row.surveyStopReason)}
|
||||
</td>
|
||||
<td
|
||||
rowSpan={unitSpan?.count}
|
||||
className="border-b border-r border-border bg-background px-2 py-2.5 align-middle"
|
||||
>
|
||||
{formatOptionalValue(row.remark)}
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
<td className="border-b border-r border-border px-2 py-2.5">
|
||||
{formatOptionalValue(row.surveyStopReason)}
|
||||
</td>
|
||||
<td className="border-b border-r border-border px-2 py-2.5">
|
||||
{formatOptionalValue(row.remark)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}) : (
|
||||
|
||||
@@ -29,7 +29,6 @@ import type {
|
||||
ExmnBusiness,
|
||||
ExmnBusinessSearchParams,
|
||||
} from "../model/exmn-business-types";
|
||||
import { EXMN_SAMPLE_BUSINESSES } from "../model/exmn-sample-data";
|
||||
import { ExmnStatusDetailPanel } from "./ExmnStatusDetailPanel";
|
||||
|
||||
const ACTION_BUTTON_CLASS =
|
||||
@@ -72,7 +71,7 @@ const EMPTY_FILTERS: BusinessFilters = {
|
||||
|
||||
type Notice = { type: "success" | "error" | "info"; text: string } | null;
|
||||
|
||||
/** 서버·샘플 목록에 같은 비교 규칙을 적용해 정렬 헤더와 실제 행 순서를 일치시킨다. */
|
||||
/** 조회 목록에 공통 비교 규칙을 적용해 정렬 헤더와 실제 행 순서를 일치시킨다. */
|
||||
const sortBusinessList = (
|
||||
source: ExmnBusiness[],
|
||||
nextSort: UiTableSort | undefined,
|
||||
@@ -116,9 +115,6 @@ export function ExmnRegistrationPage() {
|
||||
const [draftFilters, setDraftFilters] = useState<BusinessFilters>(EMPTY_FILTERS);
|
||||
const [appliedFilters, setAppliedFilters] = useState<BusinessFilters>(EMPTY_FILTERS);
|
||||
const [businesses, setBusinesses] = useState<ExmnBusiness[]>([]);
|
||||
// 명시적 sample=Y 모드에서 신규 샘플 등록까지 유지하는 부모 창 전용 데이터 원본이다.
|
||||
const [sampleBusinesses, setSampleBusinesses] = useState<ExmnBusiness[]>(EXMN_SAMPLE_BUSINESSES);
|
||||
const [sampleMode, setSampleMode] = useState(false);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [sort, setSort] = useState<UiTableSort>();
|
||||
// 선택 사업 ID는 서버 재조회 뒤에도 같은 행을 복원할 수 있도록 객체와 별도로 유지한다.
|
||||
@@ -185,14 +181,6 @@ export function ExmnRegistrationPage() {
|
||||
void fetchBusinesses(appliedFilters, nextSort);
|
||||
};
|
||||
|
||||
/** 개발 환경에서만 샘플 데이터 모드를 명시적으로 켜고 끈다. */
|
||||
const switchSampleMode = (enabled: boolean) => {
|
||||
const url = new URL(window.location.href);
|
||||
if (enabled) url.searchParams.set("sample", "Y");
|
||||
else url.searchParams.delete("sample");
|
||||
window.location.assign(`${url.pathname}${url.search}`);
|
||||
};
|
||||
|
||||
/** 사업 목록의 숫자·행정구역·사업보고서 파일명을 표시 전용 값으로 변환한다. */
|
||||
const businessRows = useMemo(() => businesses.map((business, index) => ({
|
||||
...business,
|
||||
@@ -223,21 +211,6 @@ export function ExmnRegistrationPage() {
|
||||
: undefined,
|
||||
});
|
||||
|
||||
/** 샘플 원본에 실제 조회조건과 조사사업 목록의 공통 비교 규칙을 적용한다. */
|
||||
const getSampleBusinessPage = (
|
||||
filters: BusinessFilters,
|
||||
nextSort: UiTableSort | undefined,
|
||||
) => {
|
||||
const filtered = sampleBusinesses.filter((business) => (
|
||||
(!filters.bizCycl || String(business.bizCycl ?? "") === filters.bizCycl)
|
||||
&& (filters.bizYr === "0" || business.bizYr === filters.bizYr)
|
||||
&& (!filters.ctpvNm || business.ctpvNm === filters.ctpvNm)
|
||||
&& (!filters.sggNm || business.sggNm === filters.sggNm)
|
||||
&& (!filters.bizNm || business.bizNm.includes(filters.bizNm.trim()))
|
||||
));
|
||||
return sortBusinessList(filtered, nextSort);
|
||||
};
|
||||
|
||||
/** 지정 조회조건·정렬로 스크롤 표시용 사업 목록을 읽고 선택 행을 가능한 경우 유지한다. */
|
||||
const fetchBusinesses = async (
|
||||
filters: BusinessFilters,
|
||||
@@ -246,13 +219,6 @@ export function ExmnRegistrationPage() {
|
||||
setIsLoading(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
if (sampleMode) {
|
||||
const content = getSampleBusinessPage(filters, nextSort);
|
||||
setBusinesses(content);
|
||||
setTotal(content.length);
|
||||
setSelectedBusinessId((current) => content.some((item) => item.exmnBizId === current) ? current : null);
|
||||
return true;
|
||||
}
|
||||
const result = await getExmnBusinesses(buildSearchParams(filters, nextSort));
|
||||
const content = sortBusinessList(result.content, nextSort);
|
||||
setBusinesses(content);
|
||||
@@ -273,17 +239,10 @@ export function ExmnRegistrationPage() {
|
||||
}
|
||||
};
|
||||
|
||||
/** URL에 sample=Y가 명시된 경우에만 실제 API와 분리된 화면 검증용 사업목록을 노출한다. */
|
||||
/** 화면 최초 진입 시 빈 조회조건으로 실제 조사사업 목록을 조회한다. */
|
||||
useEffect(() => {
|
||||
if (initialBusinessesLoadedRef.current) return;
|
||||
initialBusinessesLoadedRef.current = true;
|
||||
const enabled = new URLSearchParams(window.location.search).get("sample") === "Y";
|
||||
setSampleMode(enabled);
|
||||
if (enabled) {
|
||||
setBusinesses(sampleBusinesses);
|
||||
setTotal(sampleBusinesses.length);
|
||||
return;
|
||||
}
|
||||
void fetchBusinesses(EMPTY_FILTERS, undefined);
|
||||
// 최초 진입 시점의 빈 조회조건으로 한 번만 실행한다.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -302,11 +261,6 @@ export function ExmnRegistrationPage() {
|
||||
setNotice({ type: "error", text: "다운로드할 사업보고서 첨부파일 정보가 없습니다." });
|
||||
return;
|
||||
}
|
||||
if (sampleMode) {
|
||||
setNotice({ type: "info", text: "샘플 모드에서는 실제 사업보고서 파일을 다운로드하지 않습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
@@ -329,7 +283,6 @@ export function ExmnRegistrationPage() {
|
||||
const openBusinessRegister = () => {
|
||||
setNotice(null);
|
||||
const popup = openWindowPopup("exmnBusinessRegister", {
|
||||
sample: sampleMode ? "Y" : undefined,
|
||||
sourcePath: window.location.pathname,
|
||||
});
|
||||
if (!popup) {
|
||||
@@ -346,8 +299,6 @@ export function ExmnRegistrationPage() {
|
||||
setNotice(null);
|
||||
const popup = openWindowPopup("exmnBusinessRegister", {
|
||||
exmnBizId: selectedBusiness.exmnBizId,
|
||||
sample: sampleMode ? "Y" : undefined,
|
||||
business: sampleMode ? JSON.stringify(selectedBusiness) : undefined,
|
||||
sourcePath: window.location.pathname,
|
||||
});
|
||||
if (!popup) {
|
||||
@@ -373,7 +324,6 @@ export function ExmnRegistrationPage() {
|
||||
prcisExmnPrlg: selectedBusiness.prcisExmnPrlg,
|
||||
tpco: selectedBusiness.tpco,
|
||||
reportYn: selectedBusiness.reportYn ?? "N",
|
||||
sample: sampleMode ? "Y" : undefined,
|
||||
sourcePath: window.location.pathname,
|
||||
});
|
||||
if (!popup) {
|
||||
@@ -386,20 +336,6 @@ export function ExmnRegistrationPage() {
|
||||
const handlePopupMessage = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
if (event.data?.type === WINDOW_POPUP_MESSAGE_TYPE.EXMN_BUSINESS_CREATED) {
|
||||
const payload = event.data?.payload as { exmnBizId?: string; sample?: string; business?: string } | undefined;
|
||||
if (payload?.sample === "Y" && payload.business) {
|
||||
try {
|
||||
const created = JSON.parse(payload.business) as ExmnBusiness;
|
||||
setSampleBusinesses((current) => [created, ...current.filter((item) => item.exmnBizId !== created.exmnBizId)]);
|
||||
setBusinesses((current) => [created, ...current.filter((item) => item.exmnBizId !== created.exmnBizId)]);
|
||||
setTotal((current) => current + 1);
|
||||
setSelectedBusinessId(null);
|
||||
setNotice({ type: "success", text: "샘플 조사사업이 등록되었습니다." });
|
||||
} catch {
|
||||
setNotice({ type: "error", text: "샘플 사업정보를 부모 목록에 반영하지 못했습니다." });
|
||||
}
|
||||
return;
|
||||
}
|
||||
setSelectedBusinessId(null);
|
||||
void fetchBusinesses(appliedFilters, sort).then((refreshed) => {
|
||||
if (refreshed) setNotice({ type: "success", text: "조사사업이 등록되었습니다." });
|
||||
@@ -408,20 +344,6 @@ export function ExmnRegistrationPage() {
|
||||
}
|
||||
|
||||
if (event.data?.type === WINDOW_POPUP_MESSAGE_TYPE.EXMN_BUSINESS_UPDATED) {
|
||||
const payload = event.data?.payload as { sample?: string; business?: string } | undefined;
|
||||
if (payload?.sample === "Y" && payload.business) {
|
||||
try {
|
||||
const updated = JSON.parse(payload.business) as ExmnBusiness;
|
||||
setSampleBusinesses((current) => current.map((item) =>
|
||||
item.exmnBizId === updated.exmnBizId ? updated : item));
|
||||
setBusinesses((current) => current.map((item) =>
|
||||
item.exmnBizId === updated.exmnBizId ? updated : item));
|
||||
setNotice({ type: "success", text: "샘플 조사사업이 수정되었습니다." });
|
||||
} catch {
|
||||
setNotice({ type: "error", text: "수정된 샘플 사업정보를 부모 목록에 반영하지 못했습니다." });
|
||||
}
|
||||
return;
|
||||
}
|
||||
void fetchBusinesses(appliedFilters, sort).then((refreshed) => {
|
||||
if (refreshed) setNotice({ type: "success", text: "조사사업이 수정되었습니다." });
|
||||
});
|
||||
@@ -429,11 +351,6 @@ export function ExmnRegistrationPage() {
|
||||
}
|
||||
|
||||
if (event.data?.type === WINDOW_POPUP_MESSAGE_TYPE.EXMN_RESULT_COMMITTED) {
|
||||
const payload = event.data?.payload as { sample?: string } | undefined;
|
||||
if (payload?.sample === "Y") {
|
||||
setNotice({ type: "success", text: "샘플 조사결과 저장 흐름을 완료했습니다." });
|
||||
return;
|
||||
}
|
||||
void fetchBusinesses(appliedFilters, sort).then((refreshed) => {
|
||||
if (refreshed) setNotice({ type: "success", text: "조사결과 최종 저장과 PPL/MNHL 집계를 완료했습니다." });
|
||||
});
|
||||
@@ -515,15 +432,6 @@ export function ExmnRegistrationPage() {
|
||||
<ListDiv className="mt-4 min-h-0 shrink-0 overflow-hidden">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="text-2xl font-medium">조사사업 목록 <span className="text-base text-gray-500">총 {total.toLocaleString("ko-KR")}건</span></h3>
|
||||
{process.env.NODE_ENV === "development" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => switchSampleMode(!sampleMode)}
|
||||
>
|
||||
{sampleMode ? "실데이터 보기" : "샘플 데이터 보기"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative mx-[1.2rem]" style={{ marginTop: "0.6rem" }}>
|
||||
<div
|
||||
@@ -685,7 +593,7 @@ export function ExmnRegistrationPage() {
|
||||
/>
|
||||
</div>
|
||||
</ListDiv>
|
||||
<ExmnStatusDetailPanel business={selectedBusiness} sampleMode={sampleMode} />
|
||||
<ExmnStatusDetailPanel business={selectedBusiness} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,10 +18,6 @@ import {
|
||||
validateExmnFile,
|
||||
validateExmnFiles,
|
||||
} from "../lib/exmn-business-validation";
|
||||
import {
|
||||
EXMN_SAMPLE_IMPORT_VALIDATION,
|
||||
getExmnSampleBusinessDetail,
|
||||
} from "../model/exmn-sample-data";
|
||||
import type {
|
||||
ExmnAttachmentKey,
|
||||
ExmnBusiness,
|
||||
@@ -90,7 +86,6 @@ type FileValidationMessages = Partial<Record<ExmnFileType, string[]>>;
|
||||
|
||||
interface ExmnResultRegisterPopupPageProps {
|
||||
exmnBizId: string;
|
||||
sampleMode: boolean;
|
||||
businessSummary: ExmnBusiness;
|
||||
}
|
||||
|
||||
@@ -152,7 +147,6 @@ const uploadAttachment = (
|
||||
/** 공통 WindowPopup 안에서 조사 파일 5종을 선택·검토·최종 저장하는 화면이다. */
|
||||
export function ExmnResultRegisterPopupPage({
|
||||
exmnBizId,
|
||||
sampleMode,
|
||||
businessSummary,
|
||||
}: ExmnResultRegisterPopupPageProps) {
|
||||
const [detail, setDetail] = useState<ExmnBusinessDetail | null>(null);
|
||||
@@ -190,11 +184,6 @@ export function ExmnResultRegisterPopupPage({
|
||||
setNotice({ type: "error", text: "결과를 등록할 조사사업 식별자가 없습니다." });
|
||||
return;
|
||||
}
|
||||
if (sampleMode) {
|
||||
setDetail(getExmnSampleBusinessDetail(exmnBizId, businessSummary));
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
setIsLoading(true);
|
||||
getExmnBusiness(exmnBizId)
|
||||
@@ -214,7 +203,7 @@ export function ExmnResultRegisterPopupPage({
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [businessSummary, exmnBizId, sampleMode]);
|
||||
}, [exmnBizId]);
|
||||
|
||||
/** 파일 변경 시 이전 검토결과를 폐기하고 확장자·크기 규칙을 즉시 확인한다. */
|
||||
const handleFileChange = (fileType: ExmnFileType, fileList: FileList | null) => {
|
||||
@@ -269,7 +258,7 @@ export function ExmnResultRegisterPopupPage({
|
||||
}
|
||||
};
|
||||
|
||||
/** 실제 모드는 서버 검토를 호출하고 샘플 모드는 명시적 정상 검토 결과만 화면에 제공한다. */
|
||||
/** 선택한 파일을 서버 검토 API로 전달하고 파일별 결과를 화면에 반영한다. */
|
||||
const handleValidate = async (fileType: ExmnFileType) => {
|
||||
if (!files[fileType]) {
|
||||
setNotice({
|
||||
@@ -279,24 +268,6 @@ export function ExmnResultRegisterPopupPage({
|
||||
return;
|
||||
}
|
||||
|
||||
if (sampleMode) {
|
||||
setValidationResults((current) => ({
|
||||
...current,
|
||||
[fileType]: EXMN_SAMPLE_IMPORT_VALIDATION,
|
||||
}));
|
||||
setFileValidationStatuses((current) => ({
|
||||
...current,
|
||||
[fileType]: "success",
|
||||
}));
|
||||
setFileValidationMessages((current) => {
|
||||
const next = { ...current };
|
||||
delete next[fileType];
|
||||
return next;
|
||||
});
|
||||
setNotice(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const fileMap = Object.fromEntries(
|
||||
Object.entries(files).map(([type, selected]) => [type, selected?.file]),
|
||||
) as Partial<Record<ExmnFileType, File>>;
|
||||
@@ -371,14 +342,6 @@ export function ExmnResultRegisterPopupPage({
|
||||
|| fileValidationStatuses.EXMN_EXCEL !== "success"
|
||||
|| fileValidationStatuses.PPL_EXMN_PDF !== "success"
|
||||
) return;
|
||||
if (sampleMode) {
|
||||
postPopupMessageAndClose(WINDOW_POPUP_MESSAGE_TYPE.EXMN_RESULT_COMMITTED, {
|
||||
exmnBizId,
|
||||
sample: "Y",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
@@ -386,7 +349,6 @@ export function ExmnResultRegisterPopupPage({
|
||||
await commitExmnImport(exmnBizId, buildImportFormData(files, excelValidation));
|
||||
postPopupMessageAndClose(WINDOW_POPUP_MESSAGE_TYPE.EXMN_RESULT_COMMITTED, {
|
||||
exmnBizId,
|
||||
sample: "N",
|
||||
});
|
||||
} catch (error) {
|
||||
setNotice({
|
||||
|
||||
@@ -13,14 +13,16 @@ import {
|
||||
import type { UiTableSort } from "@common/business_components/ui";
|
||||
import ListDiv from "@common/business_components/ui/list_div";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { getExmnSectionSummaries } from "../api/exmn-business-api";
|
||||
import {
|
||||
downloadExmnSectionSummary,
|
||||
getExmnSectionSummaries,
|
||||
} from "../api/exmn-business-api";
|
||||
import type {
|
||||
ExmnBusiness,
|
||||
ExmnJobType,
|
||||
ExmnSectionSummaryRow,
|
||||
} from "../model/exmn-business-types";
|
||||
import type { ExmnDetailDefectType } from "../model/exmn-detail-table";
|
||||
import { getExmnSampleSectionSummaries } from "../model/exmn-sample-data";
|
||||
|
||||
const ACTION_BUTTON_CLASS =
|
||||
"rounded-sm bg-blue-600 px-4 py-2 text-sm font-medium !text-white hover:bg-blue-700 disabled:!text-white";
|
||||
@@ -59,12 +61,23 @@ type Notice = { type: "info" | "error"; text: string } | null;
|
||||
|
||||
interface ExmnStatusDetailPanelProps {
|
||||
business: ExmnBusiness | null;
|
||||
sampleMode: boolean;
|
||||
}
|
||||
|
||||
const formatNumber = (value: number | null | undefined) =>
|
||||
Number(value ?? 0).toLocaleString("ko-KR", { maximumFractionDigits: 3 });
|
||||
|
||||
/** 서버가 생성한 다중 시트 XLSX Blob을 브라우저 다운로드로 전달한다. */
|
||||
const saveBlob = (blob: Blob, fileName: string) => {
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = fileName;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
|
||||
const getSummarySortValue = (row: DisplaySummaryRow, key: string) => {
|
||||
const value = row[key];
|
||||
if (key !== "trtdstNm") {
|
||||
@@ -106,13 +119,14 @@ interface DisplaySummaryRow extends Record<string, string | number> {
|
||||
}
|
||||
|
||||
/** 동일 조사현황 화면에서 선택 사업의 관로·맨홀 처리분구 집계를 펼쳐 표시한다. */
|
||||
export function ExmnStatusDetailPanel({ business, sampleMode }: ExmnStatusDetailPanelProps) {
|
||||
export function ExmnStatusDetailPanel({ business }: ExmnStatusDetailPanelProps) {
|
||||
const [jobType, setJobType] = useState<ExmnJobType>("PPL");
|
||||
const [summarySort, setSummarySort] = useState<UiTableSort>();
|
||||
const [summaryRows, setSummaryRows] = useState<ExmnSectionSummaryRow[]>([]);
|
||||
/** 상세보기 팝업에 전달할 현재 선택 처리분구다. */
|
||||
const [selectedTrtdstId, setSelectedTrtdstId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [notice, setNotice] = useState<Notice>(null);
|
||||
const summaryRequestIdRef = useRef(0);
|
||||
|
||||
@@ -140,10 +154,7 @@ export function ExmnStatusDetailPanel({ business, sampleMode }: ExmnStatusDetail
|
||||
summaryRequestIdRef.current = requestId;
|
||||
setIsLoading(true);
|
||||
setNotice(null);
|
||||
const request = sampleMode
|
||||
? Promise.resolve(getExmnSampleSectionSummaries(business.exmnBizId, jobType))
|
||||
: getExmnSectionSummaries(business.exmnBizId, jobType);
|
||||
request
|
||||
getExmnSectionSummaries(business.exmnBizId, jobType)
|
||||
.then((rows) => {
|
||||
if (summaryRequestIdRef.current === requestId) setSummaryRows(rows);
|
||||
})
|
||||
@@ -159,7 +170,7 @@ export function ExmnStatusDetailPanel({ business, sampleMode }: ExmnStatusDetail
|
||||
return () => {
|
||||
if (summaryRequestIdRef.current === requestId) summaryRequestIdRef.current += 1;
|
||||
};
|
||||
}, [business, jobType, sampleMode]);
|
||||
}, [business, jobType]);
|
||||
|
||||
const summaryTable = useMemo(() => {
|
||||
const getCounts = (row: ExmnSectionSummaryRow) => jobType === "PPL"
|
||||
@@ -230,11 +241,37 @@ export function ExmnStatusDetailPanel({ business, sampleMode }: ExmnStatusDetail
|
||||
));
|
||||
};
|
||||
|
||||
const showPendingNotice = (feature: "다운로드" | "지도보기") => {
|
||||
const text = feature === "다운로드"
|
||||
? "다운로드는 집계 데이터와 파일 계약 확정 후 연결할 예정입니다."
|
||||
: "지도보기는 현재 보류 기능이며 버튼 진입점만 표시합니다.";
|
||||
setNotice({ type: "info", text });
|
||||
const showMapPendingNotice = () => {
|
||||
setNotice({ type: "info", text: "지도보기는 현재 보류 기능이며 버튼 진입점만 표시합니다." });
|
||||
};
|
||||
|
||||
/** 현재 조사유형의 종합집계와 처리분구별 전체 상세 시트를 서버 XLSX로 내려받는다. */
|
||||
const handleSummaryDownload = async () => {
|
||||
if (!business || summaryTable.rows.length === 0 || isDownloading) {
|
||||
if (summaryTable.rows.length === 0) {
|
||||
setNotice({ type: "info", text: "다운로드할 조사 상세현황 데이터가 없습니다." });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDownloading(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const jobTypeLabel = jobType === "PPL" ? "관로조사" : "맨홀조사";
|
||||
const fileName = `${business.bizNm}_${jobTypeLabel}_조사상세현황.xlsx`
|
||||
.replace(/[\\/:*?"<>|]/g, "_");
|
||||
const blob = await downloadExmnSectionSummary(business.exmnBizId, jobType);
|
||||
saveBlob(blob, fileName);
|
||||
} catch (error) {
|
||||
setNotice({
|
||||
type: "error",
|
||||
text: error instanceof Error
|
||||
? error.message
|
||||
: "조사 상세현황 다운로드 중 오류가 발생했습니다.",
|
||||
});
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 선택 사업·처리분구, 조사유형, 결함 범위를 공통 WindowPopup 상세표에 전달한다. */
|
||||
@@ -258,7 +295,6 @@ export function ExmnStatusDetailPanel({ business, sampleMode }: ExmnStatusDetail
|
||||
prcisExmnPrlg: business.prcisExmnPrlg,
|
||||
tpco: business.tpco,
|
||||
reportYn: business.reportYn,
|
||||
sample: sampleMode ? "Y" : "N",
|
||||
});
|
||||
if (!popup) {
|
||||
setNotice({
|
||||
@@ -323,14 +359,15 @@ export function ExmnStatusDetailPanel({ business, sampleMode }: ExmnStatusDetail
|
||||
>
|
||||
상세보기
|
||||
</Button>
|
||||
<Button className={ACTION_BUTTON_CLASS} size="sm" onClick={() => showPendingNotice("지도보기")}>지도보기</Button>
|
||||
<Button className={ACTION_BUTTON_CLASS} size="sm" onClick={showMapPendingNotice}>지도보기</Button>
|
||||
<Button
|
||||
color="green"
|
||||
className={DOWNLOAD_BUTTON_CLASS}
|
||||
size="sm"
|
||||
onClick={() => showPendingNotice("다운로드")}
|
||||
disabled={isLoading || isDownloading || summaryTable.rows.length === 0}
|
||||
onClick={() => void handleSummaryDownload()}
|
||||
>
|
||||
다운로드
|
||||
{isDownloading ? "다운로드 중..." : "다운로드"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import type { UIEvent } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
downloadExcelWithKoreanHeaders,
|
||||
type UiTableSort,
|
||||
} from "@common/business_components/ui";
|
||||
import { Button, Input } from "@common/components/ui";
|
||||
import { useEffect, useMemo, useState, type UIEvent } from "react";
|
||||
import type { ExmnBusiness, ExmnJobType } from "../model/exmn-business-types";
|
||||
|
||||
import {
|
||||
getExmnFacilityDetails,
|
||||
getExmnPplUnitDetails,
|
||||
getExmnSectionSummaries,
|
||||
} from "../api/exmn-business-api";
|
||||
import type {
|
||||
ExmnBusiness,
|
||||
ExmnJobType,
|
||||
ExmnSectionSummaryRow,
|
||||
} from "../model/exmn-business-types";
|
||||
import {
|
||||
EXMN_DETAIL_SEVERITIES,
|
||||
EXMN_DETAIL_TABLE_DEFINITIONS,
|
||||
@@ -14,13 +26,11 @@ import {
|
||||
type ExmnDetailDefectType,
|
||||
type ExmnDetailSeverity,
|
||||
type ExmnPplDetailMode,
|
||||
type ExmnPplUnitDetailRow,
|
||||
type ExmnSeverityCounts,
|
||||
type ExmnSurveyDetailRow,
|
||||
} from "../model/exmn-detail-table";
|
||||
import {
|
||||
EXMN_SAMPLE_PPL_UNIT_DETAILS,
|
||||
getExmnSampleSurveyDetails,
|
||||
} from "../model/exmn-sample-data";
|
||||
|
||||
import { ExmnPplUnitDetailTable } from "./ExmnPplUnitDetailTable";
|
||||
|
||||
const SEVERITY_LABELS: Record<ExmnDetailSeverity, string> = {
|
||||
@@ -51,23 +61,14 @@ const FIXED_COLUMNS = [
|
||||
{ id: "specification", label: "규격", width: 82 },
|
||||
{ id: "extensionM", label: "연장", width: 86 },
|
||||
{ id: "drivingDistanceM", label: "주행\n거리", width: 92 },
|
||||
{ id: "structuralGrade", label: "구조적\n내부결함", width: 105 },
|
||||
{ id: "operationalGrade", label: "운영적\n내부결함", width: 105 },
|
||||
] as const;
|
||||
|
||||
const REPORT_COLUMN_LEFT = 0;
|
||||
const PIPE_COLUMN_LEFT = FIXED_COLUMNS[0].width;
|
||||
const STICKY_COLUMN_COUNT = 2;
|
||||
const SUMMARY_LABEL_COLUMN_COUNT = 7;
|
||||
const FIXED_INFO_SCROLL_THRESHOLD = FIXED_COLUMNS
|
||||
.slice(STICKY_COLUMN_COUNT, SUMMARY_LABEL_COLUMN_COUNT)
|
||||
.reduce((sum, column) => sum + column.width, 0);
|
||||
const STICKY_COLUMNS_WIDTH = FIXED_COLUMNS
|
||||
.slice(0, STICKY_COLUMN_COUNT)
|
||||
.reduce((sum, column) => sum + column.width, 0);
|
||||
const SUMMARY_LABEL_COLUMNS_WIDTH = FIXED_COLUMNS
|
||||
.slice(0, SUMMARY_LABEL_COLUMN_COUNT)
|
||||
.reduce((sum, column) => sum + column.width, 0);
|
||||
const SUMMARY_LABEL_INITIAL_OFFSET =
|
||||
(SUMMARY_LABEL_COLUMNS_WIDTH - STICKY_COLUMNS_WIDTH) / 2;
|
||||
const PPL_FIXED_COLUMN_COUNT = 9;
|
||||
const STICKY_DIVIDER_SHADOW = "2px 0 0 #b8c5d1";
|
||||
const SEVERITY_COLUMN_WIDTH = 52;
|
||||
type ExmnDetailFixedColumnId = (typeof FIXED_COLUMNS)[number]["id"];
|
||||
@@ -78,7 +79,6 @@ interface ExmnSurveyDetailPopupPageProps {
|
||||
trtdstNm: string;
|
||||
jobType: ExmnJobType;
|
||||
defectType: ExmnDetailDefectType;
|
||||
sampleMode: boolean;
|
||||
}
|
||||
|
||||
type Notice = { type: "info"; text: string } | null;
|
||||
@@ -145,25 +145,63 @@ const getVisibleAnomalyTotals = (
|
||||
|
||||
/** 엑셀 `01. 덕풍`·`01. 덕풍 (2)` 구조를 관로·맨홀 공통 WindowPopup으로 표시한다. */
|
||||
export function ExmnSurveyDetailPopupPage({
|
||||
business,
|
||||
trtdstId,
|
||||
trtdstNm,
|
||||
jobType,
|
||||
defectType,
|
||||
sampleMode,
|
||||
}: ExmnSurveyDetailPopupPageProps) {
|
||||
/** 현재 조사유형에 실제 결과가 등록된 처리분구 목록과 팝업 조회 대상이다. */
|
||||
const [districtOptions, setDistrictOptions] = useState<ExmnSectionSummaryRow[]>([]);
|
||||
const [selectedTrtdstId, setSelectedTrtdstId] = useState(trtdstId);
|
||||
/** 관로 상세보기의 전체 관로 집계와 본단위구간 등급표를 전환하는 표시 모드다. */
|
||||
const [detailMode, setDetailMode] = useState<ExmnPplDetailMode>("PIPE");
|
||||
/** 관로·맨홀 번호 조회 입력값과 실제 적용값을 분리해 조회 버튼 동작을 재현한다. */
|
||||
const [detailNumberKeyword, setDetailNumberKeyword] = useState("");
|
||||
const [appliedDetailNumberKeyword, setAppliedDetailNumberKeyword] = useState("");
|
||||
/** 같은 검색어로 다시 조회해도 서버 요청을 새로 실행하기 위한 순번이다. */
|
||||
const [searchRequestSeq, setSearchRequestSeq] = useState(0);
|
||||
/** 고정 열 헤더의 오름차순·내림차순·기본 정렬 상태다. */
|
||||
const [sort, setSort] = useState<UiTableSort>();
|
||||
/** 가로 스크롤 위치를 기준으로 요약 라벨과 고정 열 경계선 위치를 함께 보정한다. */
|
||||
const [tableScrollLeft, setTableScrollLeft] = useState(0);
|
||||
/** 다운로드 미연결 등 현재 팝업 안내 메시지를 표시한다. */
|
||||
const [notice, setNotice] = useState<Notice>(null);
|
||||
/** 실제 DB 상세조회 결과와 요청 상태다. */
|
||||
const [actualRows, setActualRows] = useState<ExmnSurveyDetailRow[]>([]);
|
||||
const [actualUnitRows, setActualUnitRows] = useState<ExmnPplUnitDetailRow[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const definition = EXMN_DETAIL_TABLE_DEFINITIONS[jobType];
|
||||
const defectTypeLabel = DEFECT_TYPE_LABELS[defectType];
|
||||
const selectedDistrict = districtOptions.find(
|
||||
(district) => district.trtdstId === selectedTrtdstId,
|
||||
);
|
||||
const selectedTrtdstNm = selectedDistrict?.trtdstNm
|
||||
?? (selectedTrtdstId === trtdstId ? trtdstNm : "");
|
||||
/** 관로는 등급을 본단위구간에서 표시하고, 맨홀 전체표만 구조·운영 등급 열을 추가한다. */
|
||||
const fixedColumns = useMemo(
|
||||
() => jobType === "PPL"
|
||||
? FIXED_COLUMNS.slice(0, PPL_FIXED_COLUMN_COUNT)
|
||||
: FIXED_COLUMNS,
|
||||
[jobType],
|
||||
);
|
||||
const summaryLabelColumnCount = 7;
|
||||
const fixedInfoScrollThreshold = useMemo(
|
||||
() => fixedColumns
|
||||
.slice(STICKY_COLUMN_COUNT, summaryLabelColumnCount)
|
||||
.reduce((sum, column) => sum + column.width, 0),
|
||||
[fixedColumns, summaryLabelColumnCount],
|
||||
);
|
||||
const summaryLabelInitialOffset = useMemo(() => {
|
||||
const stickyColumnsWidth = fixedColumns
|
||||
.slice(0, STICKY_COLUMN_COUNT)
|
||||
.reduce((sum, column) => sum + column.width, 0);
|
||||
const labelColumnsWidth = fixedColumns
|
||||
.slice(0, summaryLabelColumnCount)
|
||||
.reduce((sum, column) => sum + column.width, 0);
|
||||
return (labelColumnsWidth - stickyColumnsWidth) / 2;
|
||||
}, [fixedColumns, summaryLabelColumnCount]);
|
||||
/** 맨홀은 별도 모드가 없으므로 항상 기존 상세 집계표를 사용한다. */
|
||||
const isUnitMode = jobType === "PPL" && detailMode === "UNIT";
|
||||
/** 결함 건수 링크 진입 화면은 조회 조건 없이 선택 분류 결과만 바로 표시한다. */
|
||||
@@ -175,27 +213,17 @@ export function ExmnSurveyDetailPopupPage({
|
||||
: definition.defectColumns.filter((column) => column.group === defectType),
|
||||
[defectType, definition],
|
||||
);
|
||||
/** 샘플 모드에서 선택 조사유형에 해당하는 상세 원본 행을 제공한다. */
|
||||
const sourceRows = useMemo(
|
||||
() => (sampleMode ? getExmnSampleSurveyDetails(jobType) : []),
|
||||
[jobType, sampleMode],
|
||||
);
|
||||
/** 본단위구간은 PPL 전용이며 샘플 모드에서 엑셀 기준 단위 기록을 제공한다. */
|
||||
const sourceUnitRows = useMemo(
|
||||
() => (sampleMode && jobType === "PPL" ? EXMN_SAMPLE_PPL_UNIT_DETAILS : []),
|
||||
[jobType, sampleMode],
|
||||
);
|
||||
/** 분류별 상세에서는 선택 결함이 한 건 이상 존재하는 시설 행만 표시한다. */
|
||||
const scopedRows = useMemo(() => {
|
||||
if (defectType === "ALL") return sourceRows;
|
||||
return sourceRows.filter((row) =>
|
||||
if (defectType === "ALL") return actualRows;
|
||||
return actualRows.filter((row) =>
|
||||
visibleDefectColumns.some((column) =>
|
||||
EXMN_DETAIL_SEVERITIES.some(
|
||||
(severity) => (row.defectCounts[column.code]?.[severity] ?? 0) > 0,
|
||||
),
|
||||
),
|
||||
);
|
||||
}, [defectType, sourceRows, visibleDefectColumns]);
|
||||
}, [actualRows, defectType, visibleDefectColumns]);
|
||||
/** 조회 버튼으로 확정한 번호 조건을 현재 상세 행에 적용한다. */
|
||||
const filteredRows = useMemo(() => {
|
||||
const normalizedKeyword = appliedDetailNumberKeyword.trim().toLocaleLowerCase("ko-KR");
|
||||
@@ -208,11 +236,11 @@ export function ExmnSurveyDetailPopupPage({
|
||||
const unitRows = useMemo(() => {
|
||||
if (!isUnitMode) return [];
|
||||
const normalizedKeyword = appliedDetailNumberKeyword.trim().toLocaleLowerCase("ko-KR");
|
||||
if (!normalizedKeyword) return sourceUnitRows;
|
||||
return sourceUnitRows.filter(
|
||||
if (!normalizedKeyword) return actualUnitRows;
|
||||
return actualUnitRows.filter(
|
||||
(row) => row.pipeNo.toLocaleLowerCase("ko-KR").includes(normalizedKeyword),
|
||||
);
|
||||
}, [appliedDetailNumberKeyword, isUnitMode, sourceUnitRows]);
|
||||
}, [actualUnitRows, appliedDetailNumberKeyword, isUnitMode]);
|
||||
/** 보고서번호부터 주행거리까지의 고정 열에 선택 정렬을 적용한다. */
|
||||
const rows = useMemo(() => {
|
||||
if (!sort) return filteredRows;
|
||||
@@ -247,9 +275,123 @@ export function ExmnSurveyDetailPopupPage({
|
||||
setNotice(null);
|
||||
}, [defectType, jobType]);
|
||||
|
||||
/** 화면 조회 버튼으로 입력 번호를 확정해 샘플 상세 행을 필터링한다. */
|
||||
/** 사업·조사유형별 상세현황에 실제 표시되는 처리분구만 조회 조건으로 제공한다. */
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setSelectedTrtdstId(trtdstId);
|
||||
void getExmnSectionSummaries(business.exmnBizId, jobType)
|
||||
.then((districts) => {
|
||||
if (!active) return;
|
||||
setDistrictOptions(districts);
|
||||
setSelectedTrtdstId(
|
||||
districts.some((district) => district.trtdstId === trtdstId)
|
||||
? trtdstId
|
||||
: districts[0]?.trtdstId ?? trtdstId,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!active) return;
|
||||
setDistrictOptions([]);
|
||||
setSelectedTrtdstId(trtdstId);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [business.exmnBizId, jobType, trtdstId]);
|
||||
|
||||
/** 전체 또는 결함 분류별 모드에서 선택 처리분구의 활성 시설과 JSONB 결함 집계를 조회한다. */
|
||||
useEffect(() => {
|
||||
if (isUnitMode) {
|
||||
setActualRows([]);
|
||||
setIsLoading(false);
|
||||
setLoadError(null);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
setActualRows([]);
|
||||
setIsLoading(true);
|
||||
setLoadError(null);
|
||||
void getExmnFacilityDetails({
|
||||
exmnBizId: business.exmnBizId,
|
||||
jobType,
|
||||
trtdstId: selectedTrtdstId,
|
||||
gisManagementNo: appliedDetailNumberKeyword,
|
||||
defectType,
|
||||
})
|
||||
.then((result) => {
|
||||
if (active) setActualRows(result);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!active) return;
|
||||
setActualRows([]);
|
||||
setLoadError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "조사 상세 집계 조회 중 오류가 발생했습니다.",
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setIsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [
|
||||
appliedDetailNumberKeyword,
|
||||
business.exmnBizId,
|
||||
defectType,
|
||||
isUnitMode,
|
||||
jobType,
|
||||
searchRequestSeq,
|
||||
selectedTrtdstId,
|
||||
]);
|
||||
|
||||
/** 본단위구간 모드에서 선택 처리분구의 PPL_UNIT_V1을 실제 상세행으로 조회한다. */
|
||||
useEffect(() => {
|
||||
if (!isUnitMode || isDefectTypeView) {
|
||||
setActualUnitRows([]);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
setActualUnitRows([]);
|
||||
setIsLoading(true);
|
||||
setLoadError(null);
|
||||
void getExmnPplUnitDetails({
|
||||
exmnBizId: business.exmnBizId,
|
||||
trtdstId: selectedTrtdstId,
|
||||
gisManagementNo: appliedDetailNumberKeyword,
|
||||
})
|
||||
.then((result) => {
|
||||
if (active) setActualUnitRows(result);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!active) return;
|
||||
setActualUnitRows([]);
|
||||
setLoadError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "본단위구간 조회 중 오류가 발생했습니다.",
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setIsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [
|
||||
appliedDetailNumberKeyword,
|
||||
business.exmnBizId,
|
||||
isDefectTypeView,
|
||||
isUnitMode,
|
||||
searchRequestSeq,
|
||||
selectedTrtdstId,
|
||||
]);
|
||||
|
||||
/** 화면 조회 버튼으로 입력 번호를 확정해 상세 조회를 다시 실행한다. */
|
||||
const handleSearch = () => {
|
||||
setAppliedDetailNumberKeyword(detailNumberKeyword.trim());
|
||||
setSearchRequestSeq((current) => current + 1);
|
||||
setNotice(null);
|
||||
};
|
||||
|
||||
@@ -282,11 +424,12 @@ export function ExmnSurveyDetailPopupPage({
|
||||
setTableScrollLeft(event.currentTarget.scrollLeft);
|
||||
};
|
||||
|
||||
const showStickyDivider =
|
||||
tableScrollLeft >= FIXED_INFO_SCROLL_THRESHOLD;
|
||||
const showStickyDivider = fixedInfoScrollThreshold === 0
|
||||
? tableScrollLeft > 0
|
||||
: tableScrollLeft >= fixedInfoScrollThreshold;
|
||||
const summaryLabelOffset = Math.max(
|
||||
0,
|
||||
SUMMARY_LABEL_INITIAL_OFFSET - tableScrollLeft,
|
||||
summaryLabelInitialOffset - tableScrollLeft,
|
||||
);
|
||||
|
||||
const totals = useMemo(
|
||||
@@ -294,19 +437,29 @@ export function ExmnSurveyDetailPopupPage({
|
||||
(sum, row) => ({
|
||||
extensionM: sum.extensionM + row.extensionM,
|
||||
drivingDistanceM: sum.drivingDistanceM + row.drivingDistanceM,
|
||||
structuralGrade: sum.structuralGrade + Number(row.structuralGrade || 0),
|
||||
operationalGrade: sum.operationalGrade + Number(row.operationalGrade || 0),
|
||||
}),
|
||||
{
|
||||
extensionM: 0,
|
||||
drivingDistanceM: 0,
|
||||
structuralGrade: 0,
|
||||
operationalGrade: 0,
|
||||
},
|
||||
),
|
||||
[rows],
|
||||
);
|
||||
|
||||
/** 고정 열 중 합산 의미가 있는 연장·주행거리 2개 열의 요약 표시값이다. */
|
||||
/** 고정 열의 합산값이며 구조·운영 등급 합계는 맨홀 전체표에만 표시한다. */
|
||||
const fixedSummaryValues = [
|
||||
{ key: "extensionM", value: formatNumber(totals.extensionM) },
|
||||
{ key: "drivingDistanceM", value: formatNumber(totals.drivingDistanceM) },
|
||||
...(jobType === "MNHL"
|
||||
? [
|
||||
{ key: "structuralGrade", value: formatNumber(totals.structuralGrade) },
|
||||
{ key: "operationalGrade", value: formatNumber(totals.operationalGrade) },
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
/** 현재 보이는 구조·운영 결함 열만 기준으로 대·중·소 소계와 항목별 합계를 계산한다. */
|
||||
@@ -355,7 +508,7 @@ export function ExmnSurveyDetailPopupPage({
|
||||
/** 현재 화면에 표시된 관로·맨홀 상세 결과를 한글 헤더 엑셀로 내려받는다. */
|
||||
const handleDownload = async () => {
|
||||
const downloadDate = new Date().toISOString().slice(0, 10);
|
||||
const safeFilePrefix = `${trtdstNm}_${definition.defectGroupTitle}조사_${
|
||||
const safeFilePrefix = `${selectedTrtdstNm}_${definition.defectGroupTitle}조사_${
|
||||
isUnitMode ? "본단위등급표" : defectTypeLabel
|
||||
}`.replace(/[\\/:*?"<>|]/g, "_");
|
||||
|
||||
@@ -416,7 +569,7 @@ export function ExmnSurveyDetailPopupPage({
|
||||
|
||||
const detailHeaderMapping: Record<string, string> = {
|
||||
reportNo: "보고서번호",
|
||||
pipeNo: jobType === "PPL" ? "관로번호" : "맨홀번호",
|
||||
pipeNo: "관로번호",
|
||||
upstreamManhole: "상류맨홀",
|
||||
downstreamManhole: "하류맨홀",
|
||||
drainageType: "배수방식",
|
||||
@@ -424,10 +577,18 @@ export function ExmnSurveyDetailPopupPage({
|
||||
specification: "규격",
|
||||
extensionM: "연장(m)",
|
||||
drivingDistanceM: "주행거리(m)",
|
||||
};
|
||||
if (jobType === "MNHL") {
|
||||
Object.assign(detailHeaderMapping, {
|
||||
structuralGrade: "구조적 내부결함",
|
||||
operationalGrade: "운영적 내부결함",
|
||||
});
|
||||
}
|
||||
Object.assign(detailHeaderMapping, {
|
||||
anomalyL: "이상항목 합계-대",
|
||||
anomalyM: "이상항목 합계-중",
|
||||
anomalyS: "이상항목 합계-소",
|
||||
};
|
||||
});
|
||||
visibleDefectColumns.forEach((column) => {
|
||||
EXMN_DETAIL_SEVERITIES.forEach((severity) => {
|
||||
detailHeaderMapping[`${column.code}_${severity}`] =
|
||||
@@ -447,10 +608,18 @@ export function ExmnSurveyDetailPopupPage({
|
||||
specification: row.specification,
|
||||
extensionM: row.extensionM,
|
||||
drivingDistanceM: row.drivingDistanceM,
|
||||
};
|
||||
if (jobType === "MNHL") {
|
||||
Object.assign(result, {
|
||||
structuralGrade: row.structuralGrade,
|
||||
operationalGrade: row.operationalGrade,
|
||||
});
|
||||
}
|
||||
Object.assign(result, {
|
||||
anomalyL: anomalyTotals.L,
|
||||
anomalyM: anomalyTotals.M,
|
||||
anomalyS: anomalyTotals.S,
|
||||
};
|
||||
});
|
||||
visibleDefectColumns.forEach((column) => {
|
||||
EXMN_DETAIL_SEVERITIES.forEach((severity) => {
|
||||
result[`${column.code}_${severity}`] =
|
||||
@@ -469,9 +638,6 @@ export function ExmnSurveyDetailPopupPage({
|
||||
specification: "",
|
||||
extensionM: totals.extensionM,
|
||||
drivingDistanceM: totals.drivingDistanceM,
|
||||
anomalyL: defectSummary.anomalySubtotals.L,
|
||||
anomalyM: defectSummary.anomalySubtotals.M,
|
||||
anomalyS: defectSummary.anomalySubtotals.S,
|
||||
};
|
||||
const totalRow: Record<string, string | number> = {
|
||||
reportNo: "합계",
|
||||
@@ -483,10 +649,27 @@ export function ExmnSurveyDetailPopupPage({
|
||||
specification: "",
|
||||
extensionM: totals.extensionM,
|
||||
drivingDistanceM: totals.drivingDistanceM,
|
||||
};
|
||||
if (jobType === "MNHL") {
|
||||
Object.assign(subtotalRow, {
|
||||
structuralGrade: totals.structuralGrade,
|
||||
operationalGrade: totals.operationalGrade,
|
||||
});
|
||||
Object.assign(totalRow, {
|
||||
structuralGrade: totals.structuralGrade,
|
||||
operationalGrade: totals.operationalGrade,
|
||||
});
|
||||
}
|
||||
Object.assign(subtotalRow, {
|
||||
anomalyL: defectSummary.anomalySubtotals.L,
|
||||
anomalyM: defectSummary.anomalySubtotals.M,
|
||||
anomalyS: defectSummary.anomalySubtotals.S,
|
||||
});
|
||||
Object.assign(totalRow, {
|
||||
anomalyL: defectSummary.anomalyTotal,
|
||||
anomalyM: "",
|
||||
anomalyS: "",
|
||||
};
|
||||
});
|
||||
visibleDefectColumns.forEach((column) => {
|
||||
EXMN_DETAIL_SEVERITIES.forEach((severity) => {
|
||||
subtotalRow[`${column.code}_${severity}`] =
|
||||
@@ -501,12 +684,13 @@ export function ExmnSurveyDetailPopupPage({
|
||||
|
||||
const subtotalExcelRow = rows.length + 2;
|
||||
const totalExcelRow = rows.length + 3;
|
||||
const summaryLabelLastColumn = getExcelColumnName(summaryLabelColumnCount);
|
||||
const mergeRanges = [
|
||||
`A${subtotalExcelRow}:G${subtotalExcelRow}`,
|
||||
`A${totalExcelRow}:G${totalExcelRow}`,
|
||||
`A${subtotalExcelRow}:${summaryLabelLastColumn}${subtotalExcelRow}`,
|
||||
`A${totalExcelRow}:${summaryLabelLastColumn}${totalExcelRow}`,
|
||||
];
|
||||
for (
|
||||
let groupStartColumn = 10;
|
||||
let groupStartColumn = fixedColumns.length + 1;
|
||||
groupStartColumn < Object.keys(detailHeaderMapping).length;
|
||||
groupStartColumn += EXMN_DETAIL_SEVERITIES.length
|
||||
) {
|
||||
@@ -539,11 +723,11 @@ export function ExmnSurveyDetailPopupPage({
|
||||
};
|
||||
|
||||
const tableWidth = useMemo(
|
||||
() => FIXED_COLUMNS.reduce((sum, column) => sum + column.width, 0)
|
||||
() => fixedColumns.reduce((sum, column) => sum + column.width, 0)
|
||||
+ (visibleDefectColumns.length + 1)
|
||||
* EXMN_DETAIL_SEVERITIES.length
|
||||
* SEVERITY_COLUMN_WIDTH,
|
||||
[visibleDefectColumns.length],
|
||||
[fixedColumns, visibleDefectColumns.length],
|
||||
);
|
||||
/** 본단위구간 요약은 조회된 한 관로의 단위 수와 상태·결함 기록 수를 사용한다. */
|
||||
const unitSectionCount = useMemo(
|
||||
@@ -556,10 +740,10 @@ export function ExmnSurveyDetailPopupPage({
|
||||
? definition.title
|
||||
: `${definition.defectGroupTitle}조사 ${defectTypeLabel} 상세현황`;
|
||||
const tableTitle = isUnitMode
|
||||
? `${trtdstNm} 관로조사 본단위등급표`
|
||||
? `${selectedTrtdstNm} 관로조사 본단위등급표`
|
||||
: defectType === "ALL"
|
||||
? `${trtdstNm} ${definition.defectGroupTitle}조사 집계표`
|
||||
: `${trtdstNm} ${definition.defectGroupTitle}조사 ${defectTypeLabel} 집계표`;
|
||||
? `${selectedTrtdstNm} ${definition.defectGroupTitle}조사 집계표`
|
||||
: `${selectedTrtdstNm} ${definition.defectGroupTitle}조사 ${defectTypeLabel} 집계표`;
|
||||
const visibleDefectGroupTitle = defectType === "ALL"
|
||||
? definition.defectGroupTitle
|
||||
: `${definition.defectGroupTitle} ${defectTypeLabel}`;
|
||||
@@ -584,10 +768,27 @@ export function ExmnSurveyDetailPopupPage({
|
||||
<div className="sn-popup-field-label">처리분구</div>
|
||||
<select
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
defaultValue={trtdstId}
|
||||
value={selectedTrtdstId}
|
||||
aria-label="처리분구"
|
||||
onChange={(event) => {
|
||||
setSelectedTrtdstId(event.target.value);
|
||||
setActualRows([]);
|
||||
setActualUnitRows([]);
|
||||
setDetailNumberKeyword("");
|
||||
setAppliedDetailNumberKeyword("");
|
||||
setSort(undefined);
|
||||
setNotice(null);
|
||||
}}
|
||||
>
|
||||
<option value={trtdstId}>{trtdstNm}</option>
|
||||
{districtOptions.length > 0 ? (
|
||||
districtOptions.map((district) => (
|
||||
<option key={district.trtdstId} value={district.trtdstId}>
|
||||
{district.trtdstNm}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<option value={trtdstId}>{trtdstNm}</option>
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
@@ -666,9 +867,19 @@ export function ExmnSurveyDetailPopupPage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!sampleMode && !isDefectTypeView && (
|
||||
{isLoading && (
|
||||
<div className="shrink-0 border-b border-border bg-secondary px-4 py-2 text-[1.05rem] text-primary">
|
||||
실제 상세 데이터 API는 후속 백엔드 연동 단계에서 연결합니다. 화면 샘플은 `sample=Y`에서 확인할 수 있습니다.
|
||||
{isUnitMode
|
||||
? "본단위구간을 조회하고 있습니다."
|
||||
: isDefectTypeView
|
||||
? `${defectTypeLabel} 상세를 조회하고 있습니다.`
|
||||
: "조사 상세 집계를 조회하고 있습니다."}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadError && (
|
||||
<div role="alert" className="shrink-0 border border-red-200 bg-red-50 px-4 py-2 text-[1.05rem] text-red-700">
|
||||
{loadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -726,7 +937,7 @@ export function ExmnSurveyDetailPopupPage({
|
||||
style={{ width: `${tableWidth}px`, minWidth: `${tableWidth}px` }}
|
||||
>
|
||||
<colgroup>
|
||||
{FIXED_COLUMNS.map((column) => (
|
||||
{fixedColumns.map((column) => (
|
||||
<col key={column.id} style={{ width: `${column.width}px` }} />
|
||||
))}
|
||||
{Array.from({
|
||||
@@ -738,13 +949,10 @@ export function ExmnSurveyDetailPopupPage({
|
||||
</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) => {
|
||||
{fixedColumns.map((column, columnIndex) => {
|
||||
const sticky = columnIndex < STICKY_COLUMN_COUNT;
|
||||
const direction = sort?.key === column.id ? sort.direction : undefined;
|
||||
const columnLabel =
|
||||
column.id === "pipeNo" && jobType === "MNHL"
|
||||
? "맨홀\n번호"
|
||||
: column.label;
|
||||
const columnLabel = column.label;
|
||||
return (
|
||||
<th
|
||||
key={column.id}
|
||||
@@ -866,7 +1074,7 @@ export function ExmnSurveyDetailPopupPage({
|
||||
);
|
||||
return (
|
||||
<tr key={row.reportNo} className="sn-popup-grid-row sn-popup-grid-row-hover">
|
||||
{FIXED_COLUMNS.map((column, columnIndex) => {
|
||||
{fixedColumns.map((column, columnIndex) => {
|
||||
const sticky = columnIndex < STICKY_COLUMN_COUNT;
|
||||
return (
|
||||
<td
|
||||
@@ -927,11 +1135,13 @@ export function ExmnSurveyDetailPopupPage({
|
||||
소계
|
||||
</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"
|
||||
/>
|
||||
{summaryLabelColumnCount > STICKY_COLUMN_COUNT && (
|
||||
<td
|
||||
aria-hidden="true"
|
||||
colSpan={summaryLabelColumnCount - 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}`}
|
||||
@@ -980,11 +1190,13 @@ export function ExmnSurveyDetailPopupPage({
|
||||
합계
|
||||
</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"
|
||||
/>
|
||||
{summaryLabelColumnCount > STICKY_COLUMN_COUNT && (
|
||||
<td
|
||||
aria-hidden="true"
|
||||
colSpan={summaryLabelColumnCount - 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}`}
|
||||
@@ -1013,7 +1225,7 @@ export function ExmnSurveyDetailPopupPage({
|
||||
) : (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={FIXED_COLUMNS.length
|
||||
colSpan={fixedColumns.length
|
||||
+ (visibleDefectColumns.length + 1)
|
||||
* EXMN_DETAIL_SEVERITIES.length}
|
||||
className="sn-popup-empty-cell h-28 border-b border-r border-border text-center"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ExmnBusinessRegisterPopupPage } from "@/features/exmn/ui/ExmnBusinessRegisterPopupPage";
|
||||
import type { ExmnBusiness } from "@/features/exmn/model/exmn-business-types";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { WindowPopupContentProps } from "../model/types";
|
||||
|
||||
@@ -9,17 +8,6 @@ import type { WindowPopupContentProps } from "../model/types";
|
||||
const getParam = (value: string | string[] | undefined) =>
|
||||
Array.isArray(value) ? value[0] ?? "" : value ?? "";
|
||||
|
||||
/** 샘플 수정 팝업에 전달된 사업 JSON을 안전하게 복원한다. */
|
||||
const getBusiness = (value: string | string[] | undefined): ExmnBusiness | undefined => {
|
||||
const text = getParam(value);
|
||||
if (!text) return undefined;
|
||||
try {
|
||||
return JSON.parse(text) as ExmnBusiness;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/** 조사사업 등록 WindowPopup을 실제 업무 화면에 연결한다. */
|
||||
export default function ExmnBusinessRegisterPopupContent({ searchParams }: WindowPopupContentProps) {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
@@ -34,9 +22,7 @@ export default function ExmnBusinessRegisterPopupContent({ searchParams }: Windo
|
||||
|
||||
return (
|
||||
<ExmnBusinessRegisterPopupPage
|
||||
sampleMode={getParam(searchParams.sample) === "Y"}
|
||||
exmnBizId={getParam(searchParams.exmnBizId) || undefined}
|
||||
initialBusiness={getBusiness(searchParams.business)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ export default function ExmnResultRegisterPopupContent({ searchParams }: WindowP
|
||||
return (
|
||||
<ExmnResultRegisterPopupPage
|
||||
exmnBizId={businessSummary.exmnBizId}
|
||||
sampleMode={getParam(searchParams.sample) === "Y"}
|
||||
businessSummary={businessSummary}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -61,7 +61,6 @@ export default function ExmnSurveyDetailPopupContent({
|
||||
trtdstNm={getParam(searchParams.trtdstNm)}
|
||||
jobType={getJobType(searchParams.jobType)}
|
||||
defectType={getDefectType(searchParams.defectType)}
|
||||
sampleMode={getParam(searchParams.sample) === "Y"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user