diff --git a/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md b/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md index 7639f9bfc..53e91ea6e 100644 --- a/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md +++ b/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md @@ -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 기준시설과 조사결과 시설번호 대조 및 지도보기는 후속 범위다. diff --git a/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md b/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md index 1cea607a5..aa520947b 100644 --- a/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md +++ b/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md @@ -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`로 전체 이름을 확인할 수 있다. diff --git a/apps/sn/src/features/exmn/api/exmn-business-api.ts b/apps/sn/src/features/exmn/api/exmn-business-api.ts index cdb798525..b03e6a65f 100644 --- a/apps/sn/src/features/exmn/api/exmn-business-api.ts +++ b/apps/sn/src/features/exmn/api/exmn-business-api.ts @@ -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 = (response: T | { data?: T }): T => typeof response === "object" && response !== null && "data" in response @@ -41,6 +48,40 @@ const toPage = (value: Partial> | null | undefined): ExmnPage totalPages: Number(value?.totalPages ?? 0), }); +/** + * 사업보고서 첨부 코드로 원본 파일명을 조회한다. + * 첨부 메타데이터 장애가 조사사업 목록 전체를 막지 않도록 실패 시 null로 대체한다. + */ +const getBusinessReportFileName = async ( + attachmentCode: string, + attachmentId: string, +): Promise => { + 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(await getData(`${EXMN_BUSINESS_API_PATH}?${query}`)); + const page = toPage( + 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 }; }; /** 선택 사업의 처리분구별 관로·맨홀 조사 상세현황을 조회한다. */ diff --git a/apps/sn/src/features/exmn/lib/exmn-business-validation.ts b/apps/sn/src/features/exmn/lib/exmn-business-validation.ts index 271a6afdc..442d5485c 100644 --- a/apps/sn/src/features/exmn/lib/exmn-business-validation.ts +++ b/apps/sn/src/features/exmn/lib/exmn-business-validation.ts @@ -8,9 +8,9 @@ export const EXMN_MAX_FILE_SIZE = 200 * 1024 * 1024; /** 조사사업 파일별 화면 표시명과 허용 확장자다. */ export const EXMN_FILE_RULES: Record = { - 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>, ): 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); diff --git a/apps/sn/src/features/exmn/model/exmn-business-types.ts b/apps/sn/src/features/exmn/model/exmn-business-types.ts index aac0abfb9..3047ef80a 100644 --- a/apps/sn/src/features/exmn/model/exmn-business-types.ts +++ b/apps/sn/src/features/exmn/model/exmn-business-types.ts @@ -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"; diff --git a/apps/sn/src/features/exmn/model/exmn-sample-data.ts b/apps/sn/src/features/exmn/model/exmn-sample-data.ts index 837f240a4..ecb90d998 100644 --- a/apps/sn/src/features/exmn/model/exmn-sample-data.ts +++ b/apps/sn/src/features/exmn/model/exmn-sample-data.ts @@ -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", diff --git a/apps/sn/src/features/exmn/ui/ExmnRegistrationPage.tsx b/apps/sn/src/features/exmn/ui/ExmnRegistrationPage.tsx index b32130073..57f9c4ab8 100644 --- a/apps/sn/src/features/exmn/ui/ExmnRegistrationPage.tsx +++ b/apps/sn/src/features/exmn/ui/ExmnRegistrationPage.tsx @@ -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 ( handleBusinessSort(head)} > - {head.title} + + {head.title} + {direction === "asc" ? ( ); diff --git a/apps/sn/src/features/exmn/ui/ExmnResultRegisterPopupPage.tsx b/apps/sn/src/features/exmn/ui/ExmnResultRegisterPopupPage.tsx index 09beb9f67..ee33ac8f1 100644 --- a/apps/sn/src/features/exmn/ui/ExmnResultRegisterPopupPage.tsx +++ b/apps/sn/src/features/exmn/ui/ExmnResultRegisterPopupPage.tsx @@ -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([ +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(VALIDATION_FILE_TYPES); + +/** 파일별로 실제 수행하는 검토 항목과 아직 적용하지 않은 범위를 사용자 문구로 정의한다. */ +const VALIDATION_CHECK_GUIDES: Partial> = { + 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>; type Notice = { type: "success" | "error" | "info"; text: string } | null; +type FileValidationStatus = "success" | "error"; +type FileValidationResults = Partial>; +type FileValidationMessages = Partial>; interface ExmnResultRegisterPopupPageProps { exmnBizId: string; @@ -75,17 +98,18 @@ interface ExmnResultRegisterPopupPageProps { const buildImportFormData = ( files: SelectedFiles, validation?: ExmnImportValidationResult | null, - attachments?: Partial>, + 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(null); const [files, setFiles] = useState({}); - const [validation, setValidation] = useState(null); - const [isLoading, setIsLoading] = useState(false); + /** 파일별 서버 검토 결과를 보관해 다른 파일 검토 후에도 결과와 건수를 유지한다. */ + const [validationResults, setValidationResults] = useState({}); + const [fileValidationStatuses, setFileValidationStatuses] = useState< + Partial> + >({}); + /** 서버 호출 전 오류와 예외 메시지도 해당 파일의 검토 내역에 남긴다. */ + const [fileValidationMessages, setFileValidationMessages] = + useState({}); const [uploadStatus, setUploadStatus] = useState(""); + const [isLoading, setIsLoading] = useState(false); const [notice, setNotice] = useState(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>; - 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>> => { + /** 선택된 파일을 첨부서비스에 올린 뒤 조사사업정보의 파일 구분별 컬럼에 키를 연결한다. */ + const uploadAndLinkAttachments = async () => { const attachments: Partial> = {}; 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>; + 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 (
@@ -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({
- {notice && ( -
{ 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" }} >
- {selected?.file.name || `${rule.extensions.map((extension) => `.${extension}`).join(", ")} 파일`} + + {selected?.file.name || `${rule.extensions.map((extension) => `.${extension}`).join(", ")} 파일`} + + {validationStatus && ( + + {validationStatus === "success" ? "검토 완료" : "검토 실패"} + + )}
{canValidate ? ( - + ) : -}
@@ -413,34 +542,64 @@ export function ExmnResultRegisterPopupPage({ })}
- {validation && ( -
-
-
-

데이터 검토 결과

-

PPL {validation.summary.pplCount}건 · MNHL {validation.summary.mnhlCount}건

-
- 검토 해시: {validation.fileHash || "-"} + {visibleValidationTypes.length > 0 && ( +
+
+

데이터 검토 내역

+ 파일별로 실제 확인한 항목입니다.
- {validationRows.length > 0 ? ( - undefined} - /> - ) : ( -
데이터 검토 오류가 없습니다.
- )} + {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 ( +
+ + {EXMN_FILE_RULES[fileType].label} + + + {status === "success" ? "검토 완료" : "검토 실패"} + +
+ {status === "success" && countText && ( +

{countText}

+ )} +

+ {guide?.checkedItems.join(" · ")} +

+ {guide?.pendingNote && ( +

{guide.pendingNote}

+ )} + {status === "error" && messages.length > 0 && ( +

+ {messages[0]} + {messages.length > 1 ? ` 외 ${messages.length - 1}건` : ""} +

+ )} +
+
+ ); + })}
)} - {uploadStatus &&

{uploadStatus}

} + {uploadStatus && ( +

{uploadStatus}

+ )}
); diff --git a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts index 132bc8285..7902af24b 100644 --- a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts +++ b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts @@ -10,8 +10,8 @@ export const windowPopupDefinitions = { exmnResultRegister: { key: "exmnResultRegister", title: "조사 결과 업로드", - width: 980, - height: 340, + width: 1000, + height: 500, requiredParams: ["exmnBizId"], }, exmnSurveyDetail: {