diff --git a/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/001-qa-SFR-010-004~007-조사사업-통합검증계획.md b/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/001-qa-SFR-010-004~007-조사사업-통합검증계획.md index 2348e90df..c021a89e1 100644 --- a/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/001-qa-SFR-010-004~007-조사사업-통합검증계획.md +++ b/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/001-qa-SFR-010-004~007-조사사업-통합검증계획.md @@ -180,3 +180,22 @@ - 브라우저 수동 검증은 로그인된 `SN` 세션과 `/oper/exmn/register?sample=Y` 진입이 선행조건이다. - 개발 서버가 실행 중이면 별도 프로덕션 빌드를 실행하지 않고 정적 검사와 사용자 화면 확인을 우선한다. + +## 14. 관로 본단위구간과 결함별 상세 팝업 회귀 계획 + +| 검증 항목 | 방법 | 기대 결과 | +|---|---|---| +| 관로 전체 상세보기 | `PPL`, `defectType=ALL` 진입 | 처리분구·관리번호와 `관로`·`본단위구간` 전환 표시 | +| 맨홀 상세보기 | `MNHL` 상세보기 진입 | 관로 전용 모드 전환 미표시 | +| 본단위구간 초기값 | 관리번호 없이 `본단위구간` 전환 | 선택 처리분구의 전체 관리번호 단위구간 표시 | +| 관리번호 검색 | 일부 관리번호 입력 후 조회 | 부분 일치 관로번호의 단위구간만 표시 | +| 단위구간 표 구조 | 한 관로의 여러 상세행 확인 | 관로·단위구간 공통 셀 병합과 14개 열 유지 | +| 구조적 링크 | 구조적결함 건수 클릭 | 조회 조건 없이 구조적결함 집계표 즉시 표시 | +| 운영적 링크 | 운영적결함 건수 클릭 | 조회 조건 없이 운영적결함 집계표 즉시 표시 | +| 결함별 요약 행 | 집계표 제목 행 확인 | 합계 정보와 다운로드 버튼이 오른쪽에 함께 표시 | +| 전체 상세보기 회귀 | 상단 상세보기 버튼 진입 | 기존 조회 조건·모드·전체 이상항목 표 유지 | +| 엑셀 다운로드 | 전체·분류별·본단위구간 각각 다운로드 | 현재 표시 행·열, 소계·합계와 병합 구조 반영 | +| 정적 검사 | 변경 저장소에서 `git diff --check` | 공백 오류 없음 | + +- 실제 다운로드 파일은 Excel에서 열어 헤더, 병합 범위, 소계·합계와 한글 파일명을 대조한다. +- 실제 API 연결 후에는 샘플과 동일한 조건으로 JSONB 코드·점수·단위구간 매핑을 재검증한다. 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 94b41df03..68e6cf9dd 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 @@ -206,3 +206,21 @@ ## 14. 흡수한 기존 문서 - 없음 + +## 15. 관로 본단위구간과 결함별 상세 팝업 검증 결과 + +### 15.1 확인 결과 + +- 관로 전체 상세보기에서 `관로`·`본단위구간` 전환 UI가 표시되고, 맨홀 상세보기에서는 표시되지 않는 분기를 소스로 확인했다. +- 본단위구간 초기 진입 시 3개 샘플 관로의 33개 상세 레코드를 표시하고 관리번호 입력 시 부분 일치로 거르는 동작을 확인했다. +- 구조적·운영적 결함 건수 링크 팝업에서 조회 조건 영역이 제거되고 제목, 조회 건수, 합계 정보와 다운로드가 한 행에 표시되는 화면을 사용자가 확인해 완료 판정했다. +- `defectType=ALL`과 `defectType=STRUCTURAL` 팝업 경로가 로컬 개발 서버에서 각각 HTTP 200으로 응답하는 것을 확인했다. +- 다운로드 구현이 현재 정렬·필터 결과와 표시 열을 전달하고 전체·분류별 표의 소계·합계 및 본단위구간 14개 열을 생성하는 것을 소스로 확인했다. +- 변경 파일에 대한 `git diff --check`를 통과했다. + +### 15.2 미수행·후속 검증 + +- 생성된 실제 `.xlsx` 파일을 Excel에서 열어 병합 범위와 소계·합계 값을 대조하는 검증은 아직 수행하지 않았다. +- 로컬 TypeScript compiler는 기존 샌드박스 `EPERM` 이력에 따라 별도 실행하지 않았다. +- 실제 서버 JSONB 데이터 연결과 코드·점수·단위구간 매핑 검증은 백엔드 연동 단계의 후속 항목이다. +- 지도보기는 보류 범위로 이번 검증 대상에 포함하지 않았다. 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 257b14e92..6d6c26171 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 @@ -206,3 +206,21 @@ - 수정 대상은 `src/features/exmn/**`와 `src/features/windowPopup/**` 안으로 제한한다. - 백엔드 API·DTO·DB는 수정하지 않는다. - 완료 게이트는 관로·맨홀 각각 `ALL|STRUCTURAL|OPERATIONAL` 화면, 코드 헤더, 필터 결과와 소계·합계가 일치하는 것이다. + +## 17. 관로 본단위구간과 결함별 상세 팝업 보완 범위 + +| 작업 단위 | 확정 내용 | +|---|---| +| 관로 전체 상세보기 | `관로`·`본단위구간` 소형 슬라이드형 모드 전환 | +| 본단위구간 초기 조회 | 관리번호가 없으면 선택 처리분구의 전체 관리번호 표시 | +| 본단위구간 검색 | 관리번호 입력 후 부분 일치 조회 | +| 본단위구간 표 | 엑셀 `본단위등급표` 기준 14개 열과 단위구간 병합 표시 | +| 맨홀 상세보기 | 관로 전용 모드 선택 미표시 | +| 결함별 상세보기 | 조회 조건·모드 영역 제거 후 선택 분류 결과 즉시 표시 | +| 결함별 요약 행 | 제목·조회 건수와 연장/주행 합계·다운로드를 한 행에 배치 | +| 엑셀 다운로드 | 현재 화면의 표시 행·열, 소계·합계 및 병합 구조 반영 | + +- 수정 범위는 `src/features/exmn/model/**`와 `src/features/exmn/ui/**`로 제한한다. +- 공통 비즈니스 컴포넌트, API, DTO, DB는 변경하지 않는다. +- 프론트 완료 게이트는 전체 상세보기 회귀, 본단위구간 전체/검색 조회, 구조·운영 결함별 간소화 화면과 현재 화면 기준 엑셀 생성이다. +- 실제 API·JSONB 매핑과 다운로드 파일의 업무 데이터 대조는 백엔드 연동 단계에서 수행한다. 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 9ca267fe6..a7a293f0a 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 @@ -353,3 +353,38 @@ - `src/features/windowPopup/model/popupDefinitions.ts` - `src/features/windowPopup/ui/ExmnSurveyDetailPopupContent.tsx` - 백엔드 코드·API·DTO·DB는 변경하지 않았다. 후속 API는 `jobType`, 처리분구 식별자, `defectType`과 JSONB 코드별 건수 계약을 사용해야 한다. + +## 13. 2026-07-27 관로 본단위구간과 결함별 상세 팝업 구현 + +### 13.1 관로 전체 상세보기 모드 + +- 관로조사 전체 상세보기에서 `관로`와 `본단위구간`을 전환하는 소형 슬라이드형 선택 UI를 추가했다. +- `본단위구간`은 관로번호, 단위구간, 단위길이, 결함위치, 결함 종류·코드·등급·발생위치·침입수가점·연속결함, 구조·운영 점수, 조사중단사유, 비고를 표시한다. +- 관로번호와 단위구간 공통 정보는 `rowSpan`으로 병합해 엑셀 `본단위등급표`와 같은 읽기 흐름을 구성했다. +- 관리번호가 비어 있으면 선택한 처리분구의 전체 관리번호를 표시하고, 입력 후 조회하면 부분 일치하는 관로번호만 표시한다. +- 맨홀조사에는 관로 전용 모드 UI가 나타나지 않도록 분기했다. +- 샘플 데이터는 3개 관로에 각 2개 단위구간, 총 33개 상세 레코드를 구성해 초기 전체 조회와 관리번호 검색을 확인할 수 있게 했다. + +### 13.2 구조적·운영적 결함별 팝업 간소화 + +- `defectType=STRUCTURAL|OPERATIONAL`로 진입하면 처리분구·관리번호, 모드, 조회 버튼과 상단 다운로드 영역을 숨기도록 변경했다. +- 선택한 처리분구·조사 유형·결함 분류가 적용된 집계표를 팝업 진입 즉시 표시한다. +- 집계표 제목 행 오른쪽으로 연장거리 합계, 주행거리 합계와 녹색 다운로드 버튼을 이동했다. +- `defectType=ALL`은 기존 조회 조건과 관로 모드를 유지해 상단 상세보기 회귀를 방지했다. + +### 13.3 엑셀 생성 + +- 기존 공통 `downloadExcelWithKoreanHeaders`를 사용해 현재 화면의 정렬·필터 결과와 표시 열을 다운로드하도록 구현했다. +- 관로·맨홀의 전체·구조·운영 표에는 현재 표시 열을 기준으로 소계·합계 행을 추가한다. +- 본단위구간 표는 화면과 동일한 14개 열로 생성한다. +- 소계·합계 고정 영역과 이상항목 대·중·소 묶음에 화면 구조에 맞는 셀 병합을 적용했다. +- 열 수가 `Z`를 넘는 다단 표도 처리하도록 마지막 열 주소를 동적으로 계산한다. + +### 13.4 수정 파일과 연동 경계 + +- `src/features/exmn/model/exmn-detail-table.ts` +- `src/features/exmn/model/exmn-sample-data.ts` +- `src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx` +- `src/features/exmn/ui/ExmnPplUnitDetailTable.tsx` +- 백엔드 코드·API·DTO·DB와 공통 비즈니스 컴포넌트는 변경하지 않았다. +- 후속 실제 연동에서는 기존 `PPL_UNIT_V1`, `MNHL_COUNT_V1` JSONB 값을 현재 화면 모델과 코드 기준으로 매핑한다. diff --git a/apps/sn/src/features/exmn/model/exmn-detail-table.ts b/apps/sn/src/features/exmn/model/exmn-detail-table.ts index 681000074..edbd79268 100644 --- a/apps/sn/src/features/exmn/model/exmn-detail-table.ts +++ b/apps/sn/src/features/exmn/model/exmn-detail-table.ts @@ -30,6 +30,29 @@ export interface ExmnSurveyDetailRow { defectCounts: Record; } +/** 관로 상세보기에서 전체 관로 집계와 본단위구간 등급표를 전환하는 표시 모드다. */ +export type ExmnPplDetailMode = "PIPE" | "UNIT"; + +/** `본단위등급표` 한 행에 표시하는 관로 단위구간의 상태·결함 기록이다. */ +export interface ExmnPplUnitDetailRow { + pipeNo: string; + unitSeq: number; + unitLengthM: number; + defectPositionM: number; + defectName: string; + code: string; + recordType: "STATUS" | "DEFECT"; + defectType: ExmnDefectGroup | null; + severity: ExmnDetailSeverity | null; + occurrencePosition: string; + infiltrationBonus: number; + continuous: boolean | null; + structuralScore: number | null; + operationalScore: number | null; + surveyStopReason: string | null; + remark: string | null; +} + /** 선택 조사유형에 따라 팝업 제목과 이상항목 설정을 함께 반환하는 화면 계약이다. */ export interface ExmnDetailTableDefinition { jobType: ExmnJobType; 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 a10a115a6..837f240a4 100644 --- a/apps/sn/src/features/exmn/model/exmn-sample-data.ts +++ b/apps/sn/src/features/exmn/model/exmn-sample-data.ts @@ -8,6 +8,7 @@ import type { import { EXMN_DETAIL_SEVERITIES, EXMN_DETAIL_TABLE_DEFINITIONS, + type ExmnPplUnitDetailRow, type ExmnSeverityCounts, type ExmnSurveyDetailRow, } from "./exmn-detail-table"; @@ -243,3 +244,217 @@ export const getExmnSampleSurveyDetails = ( defectCounts, }; }); + +/** 엑셀 `본단위등급표`의 한 관로를 단위구간별 병합 표로 확인하는 샘플 원형이다. */ +const EXMN_SAMPLE_PPL_UNIT_DETAIL_TEMPLATE: ExmnPplUnitDetailRow[] = [ + { + pipeNo: "118-0026", + unitSeq: 1, + unitLengthM: 6, + defectPositionM: 0, + defectName: "(상태)조사시작(순주행)", + code: "IS", + recordType: "STATUS", + defectType: null, + severity: null, + occurrencePosition: "0/0", + infiltrationBonus: 0, + continuous: null, + structuralScore: null, + operationalScore: null, + surveyStopReason: null, + remark: null, + }, + { + pipeNo: "118-0026", + unitSeq: 1, + unitLengthM: 6, + defectPositionM: 5.4, + defectName: "(관로)변형", + code: "DF", + recordType: "DEFECT", + defectType: "STRUCTURAL", + severity: "S", + occurrencePosition: "10/2", + infiltrationBonus: 0, + continuous: false, + structuralScore: 25, + operationalScore: null, + surveyStopReason: null, + remark: null, + }, + { + pipeNo: "118-0026", + unitSeq: 1, + unitLengthM: 6, + defectPositionM: 6, + defectName: "(관로)이음부이탈", + code: "JS", + recordType: "DEFECT", + defectType: "STRUCTURAL", + severity: "L", + occurrencePosition: "4/6", + infiltrationBonus: 0, + continuous: null, + structuralScore: 35, + operationalScore: null, + surveyStopReason: null, + remark: null, + }, + { + pipeNo: "118-0026", + unitSeq: 1, + unitLengthM: 6, + defectPositionM: 6, + defectName: "(상태)이음부(접합부)존재", + code: "JE", + recordType: "STATUS", + defectType: null, + severity: null, + occurrencePosition: "0/0", + infiltrationBonus: 0, + continuous: null, + structuralScore: null, + operationalScore: null, + surveyStopReason: null, + remark: null, + }, + { + pipeNo: "118-0026", + unitSeq: 2, + unitLengthM: 6, + defectPositionM: 6, + defectName: "(상태)이음부(접합부)존재", + code: "JE", + recordType: "STATUS", + defectType: null, + severity: null, + occurrencePosition: "0/0", + infiltrationBonus: 0, + continuous: null, + structuralScore: null, + operationalScore: null, + surveyStopReason: null, + remark: null, + }, + { + pipeNo: "118-0026", + unitSeq: 2, + unitLengthM: 6, + defectPositionM: 6, + defectName: "(관로)이음부이탈", + code: "JS", + recordType: "DEFECT", + defectType: "STRUCTURAL", + severity: "L", + occurrencePosition: "4/6", + infiltrationBonus: 0, + continuous: null, + structuralScore: 35, + operationalScore: null, + surveyStopReason: null, + remark: null, + }, + { + pipeNo: "118-0026", + unitSeq: 2, + unitLengthM: 6, + defectPositionM: 7, + defectName: "(관로)균열원주", + code: "CC", + recordType: "DEFECT", + defectType: "STRUCTURAL", + severity: "L", + occurrencePosition: "11/12", + infiltrationBonus: 0, + continuous: null, + structuralScore: 40, + operationalScore: null, + surveyStopReason: null, + remark: null, + }, + { + pipeNo: "118-0026", + unitSeq: 2, + unitLengthM: 6, + defectPositionM: 9.7, + defectName: "(관로)균열원주", + code: "CC", + recordType: "DEFECT", + defectType: "STRUCTURAL", + severity: "L", + occurrencePosition: "7/8", + infiltrationBonus: 0, + continuous: null, + structuralScore: 40, + operationalScore: null, + surveyStopReason: null, + remark: null, + }, + { + pipeNo: "118-0026", + unitSeq: 2, + unitLengthM: 6, + defectPositionM: 11.6, + defectName: "(관로)변형", + code: "DF", + recordType: "DEFECT", + defectType: "STRUCTURAL", + severity: "S", + occurrencePosition: "10/12", + infiltrationBonus: 0, + continuous: false, + structuralScore: 25, + operationalScore: null, + surveyStopReason: null, + remark: null, + }, + { + pipeNo: "118-0026", + unitSeq: 2, + unitLengthM: 6, + defectPositionM: 12, + defectName: "(관로)이음부이탈", + code: "JS", + recordType: "DEFECT", + defectType: "STRUCTURAL", + severity: "L", + occurrencePosition: "5/7", + infiltrationBonus: 0, + continuous: null, + structuralScore: 35, + operationalScore: null, + surveyStopReason: null, + remark: null, + }, + { + pipeNo: "118-0026", + unitSeq: 2, + unitLengthM: 6, + defectPositionM: 12, + defectName: "(상태)이음부(접합부)존재", + code: "JE", + recordType: "STATUS", + defectType: null, + severity: null, + occurrencePosition: "0/0", + infiltrationBonus: 0, + continuous: null, + structuralScore: null, + operationalScore: null, + surveyStopReason: null, + remark: null, + }, +]; + +/** + * 본단위구간 모드 최초 진입 시 처리분구의 관리번호 전체가 보이도록 + * 관로 상세 샘플 3건에 동일한 단위구간 원형을 적용한다. + */ +export const EXMN_SAMPLE_PPL_UNIT_DETAILS: ExmnPplUnitDetailRow[] = + SAMPLE_FACILITIES.flatMap((facility) => + EXMN_SAMPLE_PPL_UNIT_DETAIL_TEMPLATE.map((row) => ({ + ...row, + pipeNo: facility.pipeNo, + })), + ); diff --git a/apps/sn/src/features/exmn/ui/ExmnPplUnitDetailTable.tsx b/apps/sn/src/features/exmn/ui/ExmnPplUnitDetailTable.tsx new file mode 100644 index 000000000..819a84cae --- /dev/null +++ b/apps/sn/src/features/exmn/ui/ExmnPplUnitDetailTable.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { + type ExmnDetailSeverity, + type ExmnPplUnitDetailRow, +} from "../model/exmn-detail-table"; + +const SEVERITY_LABELS: Record = { + L: "대", + M: "중", + S: "소", +}; + +const COLUMN_WIDTHS = [ + 120, + 72, + 82, + 88, + 230, + 62, + 62, + 82, + 82, + 78, + 82, + 82, + 150, + 150, +] as const; + +interface ExmnPplUnitDetailTableProps { + rows: ExmnPplUnitDetailRow[]; + emptyMessage: string; +} + +interface RowSpanInfo { + firstIndex: number; + count: number; +} + +/** 연속된 동일 그룹을 찾아 엑셀의 관로번호·단위구간 병합 셀에 사용할 rowSpan을 계산한다. */ +const buildRowSpanMap = ( + rows: ExmnPplUnitDetailRow[], + getKey: (row: ExmnPplUnitDetailRow) => string, +) => { + const result = new Map(); + rows.forEach((row, index) => { + const key = getKey(row); + const current = result.get(key); + if (current) { + current.count += 1; + return; + } + result.set(key, { firstIndex: index, count: 1 }); + }); + return result; +}; + +/** 숫자 0은 유지하고 미입력 점수·사유만 엑셀형 하이픈으로 표시한다. */ +const formatOptionalValue = (value: number | string | null | undefined) => + value == null || value === "" ? "-" : String(value); + +/** 엑셀 `본단위등급표`의 2단 헤더와 단위별 병합 행을 상세보기 팝업에 표시한다. */ +export function ExmnPplUnitDetailTable({ + rows, + emptyMessage, +}: ExmnPplUnitDetailTableProps) { + const pipeSpans = buildRowSpanMap(rows, (row) => row.pipeNo); + const unitSpans = buildRowSpanMap( + rows, + (row) => `${row.pipeNo}::${row.unitSeq}`, + ); + const tableWidth = COLUMN_WIDTHS.reduce((sum, width) => sum + width, 0); + + return ( +
+ + + {COLUMN_WIDTHS.map((width, index) => ( + + ))} + + + + + + + + + + + + + + + + + + + + + + + + + {rows.length > 0 ? rows.map((row, index) => { + const pipeSpan = pipeSpans.get(row.pipeNo); + const unitKey = `${row.pipeNo}::${row.unitSeq}`; + const unitSpan = unitSpans.get(unitKey); + const showPipeCell = pipeSpan?.firstIndex === index; + const showUnitCells = unitSpan?.firstIndex === index; + + return ( + + {showPipeCell && ( + + )} + {showUnitCells && ( + <> + + + + )} + + + + + + + + + + {showUnitCells && ( + <> + + + + )} + + ); + }) : ( + + + + )} + +
관로번호단위
구간
단위
길이
(m)
결함
위치
(m)
결함등급점수(점)조사중단사유비고
종류코드등급발생
위치
침입수
가점
연속
결함
구조적운영적
+ {row.pipeNo} + + {row.unitSeq} + + {row.unitLengthM} + {row.defectPositionM}{row.defectName}{row.code} + {row.severity ? SEVERITY_LABELS[row.severity] : "-"} + {formatOptionalValue(row.occurrencePosition)}{row.infiltrationBonus} + {row.continuous == null ? "-" : row.continuous ? "Y" : "N"} + {formatOptionalValue(row.structuralScore)}{formatOptionalValue(row.operationalScore)} + {formatOptionalValue(row.surveyStopReason)} + + {formatOptionalValue(row.remark)} +
+ {emptyMessage} +
+
+ ); +} diff --git a/apps/sn/src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx b/apps/sn/src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx index 3f3cb5212..4648b13a7 100644 --- a/apps/sn/src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx +++ b/apps/sn/src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx @@ -1,6 +1,9 @@ "use client"; -import type { UiTableSort } from "@common/business_components/ui"; +import { + downloadExcelWithKoreanHeaders, + type UiTableSort, +} from "@common/business_components/ui"; import { Button, Input } from "@common/components/ui"; import { useEffect, useMemo, useState, type UIEvent } from "react"; import type { ExmnBusiness, ExmnJobType } from "../model/exmn-business-types"; @@ -10,10 +13,15 @@ import { type ExmnDetailDefectColumn, type ExmnDetailDefectType, type ExmnDetailSeverity, + type ExmnPplDetailMode, type ExmnSeverityCounts, type ExmnSurveyDetailRow, } from "../model/exmn-detail-table"; -import { getExmnSampleSurveyDetails } from "../model/exmn-sample-data"; +import { + EXMN_SAMPLE_PPL_UNIT_DETAILS, + getExmnSampleSurveyDetails, +} from "../model/exmn-sample-data"; +import { ExmnPplUnitDetailTable } from "./ExmnPplUnitDetailTable"; const SEVERITY_LABELS: Record = { L: "대", @@ -21,10 +29,10 @@ const SEVERITY_LABELS: Record = { S: "소", }; -/** 상세보기 상단 조회 조건의 조사대상 표시 문구다. */ -const DETAIL_SCOPE_LABELS: Record = { - PPL: "관로 / 본단위구간", - MNHL: "맨홀", +/** 관로 상세보기에서 조회 결과 표를 전환하는 모드 표시 문구다. */ +const PPL_DETAIL_MODE_LABELS: Record = { + PIPE: "관로", + UNIT: "본단위구간", }; const DEFECT_TYPE_LABELS: Record = { @@ -83,6 +91,18 @@ const formatNumber = (value: number) => const formatCount = (value: number | null | undefined) => Number(value ?? 0) === 0 ? "-" : Number(value).toLocaleString("ko-KR"); +/** 가변 결함 열 수에 맞춰 엑셀의 마지막 열 이름(A, Z, AA 등)을 계산한다. */ +const getExcelColumnName = (columnCount: number) => { + let remaining = columnCount; + let result = ""; + while (remaining > 0) { + remaining -= 1; + result = String.fromCharCode(65 + (remaining % 26)) + result; + remaining = Math.floor(remaining / 26); + } + return result || "A"; +}; + /** 공통 고정 열의 행 값을 화면용 문자열로 변환한다. */ const getFixedCellValue = ( row: ExmnSurveyDetailRow, @@ -131,6 +151,8 @@ export function ExmnSurveyDetailPopupPage({ defectType, sampleMode, }: ExmnSurveyDetailPopupPageProps) { + /** 관로 상세보기의 전체 관로 집계와 본단위구간 등급표를 전환하는 표시 모드다. */ + const [detailMode, setDetailMode] = useState("PIPE"); /** 관로·맨홀 번호 조회 입력값과 실제 적용값을 분리해 조회 버튼 동작을 재현한다. */ const [detailNumberKeyword, setDetailNumberKeyword] = useState(""); const [appliedDetailNumberKeyword, setAppliedDetailNumberKeyword] = useState(""); @@ -142,6 +164,10 @@ export function ExmnSurveyDetailPopupPage({ const [notice, setNotice] = useState(null); const definition = EXMN_DETAIL_TABLE_DEFINITIONS[jobType]; const defectTypeLabel = DEFECT_TYPE_LABELS[defectType]; + /** 맨홀은 별도 모드가 없으므로 항상 기존 상세 집계표를 사용한다. */ + const isUnitMode = jobType === "PPL" && detailMode === "UNIT"; + /** 결함 건수 링크 진입 화면은 조회 조건 없이 선택 분류 결과만 바로 표시한다. */ + const isDefectTypeView = defectType !== "ALL"; /** 건수 링크 진입 시 선택한 구조·운영 그룹의 결함 열만 표에 남긴다. */ const visibleDefectColumns = useMemo( () => defectType === "ALL" @@ -154,6 +180,11 @@ export function ExmnSurveyDetailPopupPage({ () => (sampleMode ? getExmnSampleSurveyDetails(jobType) : []), [jobType, sampleMode], ); + /** 본단위구간은 PPL 전용이며 샘플 모드에서 엑셀 기준 단위 기록을 제공한다. */ + const sourceUnitRows = useMemo( + () => (sampleMode && jobType === "PPL" ? EXMN_SAMPLE_PPL_UNIT_DETAILS : []), + [jobType, sampleMode], + ); /** 분류별 상세에서는 선택 결함이 한 건 이상 존재하는 시설 행만 표시한다. */ const scopedRows = useMemo(() => { if (defectType === "ALL") return sourceRows; @@ -173,6 +204,15 @@ export function ExmnSurveyDetailPopupPage({ row.pipeNo.toLocaleLowerCase("ko-KR").includes(normalizedKeyword), ); }, [appliedDetailNumberKeyword, scopedRows]); + /** 본단위구간 최초 진입은 전체를 표시하고, 관리번호가 있으면 부분 일치로 좁힌다. */ + const unitRows = useMemo(() => { + if (!isUnitMode) return []; + const normalizedKeyword = appliedDetailNumberKeyword.trim().toLocaleLowerCase("ko-KR"); + if (!normalizedKeyword) return sourceUnitRows; + return sourceUnitRows.filter( + (row) => row.pipeNo.toLocaleLowerCase("ko-KR").includes(normalizedKeyword), + ); + }, [appliedDetailNumberKeyword, isUnitMode, sourceUnitRows]); /** 보고서번호부터 주행거리까지의 고정 열에 선택 정렬을 적용한다. */ const rows = useMemo(() => { if (!sort) return filteredRows; @@ -200,6 +240,7 @@ export function ExmnSurveyDetailPopupPage({ /** 관로·맨홀 조사유형 또는 결함 범위가 바뀌면 이전 검색 조건과 안내를 초기화한다. */ useEffect(() => { + setDetailMode("PIPE"); setDetailNumberKeyword(""); setAppliedDetailNumberKeyword(""); setSort(undefined); @@ -212,6 +253,17 @@ export function ExmnSurveyDetailPopupPage({ setNotice(null); }; + /** 모드를 바꾸면 이전 표의 관리번호·정렬·안내 상태를 초기화한다. */ + const handleDetailModeChange = (nextMode: ExmnPplDetailMode) => { + if (nextMode === detailMode) return; + setDetailMode(nextMode); + setDetailNumberKeyword(""); + setAppliedDetailNumberKeyword(""); + setSort(undefined); + setTableScrollLeft(0); + setNotice(null); + }; + /** 공통 UiTable 규칙대로 asc, desc, 기본 순서로 고정 열 정렬을 순환한다. */ const handleSortChange = (columnId: ExmnDetailFixedColumnId) => { if (sort?.key !== columnId) { @@ -237,14 +289,6 @@ export function ExmnSurveyDetailPopupPage({ SUMMARY_LABEL_INITIAL_OFFSET - tableScrollLeft, ); - /** 실제 엑셀 다운로드 API 연동 전 사용자에게 후속 연결 상태를 안내한다. */ - const handleDownload = () => { - setNotice({ - type: "info", - text: "엑셀 다운로드는 실제 상세 API 연동 단계에서 연결할 예정입니다.", - }); - }; - const totals = useMemo( () => rows.reduce( (sum, row) => ({ @@ -308,6 +352,192 @@ export function ExmnSurveyDetailPopupPage({ }; }, [rows, visibleDefectColumns]); + /** 현재 화면에 표시된 관로·맨홀 상세 결과를 한글 헤더 엑셀로 내려받는다. */ + const handleDownload = async () => { + const downloadDate = new Date().toISOString().slice(0, 10); + const safeFilePrefix = `${trtdstNm}_${definition.defectGroupTitle}조사_${ + isUnitMode ? "본단위등급표" : defectTypeLabel + }`.replace(/[\\/:*?"<>|]/g, "_"); + + try { + if (isUnitMode) { + if (unitRows.length === 0) { + setNotice({ type: "info", text: "다운로드할 본단위구간 데이터가 없습니다." }); + return; + } + + const unitHeaderMapping: Record = { + pipeNo: "관로번호", + unitSeq: "단위구간", + unitLengthM: "단위길이(m)", + defectPositionM: "결함위치(m)", + defectName: "결함 종류", + code: "결함 코드", + severity: "결함 등급", + occurrencePosition: "발생위치", + infiltrationBonus: "침입수 가점", + continuous: "연속결함", + structuralScore: "구조적 등급점수", + operationalScore: "운영적 등급점수", + surveyStopReason: "조사중단사유", + remark: "비고", + }; + const unitDownloadRows = unitRows.map((row) => ({ + pipeNo: row.pipeNo, + unitSeq: row.unitSeq, + unitLengthM: row.unitLengthM, + defectPositionM: row.defectPositionM, + defectName: row.defectName, + code: row.code, + severity: row.severity ? SEVERITY_LABELS[row.severity] : "-", + occurrencePosition: row.occurrencePosition, + infiltrationBonus: row.infiltrationBonus, + continuous: row.continuous == null ? "-" : row.continuous ? "Y" : "N", + structuralScore: row.structuralScore ?? "-", + operationalScore: row.operationalScore ?? "-", + surveyStopReason: row.surveyStopReason ?? "-", + remark: row.remark ?? "-", + })); + + await downloadExcelWithKoreanHeaders({ + data: unitDownloadRows as unknown as JSON[], + fileName: `${safeFilePrefix}_${downloadDate}.xlsx`, + sheetNameArray: ["본단위등급표"], + headerRange: "A1:N1", + headerMapping: unitHeaderMapping, + }); + return; + } + + if (rows.length === 0) { + setNotice({ type: "info", text: "다운로드할 상세현황 데이터가 없습니다." }); + return; + } + + const detailHeaderMapping: Record = { + reportNo: "보고서번호", + pipeNo: jobType === "PPL" ? "관로번호" : "맨홀번호", + upstreamManhole: "상류맨홀", + downstreamManhole: "하류맨홀", + drainageType: "배수방식", + pipeType: "관종", + specification: "규격", + extensionM: "연장(m)", + drivingDistanceM: "주행거리(m)", + anomalyL: "이상항목 합계-대", + anomalyM: "이상항목 합계-중", + anomalyS: "이상항목 합계-소", + }; + visibleDefectColumns.forEach((column) => { + EXMN_DETAIL_SEVERITIES.forEach((severity) => { + detailHeaderMapping[`${column.code}_${severity}`] = + `${column.label} (${column.code})-${SEVERITY_LABELS[severity]}`; + }); + }); + + const detailDownloadRows: Record[] = rows.map((row) => { + const anomalyTotals = getVisibleAnomalyTotals(row, visibleDefectColumns); + const result: Record = { + reportNo: row.reportNo, + pipeNo: row.pipeNo, + upstreamManhole: row.upstreamManhole, + downstreamManhole: row.downstreamManhole, + drainageType: row.drainageType, + pipeType: row.pipeType, + specification: row.specification, + extensionM: row.extensionM, + drivingDistanceM: row.drivingDistanceM, + anomalyL: anomalyTotals.L, + anomalyM: anomalyTotals.M, + anomalyS: anomalyTotals.S, + }; + visibleDefectColumns.forEach((column) => { + EXMN_DETAIL_SEVERITIES.forEach((severity) => { + result[`${column.code}_${severity}`] = + row.defectCounts[column.code]?.[severity] ?? 0; + }); + }); + return result; + }); + const subtotalRow: Record = { + reportNo: "소계", + pipeNo: "", + upstreamManhole: "", + downstreamManhole: "", + drainageType: "", + pipeType: "", + specification: "", + extensionM: totals.extensionM, + drivingDistanceM: totals.drivingDistanceM, + anomalyL: defectSummary.anomalySubtotals.L, + anomalyM: defectSummary.anomalySubtotals.M, + anomalyS: defectSummary.anomalySubtotals.S, + }; + const totalRow: Record = { + reportNo: "합계", + pipeNo: "", + upstreamManhole: "", + downstreamManhole: "", + drainageType: "", + pipeType: "", + specification: "", + extensionM: totals.extensionM, + drivingDistanceM: totals.drivingDistanceM, + anomalyL: defectSummary.anomalyTotal, + anomalyM: "", + anomalyS: "", + }; + visibleDefectColumns.forEach((column) => { + EXMN_DETAIL_SEVERITIES.forEach((severity) => { + subtotalRow[`${column.code}_${severity}`] = + defectSummary.defectSubtotals[column.code]?.[severity] ?? 0; + }); + totalRow[`${column.code}_L`] = + defectSummary.defectTotals[column.code] ?? 0; + totalRow[`${column.code}_M`] = ""; + totalRow[`${column.code}_S`] = ""; + }); + detailDownloadRows.push(subtotalRow, totalRow); + + const subtotalExcelRow = rows.length + 2; + const totalExcelRow = rows.length + 3; + const mergeRanges = [ + `A${subtotalExcelRow}:G${subtotalExcelRow}`, + `A${totalExcelRow}:G${totalExcelRow}`, + ]; + for ( + let groupStartColumn = 10; + groupStartColumn < Object.keys(detailHeaderMapping).length; + groupStartColumn += EXMN_DETAIL_SEVERITIES.length + ) { + mergeRanges.push( + `${getExcelColumnName(groupStartColumn)}${totalExcelRow}:${ + getExcelColumnName(groupStartColumn + EXMN_DETAIL_SEVERITIES.length - 1) + }${totalExcelRow}`, + ); + } + + await downloadExcelWithKoreanHeaders({ + data: detailDownloadRows as unknown as JSON[], + fileName: `${safeFilePrefix}_${downloadDate}.xlsx`, + sheetNameArray: [ + `${definition.defectGroupTitle}조사 ${defectTypeLabel}`.slice(0, 31), + ], + headerRange: `A1:${getExcelColumnName(Object.keys(detailHeaderMapping).length)}1`, + headerMapping: detailHeaderMapping, + mergeRanges, + }); + } catch (error) { + console.error("조사 상세현황 엑셀 다운로드 오류:", error); + setNotice({ + type: "info", + text: error instanceof Error + ? error.message + : "엑셀 다운로드 중 오류가 발생했습니다.", + }); + } + }; + const tableWidth = useMemo( () => FIXED_COLUMNS.reduce((sum, column) => sum + column.width, 0) + (visibleDefectColumns.length + 1) @@ -315,12 +545,21 @@ export function ExmnSurveyDetailPopupPage({ * SEVERITY_COLUMN_WIDTH, [visibleDefectColumns.length], ); - const popupTitle = defectType === "ALL" - ? definition.title - : `${definition.defectGroupTitle}조사 ${defectTypeLabel} 상세현황`; - const tableTitle = defectType === "ALL" - ? `${trtdstNm} ${definition.defectGroupTitle}조사 집계표` - : `${trtdstNm} ${definition.defectGroupTitle}조사 ${defectTypeLabel} 집계표`; + /** 본단위구간 요약은 조회된 한 관로의 단위 수와 상태·결함 기록 수를 사용한다. */ + const unitSectionCount = useMemo( + () => new Set(unitRows.map((row) => `${row.pipeNo}::${row.unitSeq}`)).size, + [unitRows], + ); + const popupTitle = isUnitMode + ? "관로조사 본단위구간 상세현황" + : defectType === "ALL" + ? definition.title + : `${definition.defectGroupTitle}조사 ${defectTypeLabel} 상세현황`; + const tableTitle = isUnitMode + ? `${trtdstNm} 관로조사 본단위등급표` + : defectType === "ALL" + ? `${trtdstNm} ${definition.defectGroupTitle}조사 집계표` + : `${trtdstNm} ${definition.defectGroupTitle}조사 ${defectTypeLabel} 집계표`; const visibleDefectGroupTitle = defectType === "ALL" ? definition.defectGroupTitle : `${definition.defectGroupTitle} ${defectTypeLabel}`; @@ -338,73 +577,96 @@ export function ExmnSurveyDetailPopupPage({ )}
-
- + {!isDefectTypeView && ( +
+
+ - - - - -
- - +
-
- {!sampleMode && ( +
+ {jobType === "PPL" && ( +
+
+
+
+ )} + +
+ + +
+
+
+ )} + + {!sampleMode && !isDefectTypeView && (
실제 상세 데이터 API는 후속 백엔드 연동 단계에서 연결합니다. 화면 샘플은 `sample=Y`에서 확인할 수 있습니다.
@@ -416,19 +678,49 @@ export function ExmnSurveyDetailPopupPage({ {tableTitle} - 조회 결과 총 {rows.length.toLocaleString("ko-KR")}건 + 조회 결과 총 {(isUnitMode ? unitRows.length : rows.length).toLocaleString("ko-KR")}건
- 연장거리 합계{formatNumber(totals.extensionM)}m - 주행거리 합계{formatNumber(totals.drivingDistanceM)}m + {isUnitMode ? ( + <> + 관리번호{appliedDetailNumberKeyword || "전체"} + 단위구간{unitSectionCount.toLocaleString("ko-KR")}개 + + ) : ( + <> + 연장거리 합계{formatNumber(totals.extensionM)}m + 주행거리 합계{formatNumber(totals.drivingDistanceM)}m + {isDefectTypeView && ( + + )} + + )}
-
+ {isUnitMode ? ( + + ) : ( +
-
+
+ )}
);