조사결과 검토·저장 및 사업보고서 목록 표시 개선
This commit is contained in:
+50
-1
@@ -175,7 +175,7 @@
|
||||
- `bizRptpAtchFileId`가 있는 행만 보고서 텍스트에 파란색·밑줄·hover 스타일을 적용했다.
|
||||
- 보고서 링크 클릭 시 `stopPropagation()`으로 조사사업 행 선택 동작과 파일 다운로드 동작을 분리했다.
|
||||
- 기존 첨부서비스 경로 `/attach-service/api/v1/download/{atflId}`와 공통 `fileDownload()`를 재사용했다.
|
||||
- 실제 파일명 원천이 목록 응답에 없으므로 현재 다운로드명은 `${사업명}_사업보고서.pdf`다.
|
||||
- 목록 응답에는 실제 파일명이 없으므로 첨부 코드로 첨부서비스 메타데이터를 조회해 `orgnlFileNm`을 링크와 다운로드 파일명에 사용한다.
|
||||
- `sample=Y`는 실제 첨부서비스를 호출하지 않고 안내를 표시한다.
|
||||
- 실제 첨부 ID를 사용한 브라우저 다운로드와 PDF 열림 확인은 백엔드·첨부 데이터 준비 후 수행해야 한다.
|
||||
|
||||
@@ -264,3 +264,52 @@
|
||||
|
||||
- 다음 파일 업로드 단계에서 생성 양식 업로드와 Excel 파서 호환성을 확인한다.
|
||||
- 지도보기는 기존 보류 범위를 유지한다.
|
||||
|
||||
## 18. 2026-07-29 결과등록 파일별 검토 내역 정적 검증
|
||||
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| 파일별 검토 결과 독립 보관 | 통과 | Excel·관로 PDF·맨홀 PDF 상태와 메시지를 파일 구분별로 관리 |
|
||||
| Excel 재선택 시 의존 검토 초기화 | 통과 | 관로·맨홀 PDF 검토 상태와 내역을 함께 폐기 |
|
||||
| 검토 항목·건수·실패 사유 표시 | 통과 | 검토한 파일만 하단 내역에 표시 |
|
||||
| 최종 저장 검토 해시 | 통과 | 조사결과 Excel의 정상 검토 결과를 사용 |
|
||||
| 프론트 변경 공백·문법 흔적 검사 | 통과 | 대상 파일 `git diff --check` 통과 |
|
||||
|
||||
### 남은 검증
|
||||
|
||||
- 실제 로그인 화면에서 세 파일을 순서대로 검토해 하단 내역의 높이, 문구, 성공·실패 색상을 최종 확인한다.
|
||||
- 맨홀 PDF 내부 번호 대조는 검토 기준 확정 후 별도 검증한다.
|
||||
|
||||
## 19. 2026-07-29 사업보고서 원본 파일명 표시 정적 검증
|
||||
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| 첨부 메타데이터 원본 파일명 조회 | 통과 | 원문 JSON의 직접 배열·`data`·`content` 응답을 정규화한 뒤 첨부 ID가 일치하는 `orgnlFileNm` 선택 |
|
||||
| 목록 보고서 셀 표시 | 통과 | 원본 파일명 우선, 메타데이터 실패 시 `있음`, 미첨부 시 `없음` |
|
||||
| 다운로드 파일명 | 통과 | 원본 파일명 우선 사용 |
|
||||
| 목록 조회 장애 격리 | 통과 | 첨부 메타 조회 실패를 행별 `null`로 처리 |
|
||||
| 변경 파일 공백 검사 | 통과 | `git diff --check` 통과 |
|
||||
|
||||
### 남은 검증
|
||||
|
||||
- 로그인된 실제 화면에서 저장된 사업보고서 원본 파일명이 표시되고 같은 이름으로 다운로드되는지 확인한다.
|
||||
|
||||
## 20. 2026-07-29 결과등록 사용자 통합 검증과 목록 UI 최종 확인
|
||||
|
||||
| 검증 항목 | 결과 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| 조사결과 Excel 데이터 검토 | 통과 | 처리분구별 PPL·MNHL 및 본단위구간 검토 완료 |
|
||||
| 관로 PDF 관로번호 검토 | 통과 | PDF 관로번호가 Excel 관로번호에 포함되는지 확인 |
|
||||
| 파일별 검토 상태 표시 | 통과 | 성공은 연한 초록색·검토 완료, 실패는 연한 빨간색·검토 실패 |
|
||||
| 최종 저장 활성 조건 | 통과 | Excel과 관로 PDF 검토가 모두 정상일 때 활성 |
|
||||
| 조사결과 최종 저장 | 통과 | 사용자 화면에서 PPL/MNHL 저장·집계 완료 메시지 확인 |
|
||||
| 사업보고서 원본 파일명 표시 | 통과 | `있음` 대신 첨부 원본 파일명 표시 확인 |
|
||||
| 보고서 열 폭 | 통과 | 보고서 22%, 사업차수 6%, 처리구역·분구/정밀조사 연장 각 8% |
|
||||
| 좁은 헤더 한 줄 표시 | 통과 | 헤더 줄바꿈 금지와 간격·글자 크기 보정 |
|
||||
| 프론트 공백 검사 | 통과 | `git diff --check` |
|
||||
|
||||
### 남은 검증
|
||||
|
||||
- 사업보고서 링크 클릭 시 첨부 원본 파일명으로 실제 다운로드되는지 최종 확인한다.
|
||||
- 맨홀 PDF 내부 맨홀번호 대조는 검토 기준 협의 후 구현·검증한다.
|
||||
- GIS 기준시설과 조사결과 시설번호 대조 및 지도보기는 후속 범위다.
|
||||
|
||||
+75
-4
@@ -176,6 +176,10 @@
|
||||
- 사업등록 팝업은 자체 브라우저 컨텍스트에서 시도·시군구 store를 다시 로드하고, 활성 처리분구를 `localeCompare(..., "ko")` 기준 가나다순으로 정렬해 체크박스 다중 선택으로 제공한다.
|
||||
- 사업 저장 완료와 결과 저장 완료는 `window.opener.postMessage`로 부모 목록에 전달한다. 팝업 차단 시 부모 화면에 허용 안내를 표시한다.
|
||||
- 결과등록 팝업은 조사 결과, CCTV 조사 결과(관로), CCTV 조사 결과(맨홀), 사업보고서, 조사 위치도 5개 행을 표시하며 각 행의 파일명·찾기와 조사 원본의 데이터 검토 진입을 제공한다.
|
||||
- 파일 선택 규칙은 조사 결과를 `.xlsx`, `.xlsm`으로 제한하고 CCTV 조사 결과(관로)·CCTV 조사 결과(맨홀)는 각각 `.pdf`만 허용한다.
|
||||
- 상단의 중복 성공 알림과 검토 해시는 노출하지 않는다. 데이터 검토를 수행한 뒤에는 하단 `데이터 검토 내역`에서 파일별 검토 상태와 실제 확인 항목만 간단히 안내한다.
|
||||
- 파일별 검토 상태는 선택 파일 영역에서 완료 시 연한 초록색과 `검토 완료`, 실패 시 연한 빨간색과 `검토 실패`로 표시하며 파일을 다시 선택하면 해당 상태를 초기화한다.
|
||||
- 결과등록 WindowPopup의 기본 크기는 파일 5개 행이 여유 있게 보이도록 `1000×500`으로 설정한다.
|
||||
- 정상 검토 전 저장 버튼은 비활성이고 실제 모드의 Tus 첨부→첨부키 연결→commit 계약은 유지한다.
|
||||
- 백엔드 데이터가 없는 사용자 화면 검증을 위해 `/oper/exmn/register?sample=Y`만 샘플 모드로 사용한다. 일반 URL의 API 빈 결과를 자동 샘플로 대체하지 않는다.
|
||||
- 샘플 모드는 사업목록, 가나다순 처리분구, 정상 검토 요약을 제공하되 실제 API 저장·첨부·다운로드는 수행하지 않는다.
|
||||
@@ -190,7 +194,7 @@
|
||||
- 개발 환경의 목록 헤더에 `샘플 데이터 보기`를 제공한다. 이 버튼은 URL에 `sample=Y`를 명시해 기존 격리 원칙을 유지하며, 사업목록과 처리분구 샘플을 함께 활성화한다.
|
||||
- 사용자 재확인에 따라 팝업의 `샘플 화면` 배지를 제거하고 처리분구 목록을 고정 높이 `21rem`으로 확장했다. 3열 7행으로 21개까지 한 번에 표시하고 초과 후보는 목록 내부 세로 스크롤로 확인한다.
|
||||
- 정밀조사연장은 브라우저 증감 스피너가 없는 `text`+`inputMode=decimal` 입력으로 유지한다. 일반 등록 초기값은 빈 값이며 입력 중 문자를 강제 차단하지 않고 저장 시 숫자·0 이상 여부를 검증한다. 최대 10자로 첫 줄 안에서 표시한다.
|
||||
- 결과등록 popup definition은 Chrome 외곽 프레임을 포함한 사용자 목표 크기 약 `980×410`에 맞춰 콘텐츠 창 설정을 `980×340`으로 확정하고 샘플 배지와 닫기 버튼을 제거했다.
|
||||
- 결과등록 popup은 초기 `980×340`에서 파일별 검토 내역을 함께 표시할 수 있는 `1000×500`으로 최종 확장했으며 샘플 배지와 닫기 버튼은 표시하지 않는다.
|
||||
- 결과등록 상단은 `사업차수 → 사업년도 → 행정구역 → 사업명` 순서로 한 줄 표시한다. 짧은 항목은 좁고 사업명은 넓은 `0.7fr/0.8fr/1.2fr/2.3fr` 비율과 `gap-8` 간격을 사용한다.
|
||||
- 파일 5종 업로드 영역은 강한 셀 격자 대신 기존 화면처럼 연한 외곽선·헤더 배경·가로 행 구분선만 사용하는 4열 폼 목록으로 구성한다.
|
||||
- popup root는 `h-dvh`·`overflow-hidden`으로 브라우저 가로·세로 스크롤을 차단하고, 검토결과가 추가돼 실제 높이가 넘을 때만 본문 내부를 세로 스크롤한다.
|
||||
@@ -278,8 +282,9 @@
|
||||
### 9.15 조사사업 목록 사업보고서 직접 다운로드
|
||||
|
||||
- 정보검색 보고서 탭의 파란 밑줄 파일 링크와 기존 운영 화면의 첨부서비스 Blob 다운로드 패턴을 재사용했다.
|
||||
- 조사사업 목록에서 `bizRptpAtchFileId`가 있는 `보고서=있음` 셀만 파란색·밑줄·hover 색상으로 표시한다.
|
||||
- 링크 클릭은 행 선택 이벤트의 전파를 중단하고 `/attach-service/api/v1/download/{atflId}`에서 원본 Blob을 받은 뒤 `${사업명}_사업보고서.pdf`로 내려받는다.
|
||||
- 조사사업 목록에서 `bizRptpAtchFileId`가 있는 보고서 셀만 파란색·밑줄·hover 색상으로 표시한다.
|
||||
- 첨부 코드로 첨부서비스 메타데이터를 조회해 `있음` 대신 원본 파일명을 표시하고, 메타데이터 조회 실패 시에만 `있음`으로 대체한다.
|
||||
- 링크 클릭은 행 선택 이벤트의 전파를 중단하고 `/attach-service/api/v1/download/{atflId}`에서 원본 Blob을 받은 뒤 원본 파일명으로 내려받는다.
|
||||
- `reportYn=Y`여도 첨부파일 ID가 없으면 다운로드 링크로 표시하지 않는다.
|
||||
- `sample=Y`의 샘플 첨부 ID는 실제 파일로 위장하지 않고 다운로드 미지원 안내만 표시한다.
|
||||
- 신규 백엔드 API·DTO·DB 변경은 없으며 결과등록에서 저장한 `bizRptpAtchFileId`를 그대로 사용한다.
|
||||
@@ -292,7 +297,7 @@
|
||||
- `app/components/lib/utils/fileUploadUtil.tsx`: Tus 업로드 실패를 화면 Promise reject로 전달하는 `handleError` 콜백을 추가했다.
|
||||
- `src/app/styles/components.css`: 조사사업 전용 `sn-exmn-grid-scroll`을 제거해 Git 기준 전역 스타일 내용으로 복구했다. `git status`에 줄바꿈/작업트리 표시는 남을 수 있으나 의미 있는 CSS diff는 없다.
|
||||
- `src/features/exmn/index.ts`: 사업 API·검증·타입·등록/현황 화면 공개 export를 추가했다.
|
||||
- `src/features/windowPopup/model/popupDefinitions.ts`: 사업등록은 현재 `1000×620`, 결과등록은 `980×340` popup 정의를 사용한다.
|
||||
- `src/features/windowPopup/model/popupDefinitions.ts`: 사업등록은 현재 `1000×620`, 결과등록은 `1000×500` popup 정의를 사용한다.
|
||||
- `src/features/windowPopup/model/types.ts`: 사업 생성·결과 확정 opener 메시지 타입을 추가했다.
|
||||
- `src/features/windowPopup/registry.tsx`: 조사사업 popup content 2종을 registry에 등록했다.
|
||||
- `tailwind.config.ts`: `./src/**/*`를 content에 포함해 조사사업 popup의 grid·임의 폭 클래스 생성을 복구했다.
|
||||
@@ -495,3 +500,69 @@
|
||||
|
||||
- 조사사업 목록, 사업등록/수정 WindowPopup, 조사 입력·보고서 양식 다운로드, 상세현황 빈 상태에 영향을 준다.
|
||||
- 조사 상세보기 표와 지도보기 보류 범위는 변경하지 않는다.
|
||||
|
||||
## 18. 2026-07-28 조사결과 Excel 검증 API 전달 보완
|
||||
|
||||
- 결과등록 팝업의 첫 번째 `조사 결과` 파일을 multipart `exmnFile`로 전달한다.
|
||||
- 조사결과 검토의 필수 원본은 통합 XLSX/XLSM 한 파일로 정리했다.
|
||||
- 기존 PPL·MNHL 별도 필드 전달은 후속 PDF 검증 계약과의 호환을 위해 유지하되 이번 서버 파싱 원본으로 사용하지 않는다.
|
||||
- validate 응답의 `fileHash`는 commit 요청에 다시 전달해 검토 후 파일 변경을 차단한다.
|
||||
- 실제 로그인 환경에서 데이터 검토와 저장 버튼을 통한 DB 반영을 확인해야 한다.
|
||||
|
||||
## 19. 2026-07-28 결과등록 첨부서비스 연계
|
||||
|
||||
- 관로·맨홀 결과 파일 유형 키를 실제 형식에 맞춰 `PPL_EXMN_PDF`, `MNHL_EXMN_PDF`로 변경했다.
|
||||
- 최종 저장 시 선택된 5종 파일을 기존 TUS 첨부서비스에 순서대로 업로드한다.
|
||||
- 첨부서비스 응답 헤더 `X-File-Info`에서 `atflCd`, `atflId`를 읽어 파일 유형별 첨부키 맵을 구성한다.
|
||||
- `PUT /api/v1/exmnBusinesses/{exmnBizId}/attachments`로 첨부키를 조사사업정보에 연결한 뒤 검토된 통합 Excel을 확정 저장한다.
|
||||
- 업로드 중인 파일명과 진행률을 팝업 하단에 표시한다.
|
||||
- 파일 바이너리는 첨부서비스가 관리하며 프론트는 조사사업 DB에 파일 자체를 직접 저장하지 않는다.
|
||||
- 관로·맨홀 PDF의 내부 번호 대조 검증은 이번 범위에 포함하지 않는다.
|
||||
- TypeScript 검사는 설치된 TypeScript와 `tsconfig`의 `ignoreDeprecations` 값 불일치로 본문 타입검사 전에 중단됐다.
|
||||
|
||||
## 20. 2026-07-29 관로 PDF 관로번호 검토 연계
|
||||
|
||||
- 데이터 검토 요청에 클릭한 파일 구분을 `validationType`으로 전달한다.
|
||||
- 관로 PDF 검토 시 조사결과 Excel `exmnFile`과 관로 PDF `pplFile`을 함께 전송한다.
|
||||
- 서버가 반환한 관로번호 불일치 오류 메시지를 팝업 알림에 최대 5건까지 표시한다.
|
||||
- 관로 PDF 검토 성공은 조사결과 Excel 전체 검토 성공으로 간주하지 않고 두 상태를 독립적으로 표시한다.
|
||||
- 조사결과 Excel을 다시 선택하면 기존 관로 PDF 검토 상태를 폐기한다.
|
||||
- 최종 저장은 조사결과 Excel과 관로 PDF의 검토 상태가 모두 정상일 때만 활성화한다.
|
||||
- 맨홀 PDF 번호 대조는 협의 전이므로 이번 프론트 저장 조건에 추가하지 않았다.
|
||||
|
||||
## 21. 2026-07-29 관로 PDF 경량 검토 상태 분리
|
||||
|
||||
- 관로 PDF 검토가 경량 관로번호 대조로 변경됨에 따라 성공 시 `PPL_EXMN_PDF` 상태만 정상으로 기록한다.
|
||||
- `EXMN_EXCEL`은 사용자가 조사결과 데이터 검토를 별도로 성공한 경우에만 정상 상태가 된다.
|
||||
- 어느 버튼을 먼저 검토하더라도 두 상태가 모두 정상이고 현재 Excel 해시가 일치해야 최종 저장할 수 있다.
|
||||
|
||||
## 22. 2026-07-29 결과등록 파일별 검토 내역 표시
|
||||
|
||||
- 데이터 검토를 수행한 파일만 팝업 하단 `데이터 검토 내역`에 입력 순서대로 표시한다.
|
||||
- 조사결과 Excel은 처리분구별 관로·맨홀 시트와 필수값, 시설번호 중복, 관로번호와 본단위구간 연결, 결함코드·점수·등급 및 저장 가능 여부를 확인한 것으로 안내한다.
|
||||
- 관로 PDF는 관로번호 텍스트 추출과 조사결과 Excel 관로번호 포함 여부를 확인한 것으로 안내한다.
|
||||
- 맨홀 PDF는 현재 파일 형식과 조사결과 Excel 구조·저장 가능 여부까지만 안내하고, PDF 내부 맨홀번호 대조는 기준 협의 후 적용할 범위로 명시한다.
|
||||
- 성공 시 처리 건수와 검토 완료 배지를, 실패 시 첫 오류와 추가 오류 건수를 표시한다.
|
||||
- 조사결과 Excel을 다시 선택하면 이에 의존하는 관로·맨홀 PDF 검토 상태와 내역도 함께 초기화한다.
|
||||
- 최종 저장은 조사결과 Excel 검토 결과의 해시를 사용하며, 선택적으로 수행한 맨홀 PDF 검토 결과가 Excel 검토 결과를 덮어쓰지 않도록 파일별 상태를 분리했다.
|
||||
|
||||
## 23. 2026-07-29 조사사업 목록 사업보고서 원본 파일명 표시
|
||||
|
||||
- 조사사업 목록 응답의 `bizRptpAtchFileCd`, `bizRptpAtchFileId`로 기존 첨부서비스 메타데이터를 조회한다.
|
||||
- 첨부서비스는 공통 응답 래퍼 없이 배열을 직접 반환할 수 있으므로 `getRawJson()`으로 원문을 읽고 직접 배열·`data`·`content` 형식을 모두 정규화한다.
|
||||
- 첨부 ID와 일치하는 `orgnlFileNm`을 목록의 보고서 링크 텍스트와 다운로드 파일명으로 사용한다.
|
||||
- 보고서가 없으면 `없음`, 첨부 메타데이터 조회만 실패하면 기존 첨부 여부를 보존하기 위해 `있음`으로 표시한다.
|
||||
- 첨부서비스 메타데이터 오류가 조사사업 목록 전체 조회를 실패시키지 않도록 행별 파일명 조회 실패를 격리했다.
|
||||
- 조사사업 API·DB 계약과 보고서 정렬 기준은 변경하지 않았다.
|
||||
|
||||
## 24. 2026-07-29 결과등록 통합 확인과 보고서 열 최종 보완
|
||||
|
||||
- 사용자가 실제 조사결과 Excel과 관로·맨홀 PDF를 선택해 파일별 데이터 검토가 정상 완료되는 것을 확인했다.
|
||||
- 조사결과 Excel 검토와 관로 PDF 검토가 모두 정상일 때 저장 버튼이 활성화되고, 최종 저장 후 `조사결과 최종 저장과 PPL/MNHL 집계를 완료했습니다.` 응답을 확인했다.
|
||||
- 최종 저장은 선택 파일을 TUS 첨부서비스에 업로드하고 조사사업 첨부키를 연결한 뒤, 검토 해시가 일치하는 Excel을 commit하는 기존 순서를 유지한다.
|
||||
- 파일별 검토 내역은 조사결과 Excel의 시트·중복·본단위구간·결함/점수/등급 검토와 관로 PDF의 관로번호 포함 여부를 구분해 표시한다.
|
||||
- 맨홀 PDF 내부 번호 대조는 협의 전이므로 파일 형식과 Excel 저장 가능 범위까지만 안내한다.
|
||||
- 조사사업 목록의 보고서 링크는 저장된 원본 파일명을 표시하고 동일 이름으로 다운로드한다.
|
||||
- 보고서 파일명이 잘리지 않도록 목록 열 비율을 `사업차수 6%`, `처리구역·분구 8%`, `정밀조사 연장 8%`, `보고서 22%`로 최종 조정했다.
|
||||
- 좁아진 두 숫자 열의 헤더는 좌우 여백과 글자 크기를 보정하고 줄바꿈을 금지해 한 줄로 표시한다.
|
||||
- 매우 긴 보고서명은 셀 오른쪽에서 말줄임하고 `title`로 전체 이름을 확인할 수 있다.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
getBlobData,
|
||||
getData,
|
||||
getRawJson,
|
||||
postMutiPart,
|
||||
postRawJson,
|
||||
putData,
|
||||
@@ -26,6 +27,12 @@ import type {
|
||||
/** 조사사업 API의 공통 기준 경로다. */
|
||||
const EXMN_BUSINESS_API_PATH = "v1/exmnBusinesses";
|
||||
|
||||
/** 첨부서비스의 사업보고서 원본 파일명 조회에 필요한 최소 응답 계약이다. */
|
||||
interface AttachmentMetadata {
|
||||
atflId?: string | null;
|
||||
orgnlFileNm?: string | null;
|
||||
}
|
||||
|
||||
/** 공통 응답 원문 또는 data 래퍼 양쪽에서 실제 payload를 해석한다. */
|
||||
const unwrapData = <T>(response: T | { data?: T }): T =>
|
||||
typeof response === "object" && response !== null && "data" in response
|
||||
@@ -41,6 +48,40 @@ const toPage = <T>(value: Partial<ExmnPage<T>> | null | undefined): ExmnPage<T>
|
||||
totalPages: Number(value?.totalPages ?? 0),
|
||||
});
|
||||
|
||||
/**
|
||||
* 사업보고서 첨부 코드로 원본 파일명을 조회한다.
|
||||
* 첨부 메타데이터 장애가 조사사업 목록 전체를 막지 않도록 실패 시 null로 대체한다.
|
||||
*/
|
||||
const getBusinessReportFileName = async (
|
||||
attachmentCode: string,
|
||||
attachmentId: string,
|
||||
): Promise<string | null> => {
|
||||
try {
|
||||
const response = await getRawJson(
|
||||
`/attach-service/api/v1/atfl/attachments/${encodeURIComponent(attachmentCode)}`,
|
||||
) as AttachmentMetadata[]
|
||||
| { data?: AttachmentMetadata[] | { content?: AttachmentMetadata[] } }
|
||||
| { content?: AttachmentMetadata[] }
|
||||
| null;
|
||||
const payload = (
|
||||
typeof response === "object"
|
||||
&& response !== null
|
||||
&& !Array.isArray(response)
|
||||
&& "data" in response
|
||||
) ? response.data : response;
|
||||
const attachments = Array.isArray(payload)
|
||||
? payload
|
||||
: Array.isArray(payload?.content)
|
||||
? payload.content
|
||||
: [];
|
||||
const attachment = attachments.find((item) => item.atflId === attachmentId)
|
||||
?? attachments[0];
|
||||
return attachment?.orgnlFileNm?.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/** 조사사업 목록을 조회조건·0-base 페이지·서버 정렬 계약으로 요청한다. */
|
||||
export const getExmnBusinesses = async (
|
||||
params: ExmnBusinessSearchParams,
|
||||
@@ -56,7 +97,19 @@ export const getExmnBusinesses = async (
|
||||
if (params.bizNm?.trim()) query.set("bizNm", params.bizNm.trim());
|
||||
if (params.sort) query.set("sort", params.sort);
|
||||
|
||||
return toPage<ExmnBusiness>(await getData(`${EXMN_BUSINESS_API_PATH}?${query}`));
|
||||
const page = toPage<ExmnBusiness>(
|
||||
await getData(`${EXMN_BUSINESS_API_PATH}?${query}`),
|
||||
);
|
||||
const content = await Promise.all(page.content.map(async (business) => {
|
||||
const attachmentCode = business.bizRptpAtchFileCd?.trim();
|
||||
const attachmentId = business.bizRptpAtchFileId?.trim();
|
||||
if (!attachmentCode || !attachmentId) return business;
|
||||
return {
|
||||
...business,
|
||||
bizRptpFileName: await getBusinessReportFileName(attachmentCode, attachmentId),
|
||||
};
|
||||
}));
|
||||
return { ...page, content };
|
||||
};
|
||||
|
||||
/** 선택 사업의 처리분구별 관로·맨홀 조사 상세현황을 조회한다. */
|
||||
|
||||
@@ -8,9 +8,9 @@ export const EXMN_MAX_FILE_SIZE = 200 * 1024 * 1024;
|
||||
|
||||
/** 조사사업 파일별 화면 표시명과 허용 확장자다. */
|
||||
export const EXMN_FILE_RULES: Record<ExmnFileType, { label: string; extensions: string[] }> = {
|
||||
EXMN_EXCEL: { label: "조사 결과", extensions: ["xlsx", "xls"] },
|
||||
PPL_EXMN_EXCEL: { label: "CCTV 조사 결과(관로)", extensions: ["xlsx", "xls"] },
|
||||
MNHL_EXMN_EXCEL: { label: "CCTV 조사 결과(맨홀)", extensions: ["xlsx", "xls"] },
|
||||
EXMN_EXCEL: { label: "조사 결과", extensions: ["xlsx", "xlsm"] },
|
||||
PPL_EXMN_PDF: { label: "CCTV 조사 결과(관로)", extensions: ["pdf"] },
|
||||
MNHL_EXMN_PDF: { label: "CCTV 조사 결과(맨홀)", extensions: ["pdf"] },
|
||||
BIZ_REPORT: { label: "사업보고서", extensions: ["pdf"] },
|
||||
LOCATION_MAP: { label: "조사 위치도", extensions: ["pdf", "png", "jpg", "jpeg"] },
|
||||
};
|
||||
@@ -39,12 +39,12 @@ export const validateExmnFile = (fileType: ExmnFileType, file: File): string | n
|
||||
return null;
|
||||
};
|
||||
|
||||
/** PPL 또는 MNHL 파일이 하나 이상 선택됐고 모든 파일이 1차 규칙을 통과하는지 확인한다. */
|
||||
/** 조사결과 Excel이 선택됐고 모든 파일이 1차 규칙을 통과하는지 확인한다. */
|
||||
export const validateExmnFiles = (
|
||||
files: Partial<Record<ExmnFileType, File>>,
|
||||
): string | null => {
|
||||
if (!files.PPL_EXMN_EXCEL && !files.MNHL_EXMN_EXCEL) {
|
||||
return "검토할 PPL 또는 MNHL 조사 엑셀을 1개 이상 선택해주세요.";
|
||||
if (!files.EXMN_EXCEL) {
|
||||
return "검토할 조사 결과 Excel 파일을 선택해주세요.";
|
||||
}
|
||||
for (const [fileType, file] of Object.entries(files) as [ExmnFileType, File][]) {
|
||||
const message = validateExmnFile(fileType, file);
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface ExmnBusiness {
|
||||
tpco: number;
|
||||
bizRptpAtchFileCd?: string | null;
|
||||
bizRptpAtchFileId?: string | null;
|
||||
/** 첨부서비스에서 조회한 사업보고서 원본 파일명이다. */
|
||||
bizRptpFileName?: string | null;
|
||||
reportYn?: "Y" | "N";
|
||||
}
|
||||
|
||||
@@ -78,8 +80,8 @@ export interface ExmnTemplateResult {
|
||||
/** 조사사업에서 관리하는 파일의 업무 구분이다. */
|
||||
export type ExmnFileType =
|
||||
| "EXMN_EXCEL"
|
||||
| "PPL_EXMN_EXCEL"
|
||||
| "MNHL_EXMN_EXCEL"
|
||||
| "PPL_EXMN_PDF"
|
||||
| "MNHL_EXMN_PDF"
|
||||
| "BIZ_REPORT"
|
||||
| "LOCATION_MAP";
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ export const EXMN_SAMPLE_BUSINESSES: ExmnBusiness[] = [
|
||||
reportYn: "Y",
|
||||
bizRptpAtchFileCd: "SAMPLE",
|
||||
bizRptpAtchFileId: "SAMPLE-REPORT-001",
|
||||
bizRptpFileName: "2026년_양평군_정밀조사_사업보고서.pdf",
|
||||
},
|
||||
{
|
||||
exmnBizId: "SAMPLE-BIZ-002",
|
||||
@@ -55,6 +56,7 @@ export const EXMN_SAMPLE_BUSINESSES: ExmnBusiness[] = [
|
||||
reportYn: "Y",
|
||||
bizRptpAtchFileCd: "SAMPLE",
|
||||
bizRptpAtchFileId: "SAMPLE-REPORT-003",
|
||||
bizRptpFileName: "2024년_원주시_관로맨홀_사업보고서.pdf",
|
||||
},
|
||||
{
|
||||
exmnBizId: "SAMPLE-BIZ-004",
|
||||
|
||||
@@ -40,14 +40,14 @@ const BUSINESS_LIST_SCROLL_FETCH_SIZE = 1000;
|
||||
|
||||
const BUSINESS_TABLE_HEAD = [
|
||||
{ id: "rowNumber", title: "NO", widthPercent: 5, sortable: true },
|
||||
{ id: "bizCyclText", title: "사업차수", widthPercent: 7, sortable: true, sortKey: "bizCycl" },
|
||||
{ id: "bizCyclText", title: "사업차수", widthPercent: 6, sortable: true, sortKey: "bizCycl" },
|
||||
{ id: "bizYr", title: "사업년도", widthPercent: 7, sortable: true },
|
||||
{ id: "ctpvNm", title: "시도", widthPercent: 9, sortable: true },
|
||||
{ id: "sggNm", title: "시군구", widthPercent: 9, sortable: true },
|
||||
{ id: "bizNm", title: "사업명", widthPercent: 26, sortable: true },
|
||||
{ id: "trtdstCntText", title: "처리구역·분구(개소)", widthPercent: 12, sortable: true, sortKey: "trtdstCnt" },
|
||||
{ id: "prcisExmnPrlgText", title: "정밀조사 연장(km)", widthPercent: 13, sortable: true, sortKey: "prcisExmnPrlg" },
|
||||
{ id: "reportYnText", title: "보고서", widthPercent: 12, sortable: true, sortKey: "reportYn" },
|
||||
{ id: "trtdstCntText", title: "처리구역·분구(개소)", widthPercent: 8, sortable: true, sortKey: "trtdstCnt" },
|
||||
{ id: "prcisExmnPrlgText", title: "정밀조사 연장(km)", widthPercent: 8, sortable: true, sortKey: "prcisExmnPrlg" },
|
||||
{ id: "reportYnText", title: "보고서", widthPercent: 22, sortable: true, sortKey: "reportYn" },
|
||||
];
|
||||
|
||||
interface BusinessFilters {
|
||||
@@ -193,7 +193,7 @@ export function ExmnRegistrationPage() {
|
||||
window.location.assign(`${url.pathname}${url.search}`);
|
||||
};
|
||||
|
||||
/** 사업 목록의 숫자·행정구역·보고서 여부를 표시 전용 값으로 변환한다. */
|
||||
/** 사업 목록의 숫자·행정구역·사업보고서 파일명을 표시 전용 값으로 변환한다. */
|
||||
const businessRows = useMemo(() => businesses.map((business, index) => ({
|
||||
...business,
|
||||
rowNumber: sort?.key === "rowNumber" && sort.direction === "desc"
|
||||
@@ -202,7 +202,8 @@ export function ExmnRegistrationPage() {
|
||||
bizCyclText: business.bizCycl == null ? "-" : `${business.bizCycl}차`,
|
||||
trtdstCntText: Number(business.trtdstCnt ?? 0).toLocaleString("ko-KR"),
|
||||
prcisExmnPrlgText: Number(business.prcisExmnPrlg ?? 0).toLocaleString("ko-KR", { maximumFractionDigits: 3 }),
|
||||
reportYnText: business.reportYn === "Y" || business.bizRptpAtchFileId ? "있음" : "없음",
|
||||
reportYnText: business.bizRptpFileName?.trim()
|
||||
|| (business.reportYn === "Y" || business.bizRptpAtchFileId ? "있음" : "없음"),
|
||||
})), [businesses, sort]);
|
||||
|
||||
/** 화면 조건을 행정구역 이름과 목록 내부 스크롤용 단일 서버 Page 계약으로 변환한다. */
|
||||
@@ -310,7 +311,10 @@ export function ExmnRegistrationPage() {
|
||||
setNotice(null);
|
||||
try {
|
||||
const reportBlob = await downloadExmnBusinessReport(attachmentId);
|
||||
fileDownload(reportBlob, `${business.bizNm}_사업보고서.pdf`);
|
||||
fileDownload(
|
||||
reportBlob,
|
||||
business.bizRptpFileName?.trim() || `${business.bizNm}_사업보고서.pdf`,
|
||||
);
|
||||
} catch (error) {
|
||||
setNotice({
|
||||
type: "error",
|
||||
@@ -548,10 +552,14 @@ export function ExmnRegistrationPage() {
|
||||
const sortKey = "sortKey" in head && head.sortKey ? head.sortKey : head.id;
|
||||
const direction = sort?.key === sortKey ? sort.direction : undefined;
|
||||
const sortable = "sortable" in head && head.sortable;
|
||||
const compactHeader = head.id === "trtdstCntText"
|
||||
|| head.id === "prcisExmnPrlgText";
|
||||
return (
|
||||
<TableHead
|
||||
key={head.id}
|
||||
className="sticky top-0 z-20 bg-[#eaf3fb] text-center"
|
||||
className={`sticky top-0 z-20 whitespace-nowrap bg-[#eaf3fb] text-center ${
|
||||
compactHeader ? "!px-1" : ""
|
||||
}`}
|
||||
style={{
|
||||
borderTopWidth: 0,
|
||||
boxShadow: "inset 0 1px 0 var(--border)",
|
||||
@@ -569,11 +577,13 @@ export function ExmnRegistrationPage() {
|
||||
{sortable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex w-full items-center justify-center gap-1.5"
|
||||
className="inline-flex w-full items-center justify-center gap-1 whitespace-nowrap"
|
||||
aria-label={`${head.title} 정렬`}
|
||||
onClick={() => handleBusinessSort(head)}
|
||||
>
|
||||
<span>{head.title}</span>
|
||||
<span className={compactHeader ? "text-[1.2rem]" : undefined}>
|
||||
{head.title}
|
||||
</span>
|
||||
{direction === "asc" ? (
|
||||
<span
|
||||
className="inline-flex min-w-2 justify-center text-[8px] leading-none text-[#1d4ed8]"
|
||||
@@ -621,15 +631,19 @@ export function ExmnRegistrationPage() {
|
||||
<TableCell key={head.id} className="!p-0">
|
||||
<button
|
||||
type="button"
|
||||
className={`flex min-h-[4rem] w-full cursor-pointer items-center justify-center px-2 py-[1.2rem] text-[1.3rem] ${
|
||||
className={`flex min-h-[4rem] min-w-0 w-full cursor-pointer items-center justify-center px-2 py-[1.2rem] text-[1.3rem] ${
|
||||
reportAvailable
|
||||
? "font-semibold text-blue-600 underline underline-offset-4 hover:text-blue-800"
|
||||
: selected
|
||||
? "font-bold"
|
||||
: "font-normal"
|
||||
}`}
|
||||
aria-label={reportAvailable ? `${business.bizNm} 사업보고서 다운로드` : undefined}
|
||||
title={reportAvailable ? "사업보고서 다운로드" : undefined}
|
||||
aria-label={reportAvailable
|
||||
? `${business.reportYnText} 다운로드`
|
||||
: undefined}
|
||||
title={reportAvailable
|
||||
? `${business.reportYnText} 다운로드`
|
||||
: undefined}
|
||||
onClick={(event) => {
|
||||
if (reportAvailable) {
|
||||
event.stopPropagation();
|
||||
@@ -639,7 +653,9 @@ export function ExmnRegistrationPage() {
|
||||
setSelectedBusinessId(business.exmnBizId);
|
||||
}}
|
||||
>
|
||||
{String(business[head.id as keyof typeof business] ?? "")}
|
||||
<span className={isReportCell ? "block min-w-0 w-full truncate" : undefined}>
|
||||
{String(business[head.id as keyof typeof business] ?? "")}
|
||||
</span>
|
||||
</button>
|
||||
</TableCell>
|
||||
);
|
||||
|
||||
@@ -5,9 +5,8 @@ import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import { postPopupMessageAndClose } from "@/features/windowPopup/lib/popupMessage";
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
|
||||
import { Button } from "@common/components/ui";
|
||||
import { UiTable } from "@common/business_components/ui";
|
||||
import type { HttpResponse } from "tus-js-client";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
commitExmnImport,
|
||||
getExmnBusiness,
|
||||
@@ -36,27 +35,48 @@ const ACTION_BUTTON_CLASS =
|
||||
|
||||
const FILE_TYPES: ExmnFileType[] = [
|
||||
"EXMN_EXCEL",
|
||||
"PPL_EXMN_EXCEL",
|
||||
"MNHL_EXMN_EXCEL",
|
||||
"PPL_EXMN_PDF",
|
||||
"MNHL_EXMN_PDF",
|
||||
"BIZ_REPORT",
|
||||
"LOCATION_MAP",
|
||||
];
|
||||
|
||||
const VALIDATION_BUTTON_FILE_TYPES = new Set<ExmnFileType>([
|
||||
const VALIDATION_FILE_TYPES: ExmnFileType[] = [
|
||||
"EXMN_EXCEL",
|
||||
"PPL_EXMN_EXCEL",
|
||||
"MNHL_EXMN_EXCEL",
|
||||
]);
|
||||
|
||||
const ERROR_TABLE_HEAD = [
|
||||
{ id: "rowNumberText", title: "행", widthPercent: 7 },
|
||||
{ id: "sheetName", title: "시트/처리분구", widthPercent: 17 },
|
||||
{ id: "gisManagementNo", title: "GIS 관리번호", widthPercent: 16 },
|
||||
{ id: "fieldPath", title: "필드/JSON 경로", widthPercent: 24 },
|
||||
{ id: "code", title: "오류코드", widthPercent: 13 },
|
||||
{ id: "message", title: "오류사유", widthPercent: 23 },
|
||||
"PPL_EXMN_PDF",
|
||||
"MNHL_EXMN_PDF",
|
||||
];
|
||||
|
||||
const VALIDATION_BUTTON_FILE_TYPES = new Set<ExmnFileType>(VALIDATION_FILE_TYPES);
|
||||
|
||||
/** 파일별로 실제 수행하는 검토 항목과 아직 적용하지 않은 범위를 사용자 문구로 정의한다. */
|
||||
const VALIDATION_CHECK_GUIDES: Partial<Record<
|
||||
ExmnFileType,
|
||||
{ checkedItems: string[]; pendingNote?: string }
|
||||
>> = {
|
||||
EXMN_EXCEL: {
|
||||
checkedItems: [
|
||||
"처리분구별 관로·맨홀 시트와 필수값",
|
||||
"관로·맨홀 번호 중복",
|
||||
"관로번호와 본단위구간 연결",
|
||||
"결함코드·점수·등급 및 저장 가능 여부",
|
||||
],
|
||||
},
|
||||
PPL_EXMN_PDF: {
|
||||
checkedItems: [
|
||||
"PDF 관로번호 텍스트 추출",
|
||||
"PDF 관로번호의 조사결과 Excel 포함 여부",
|
||||
],
|
||||
},
|
||||
MNHL_EXMN_PDF: {
|
||||
checkedItems: [
|
||||
"PDF 파일 형식",
|
||||
"조사결과 Excel 구조 및 저장 가능 여부",
|
||||
],
|
||||
pendingNote: "PDF 내부 맨홀번호 대조는 검토 기준 협의 후 적용 예정입니다.",
|
||||
},
|
||||
};
|
||||
|
||||
interface SelectedFile {
|
||||
file: File;
|
||||
fileList: FileList;
|
||||
@@ -64,6 +84,9 @@ interface SelectedFile {
|
||||
|
||||
type SelectedFiles = Partial<Record<ExmnFileType, SelectedFile>>;
|
||||
type Notice = { type: "success" | "error" | "info"; text: string } | null;
|
||||
type FileValidationStatus = "success" | "error";
|
||||
type FileValidationResults = Partial<Record<ExmnFileType, ExmnImportValidationResult>>;
|
||||
type FileValidationMessages = Partial<Record<ExmnFileType, string[]>>;
|
||||
|
||||
interface ExmnResultRegisterPopupPageProps {
|
||||
exmnBizId: string;
|
||||
@@ -75,17 +98,18 @@ interface ExmnResultRegisterPopupPageProps {
|
||||
const buildImportFormData = (
|
||||
files: SelectedFiles,
|
||||
validation?: ExmnImportValidationResult | null,
|
||||
attachments?: Partial<Record<ExmnFileType, ExmnAttachmentKey>>,
|
||||
validationType?: ExmnFileType,
|
||||
) => {
|
||||
const formData = new FormData();
|
||||
if (files.PPL_EXMN_EXCEL) formData.append("pplFile", files.PPL_EXMN_EXCEL.file);
|
||||
if (files.MNHL_EXMN_EXCEL) formData.append("mnhlFile", files.MNHL_EXMN_EXCEL.file);
|
||||
if (files.EXMN_EXCEL) formData.append("exmnFile", files.EXMN_EXCEL.file);
|
||||
if (files.PPL_EXMN_PDF) formData.append("pplFile", files.PPL_EXMN_PDF.file);
|
||||
if (files.MNHL_EXMN_PDF) formData.append("mnhlFile", files.MNHL_EXMN_PDF.file);
|
||||
if (validation?.fileHash) formData.append("fileHash", validation.fileHash);
|
||||
if (attachments) formData.append("attachments", JSON.stringify(attachments));
|
||||
if (validationType) formData.append("validationType", validationType);
|
||||
return formData;
|
||||
};
|
||||
|
||||
/** 기존 Tus 첨부서비스로 파일 한 건을 전송하고 서버 파일키를 반환한다. */
|
||||
/** 기존 TUS 첨부서비스로 파일 한 건을 전송하고 발급된 파일 코드·아이디를 반환한다. */
|
||||
const uploadAttachment = (
|
||||
selected: SelectedFile,
|
||||
onProgress: (percentage: string) => void,
|
||||
@@ -102,11 +126,19 @@ const uploadAttachment = (
|
||||
const fileInfo = response.getHeader("X-File-Info");
|
||||
if (!fileInfo) throw new Error("첨부서비스가 파일키를 반환하지 않았습니다.");
|
||||
const parsed = JSON.parse(fileInfo) as { atflCd?: string; atflId?: string };
|
||||
if (!parsed.atflCd || !parsed.atflId) throw new Error("첨부서비스 파일키 형식이 올바르지 않습니다.");
|
||||
if (!parsed.atflCd || !parsed.atflId) {
|
||||
throw new Error("첨부서비스 파일키 형식이 올바르지 않습니다.");
|
||||
}
|
||||
settled = true;
|
||||
resolve({ atflCd: parsed.atflCd, atflId: parsed.atflId, fileName: selected.file.name });
|
||||
resolve({
|
||||
atflCd: parsed.atflCd,
|
||||
atflId: parsed.atflId,
|
||||
fileName: selected.file.name,
|
||||
});
|
||||
} catch (error) {
|
||||
rejectOnce(error instanceof Error ? error : new Error("첨부파일 응답을 해석할 수 없습니다."));
|
||||
rejectOnce(error instanceof Error
|
||||
? error
|
||||
: new Error("첨부파일 응답을 해석할 수 없습니다."));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -125,9 +157,16 @@ export function ExmnResultRegisterPopupPage({
|
||||
}: ExmnResultRegisterPopupPageProps) {
|
||||
const [detail, setDetail] = useState<ExmnBusinessDetail | null>(null);
|
||||
const [files, setFiles] = useState<SelectedFiles>({});
|
||||
const [validation, setValidation] = useState<ExmnImportValidationResult | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
/** 파일별 서버 검토 결과를 보관해 다른 파일 검토 후에도 결과와 건수를 유지한다. */
|
||||
const [validationResults, setValidationResults] = useState<FileValidationResults>({});
|
||||
const [fileValidationStatuses, setFileValidationStatuses] = useState<
|
||||
Partial<Record<ExmnFileType, FileValidationStatus>>
|
||||
>({});
|
||||
/** 서버 호출 전 오류와 예외 메시지도 해당 파일의 검토 내역에 남긴다. */
|
||||
const [fileValidationMessages, setFileValidationMessages] =
|
||||
useState<FileValidationMessages>({});
|
||||
const [uploadStatus, setUploadStatus] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [notice, setNotice] = useState<Notice>(null);
|
||||
|
||||
/** popup layout 상위 html/body에서 생기는 문서 스크롤바를 팝업이 열린 동안 잠근다. */
|
||||
@@ -145,15 +184,6 @@ export function ExmnResultRegisterPopupPage({
|
||||
};
|
||||
}, []);
|
||||
|
||||
/** 검토 오류를 UiTable 표시용 문자열 행으로 정규화한다. */
|
||||
const validationRows = useMemo(() => (validation?.errors ?? []).map((error) => ({
|
||||
...error,
|
||||
rowNumberText: error.rowNumber == null ? "-" : String(error.rowNumber),
|
||||
sheetName: error.sheetName || error.trtdstNm || error.trtdstId || "-",
|
||||
gisManagementNo: error.gisManagementNo || "-",
|
||||
fieldPath: error.fieldPath || "-",
|
||||
})), [validation]);
|
||||
|
||||
/** 팝업 진입 시 선택 사업의 처리분구·첨부 상세를 읽는다. */
|
||||
useEffect(() => {
|
||||
if (!exmnBizId) {
|
||||
@@ -188,8 +218,25 @@ export function ExmnResultRegisterPopupPage({
|
||||
|
||||
/** 파일 변경 시 이전 검토결과를 폐기하고 확장자·크기 규칙을 즉시 확인한다. */
|
||||
const handleFileChange = (fileType: ExmnFileType, fileList: FileList | null) => {
|
||||
setValidation(null);
|
||||
setUploadStatus("");
|
||||
const invalidatedTypes: ExmnFileType[] = fileType === "EXMN_EXCEL"
|
||||
? ["EXMN_EXCEL", "PPL_EXMN_PDF", "MNHL_EXMN_PDF"]
|
||||
: [fileType];
|
||||
setFileValidationStatuses((current) => {
|
||||
const next = { ...current };
|
||||
invalidatedTypes.forEach((type) => delete next[type]);
|
||||
return next;
|
||||
});
|
||||
setValidationResults((current) => {
|
||||
const next = { ...current };
|
||||
invalidatedTypes.forEach((type) => delete next[type]);
|
||||
return next;
|
||||
});
|
||||
setFileValidationMessages((current) => {
|
||||
const next = { ...current };
|
||||
invalidatedTypes.forEach((type) => delete next[type]);
|
||||
return next;
|
||||
});
|
||||
setFiles((current) => {
|
||||
const next = { ...current };
|
||||
delete next[fileType];
|
||||
@@ -206,50 +253,8 @@ export function ExmnResultRegisterPopupPage({
|
||||
setNotice(null);
|
||||
};
|
||||
|
||||
/** 실제 모드는 서버 검토를 호출하고 샘플 모드는 명시적 정상 검토 결과만 화면에 제공한다. */
|
||||
const handleValidate = async () => {
|
||||
if (sampleMode) {
|
||||
setValidation(EXMN_SAMPLE_IMPORT_VALIDATION);
|
||||
setNotice({
|
||||
type: "success",
|
||||
text: `샘플 데이터 검토 완료: PPL ${EXMN_SAMPLE_IMPORT_VALIDATION.summary.pplCount}건, MNHL ${EXMN_SAMPLE_IMPORT_VALIDATION.summary.mnhlCount}건`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const fileMap = Object.fromEntries(
|
||||
Object.entries(files).map(([type, selected]) => [type, selected?.file]),
|
||||
) as Partial<Record<ExmnFileType, File>>;
|
||||
const validationMessage = validateExmnFiles(fileMap);
|
||||
if (validationMessage) {
|
||||
setNotice({ type: "error", text: validationMessage });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const result = await validateExmnImport(exmnBizId, buildImportFormData(files));
|
||||
setValidation(result);
|
||||
setNotice({
|
||||
type: result.valid ? "success" : "error",
|
||||
text: result.valid
|
||||
? `검토가 완료되었습니다. PPL ${result.summary.pplCount}건, MNHL ${result.summary.mnhlCount}건`
|
||||
: `검토 오류 ${result.errors.length}건을 확인해주세요.`,
|
||||
});
|
||||
} catch (error) {
|
||||
setValidation(null);
|
||||
setNotice({
|
||||
type: "error",
|
||||
text: error instanceof Error ? error.message : "조사파일 검토 중 오류가 발생했습니다.",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 모든 파일을 첨부서비스에 올리고 발급 키를 조사사업에 연결한다. */
|
||||
const uploadAndLinkAttachments = async (): Promise<Partial<Record<ExmnFileType, ExmnAttachmentKey>>> => {
|
||||
/** 선택된 파일을 첨부서비스에 올린 뒤 조사사업정보의 파일 구분별 컬럼에 키를 연결한다. */
|
||||
const uploadAndLinkAttachments = async () => {
|
||||
const attachments: Partial<Record<ExmnFileType, ExmnAttachmentKey>> = {};
|
||||
for (const fileType of FILE_TYPES) {
|
||||
const selected = files[fileType];
|
||||
@@ -259,13 +264,113 @@ export function ExmnResultRegisterPopupPage({
|
||||
setUploadStatus(`${label} 업로드 ${percentage}%`);
|
||||
});
|
||||
}
|
||||
await updateExmnAttachments(exmnBizId, attachments);
|
||||
return attachments;
|
||||
if (Object.keys(attachments).length > 0) {
|
||||
await updateExmnAttachments(exmnBizId, attachments);
|
||||
}
|
||||
};
|
||||
|
||||
/** 정상 검토된 파일을 저장하고 부모 화면에 완료 메시지를 보낸 뒤 팝업을 닫는다. */
|
||||
/** 실제 모드는 서버 검토를 호출하고 샘플 모드는 명시적 정상 검토 결과만 화면에 제공한다. */
|
||||
const handleValidate = async (fileType: ExmnFileType) => {
|
||||
if (!files[fileType]) {
|
||||
setNotice({
|
||||
type: "error",
|
||||
text: `${EXMN_FILE_RULES[fileType].label} 파일을 먼저 선택해주세요.`,
|
||||
});
|
||||
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>>;
|
||||
const validationMessage = validateExmnFiles(fileMap);
|
||||
if (validationMessage) {
|
||||
setFileValidationStatuses((current) => ({ ...current, [fileType]: "error" }));
|
||||
setValidationResults((current) => {
|
||||
const next = { ...current };
|
||||
delete next[fileType];
|
||||
return next;
|
||||
});
|
||||
setFileValidationMessages((current) => ({
|
||||
...current,
|
||||
[fileType]: [validationMessage],
|
||||
}));
|
||||
setNotice({ type: "error", text: validationMessage });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const result = await validateExmnImport(
|
||||
exmnBizId,
|
||||
buildImportFormData(files, null, fileType),
|
||||
);
|
||||
setValidationResults((current) => ({ ...current, [fileType]: result }));
|
||||
setFileValidationStatuses((current) => ({
|
||||
...current,
|
||||
[fileType]: result.valid ? "success" : "error",
|
||||
}));
|
||||
setFileValidationMessages((current) => ({
|
||||
...current,
|
||||
[fileType]: result.errors.map((item) => item.message),
|
||||
}));
|
||||
const visibleErrors = result.errors.slice(0, 5).map((item) => item.message);
|
||||
if (result.errors.length > visibleErrors.length) {
|
||||
visibleErrors.push(`외 ${result.errors.length - visibleErrors.length}건`);
|
||||
}
|
||||
setNotice(result.valid
|
||||
? null
|
||||
: {
|
||||
type: "error",
|
||||
text: `검토 오류 ${result.errors.length}건\n${visibleErrors.join("\n")}`,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "조사파일 검토 중 오류가 발생했습니다.";
|
||||
setValidationResults((current) => {
|
||||
const next = { ...current };
|
||||
delete next[fileType];
|
||||
return next;
|
||||
});
|
||||
setFileValidationStatuses((current) => ({ ...current, [fileType]: "error" }));
|
||||
setFileValidationMessages((current) => ({
|
||||
...current,
|
||||
[fileType]: [message],
|
||||
}));
|
||||
setNotice({
|
||||
type: "error",
|
||||
text: message,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 정상 검토된 조사결과 Excel을 저장하고 부모 화면에 완료 메시지를 보낸 뒤 팝업을 닫는다. */
|
||||
const handleCommit = async () => {
|
||||
if (!validation?.valid) return;
|
||||
const excelValidation = validationResults.EXMN_EXCEL;
|
||||
if (
|
||||
!excelValidation?.valid
|
||||
|| fileValidationStatuses.EXMN_EXCEL !== "success"
|
||||
|| fileValidationStatuses.PPL_EXMN_PDF !== "success"
|
||||
) return;
|
||||
if (sampleMode) {
|
||||
postPopupMessageAndClose(WINDOW_POPUP_MESSAGE_TYPE.EXMN_RESULT_COMMITTED, {
|
||||
exmnBizId,
|
||||
@@ -277,8 +382,8 @@ export function ExmnResultRegisterPopupPage({
|
||||
setIsLoading(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const attachments = await uploadAndLinkAttachments();
|
||||
await commitExmnImport(exmnBizId, buildImportFormData(files, validation, attachments));
|
||||
await uploadAndLinkAttachments();
|
||||
await commitExmnImport(exmnBizId, buildImportFormData(files, excelValidation));
|
||||
postPopupMessageAndClose(WINDOW_POPUP_MESSAGE_TYPE.EXMN_RESULT_COMMITTED, {
|
||||
exmnBizId,
|
||||
sample: "N",
|
||||
@@ -293,6 +398,12 @@ export function ExmnResultRegisterPopupPage({
|
||||
}
|
||||
};
|
||||
|
||||
/** 검토를 한 파일만 입력 순서대로 하단 검토 내역에 표시한다. */
|
||||
const visibleValidationTypes = VALIDATION_FILE_TYPES.filter(
|
||||
(fileType) => fileValidationStatuses[fileType],
|
||||
);
|
||||
const excelValidation = validationResults.EXMN_EXCEL;
|
||||
|
||||
return (
|
||||
<main className="sn-popup-page">
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
@@ -304,7 +415,12 @@ export function ExmnResultRegisterPopupPage({
|
||||
color="white"
|
||||
size="xs"
|
||||
className="sn-popup-header-button"
|
||||
disabled={isLoading || !validation?.valid}
|
||||
disabled={
|
||||
isLoading
|
||||
|| !excelValidation?.valid
|
||||
|| fileValidationStatuses.EXMN_EXCEL !== "success"
|
||||
|| fileValidationStatuses.PPL_EXMN_PDF !== "success"
|
||||
}
|
||||
onClick={() => void handleCommit()}
|
||||
>
|
||||
저장
|
||||
@@ -313,8 +429,8 @@ export function ExmnResultRegisterPopupPage({
|
||||
</header>
|
||||
|
||||
<section className="flex min-h-0 flex-1 flex-col gap-2 overflow-x-hidden overflow-y-auto px-4 pb-3 pt-3">
|
||||
{notice && (
|
||||
<div role="alert" className={`rounded border px-4 py-3 text-sm ${
|
||||
{notice && notice.type !== "success" && (
|
||||
<div role="alert" className={`whitespace-pre-line rounded border px-4 py-3 text-sm ${
|
||||
notice.type === "error"
|
||||
? "border-red-200 bg-red-50 text-red-700"
|
||||
: notice.type === "success"
|
||||
@@ -371,6 +487,7 @@ export function ExmnResultRegisterPopupPage({
|
||||
{FILE_TYPES.map((fileType) => {
|
||||
const rule = EXMN_FILE_RULES[fileType];
|
||||
const selected = files[fileType];
|
||||
const validationStatus = fileValidationStatuses[fileType];
|
||||
const inputId = `exmn-popup-file-${fileType}`;
|
||||
const canValidate = VALIDATION_BUTTON_FILE_TYPES.has(fileType);
|
||||
return (
|
||||
@@ -380,17 +497,29 @@ export function ExmnResultRegisterPopupPage({
|
||||
style={{ gridTemplateColumns: "180px minmax(0, 1fr) 80px 112px" }}
|
||||
>
|
||||
<label htmlFor={inputId} className="font-medium text-gray-700">
|
||||
{(fileType === "PPL_EXMN_EXCEL" || fileType === "MNHL_EXMN_EXCEL") && <span className="text-red-500">* </span>}
|
||||
{(fileType === "PPL_EXMN_PDF" || fileType === "MNHL_EXMN_PDF") && <span className="text-red-500">* </span>}
|
||||
{rule.label}
|
||||
</label>
|
||||
<div
|
||||
className={`truncate border px-3 py-1.5 ${selected
|
||||
? "border-blue-300 bg-blue-50 font-semibold text-blue-700"
|
||||
: "border-[#9fb2c4] bg-white text-gray-600"
|
||||
className={`flex min-w-0 items-center gap-2 border px-3 py-1.5 ${
|
||||
validationStatus === "success"
|
||||
? "border-emerald-300 bg-emerald-50 font-semibold text-emerald-700"
|
||||
: validationStatus === "error"
|
||||
? "border-red-300 bg-red-50 font-semibold text-red-700"
|
||||
: selected
|
||||
? "border-blue-300 bg-blue-50 font-semibold text-blue-700"
|
||||
: "border-[#9fb2c4] bg-white text-gray-600"
|
||||
}`}
|
||||
title={selected?.file.name}
|
||||
>
|
||||
{selected?.file.name || `${rule.extensions.map((extension) => `.${extension}`).join(", ")} 파일`}
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{selected?.file.name || `${rule.extensions.map((extension) => `.${extension}`).join(", ")} 파일`}
|
||||
</span>
|
||||
{validationStatus && (
|
||||
<span className="shrink-0 text-xs">
|
||||
{validationStatus === "success" ? "검토 완료" : "검토 실패"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<input
|
||||
@@ -405,7 +534,7 @@ export function ExmnResultRegisterPopupPage({
|
||||
</div>
|
||||
<div className="text-center">
|
||||
{canValidate ? (
|
||||
<Button className={ACTION_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={() => void handleValidate()}>데이터 검토</Button>
|
||||
<Button className={ACTION_BUTTON_CLASS} size="sm" disabled={isLoading} onClick={() => void handleValidate(fileType)}>데이터 검토</Button>
|
||||
) : <span className="text-gray-400">-</span>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -413,34 +542,64 @@ export function ExmnResultRegisterPopupPage({
|
||||
})}
|
||||
</section>
|
||||
|
||||
{validation && (
|
||||
<section className="border border-border p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-gray-700">데이터 검토 결과</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">PPL {validation.summary.pplCount}건 · MNHL {validation.summary.mnhlCount}건</p>
|
||||
</div>
|
||||
<span style={{ maxWidth: "50%" }} className="truncate text-xs text-gray-500">검토 해시: {validation.fileHash || "-"}</span>
|
||||
{visibleValidationTypes.length > 0 && (
|
||||
<section className="overflow-hidden border border-border" aria-label="데이터 검토 내역">
|
||||
<div className="flex items-center justify-between bg-[#eaf3fb] px-3 py-2.5">
|
||||
<h2 className="text-sm font-semibold text-title">데이터 검토 내역</h2>
|
||||
<span className="text-xs text-label">파일별로 실제 확인한 항목입니다.</span>
|
||||
</div>
|
||||
{validationRows.length > 0 ? (
|
||||
<UiTable
|
||||
headName=""
|
||||
headlist={ERROR_TABLE_HEAD}
|
||||
tableData={validationRows}
|
||||
total={validationRows.length}
|
||||
pageSize={Math.max(validationRows.length, 1)}
|
||||
curPage={1}
|
||||
widthFixed
|
||||
cellClick={undefined}
|
||||
onPageChange={() => undefined}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded border border-emerald-200 bg-emerald-50 px-4 py-5 text-center text-sm text-emerald-700">데이터 검토 오류가 없습니다.</div>
|
||||
)}
|
||||
{visibleValidationTypes.map((fileType) => {
|
||||
const status = fileValidationStatuses[fileType]!;
|
||||
const result = validationResults[fileType];
|
||||
const messages = fileValidationMessages[fileType] ?? [];
|
||||
const guide = VALIDATION_CHECK_GUIDES[fileType];
|
||||
const countText = fileType === "EXMN_EXCEL"
|
||||
? `관로 ${result?.summary.pplCount ?? 0}건 · 맨홀 ${result?.summary.mnhlCount ?? 0}건`
|
||||
: fileType === "PPL_EXMN_PDF"
|
||||
? `Excel 관로 ${result?.summary.pplCount ?? 0}건 기준`
|
||||
: "";
|
||||
return (
|
||||
<div
|
||||
key={fileType}
|
||||
className="grid items-start gap-3 border-t border-border px-3 py-2.5 text-sm"
|
||||
style={{ gridTemplateColumns: "180px 88px minmax(0, 1fr)" }}
|
||||
>
|
||||
<span className="font-medium text-gray-700">
|
||||
{EXMN_FILE_RULES[fileType].label}
|
||||
</span>
|
||||
<span className={`inline-flex w-fit rounded-full px-2.5 py-1 text-xs font-semibold ${
|
||||
status === "success"
|
||||
? "bg-emerald-100 text-emerald-700"
|
||||
: "bg-red-100 text-red-700"
|
||||
}`}>
|
||||
{status === "success" ? "검토 완료" : "검토 실패"}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
{status === "success" && countText && (
|
||||
<p className="mb-1 font-medium text-foreground">{countText}</p>
|
||||
)}
|
||||
<p className="leading-5 text-gray-600">
|
||||
{guide?.checkedItems.join(" · ")}
|
||||
</p>
|
||||
{guide?.pendingNote && (
|
||||
<p className="mt-1 text-amber-700">{guide.pendingNote}</p>
|
||||
)}
|
||||
{status === "error" && messages.length > 0 && (
|
||||
<p className="mt-1 truncate text-red-700" title={messages.join("\n")}>
|
||||
{messages[0]}
|
||||
{messages.length > 1 ? ` 외 ${messages.length - 1}건` : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{uploadStatus && <p className="text-right text-sm text-blue-700">{uploadStatus}</p>}
|
||||
{uploadStatus && (
|
||||
<p className="text-right text-sm text-blue-700">{uploadStatus}</p>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -10,8 +10,8 @@ export const windowPopupDefinitions = {
|
||||
exmnResultRegister: {
|
||||
key: "exmnResultRegister",
|
||||
title: "조사 결과 업로드",
|
||||
width: 980,
|
||||
height: 340,
|
||||
width: 1000,
|
||||
height: 500,
|
||||
requiredParams: ["exmnBizId"],
|
||||
},
|
||||
exmnSurveyDetail: {
|
||||
|
||||
Reference in New Issue
Block a user