조사결과 검토·저장 및 사업보고서 목록 표시 개선

This commit is contained in:
kiminchan
2026-07-29 13:22:59 +09:00
parent 62c41297c4
commit f52f22e80f
9 changed files with 501 additions and 149 deletions
@@ -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 기준시설과 조사결과 시설번호 대조 및 지도보기는 후속 범위다.
@@ -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: {