Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev

This commit is contained in:
2026-07-27 10:32:25 +09:00
8 changed files with 903 additions and 92 deletions
@@ -180,3 +180,22 @@
- 브라우저 수동 검증은 로그인된 `SN` 세션과 `/oper/exmn/register?sample=Y` 진입이 선행조건이다.
- 개발 서버가 실행 중이면 별도 프로덕션 빌드를 실행하지 않고 정적 검사와 사용자 화면 확인을 우선한다.
## 14. 관로 본단위구간과 결함별 상세 팝업 회귀 계획
| 검증 항목 | 방법 | 기대 결과 |
|---|---|---|
| 관로 전체 상세보기 | `PPL`, `defectType=ALL` 진입 | 처리분구·관리번호와 `관로`·`본단위구간` 전환 표시 |
| 맨홀 상세보기 | `MNHL` 상세보기 진입 | 관로 전용 모드 전환 미표시 |
| 본단위구간 초기값 | 관리번호 없이 `본단위구간` 전환 | 선택 처리분구의 전체 관리번호 단위구간 표시 |
| 관리번호 검색 | 일부 관리번호 입력 후 조회 | 부분 일치 관로번호의 단위구간만 표시 |
| 단위구간 표 구조 | 한 관로의 여러 상세행 확인 | 관로·단위구간 공통 셀 병합과 14개 열 유지 |
| 구조적 링크 | 구조적결함 건수 클릭 | 조회 조건 없이 구조적결함 집계표 즉시 표시 |
| 운영적 링크 | 운영적결함 건수 클릭 | 조회 조건 없이 운영적결함 집계표 즉시 표시 |
| 결함별 요약 행 | 집계표 제목 행 확인 | 합계 정보와 다운로드 버튼이 오른쪽에 함께 표시 |
| 전체 상세보기 회귀 | 상단 상세보기 버튼 진입 | 기존 조회 조건·모드·전체 이상항목 표 유지 |
| 엑셀 다운로드 | 전체·분류별·본단위구간 각각 다운로드 | 현재 표시 행·열, 소계·합계와 병합 구조 반영 |
| 정적 검사 | 변경 저장소에서 `git diff --check` | 공백 오류 없음 |
- 실제 다운로드 파일은 Excel에서 열어 헤더, 병합 범위, 소계·합계와 한글 파일명을 대조한다.
- 실제 API 연결 후에는 샘플과 동일한 조건으로 JSONB 코드·점수·단위구간 매핑을 재검증한다.
@@ -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 데이터 연결과 코드·점수·단위구간 매핑 검증은 백엔드 연동 단계의 후속 항목이다.
- 지도보기는 보류 범위로 이번 검증 대상에 포함하지 않았다.
@@ -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 매핑과 다운로드 파일의 업무 데이터 대조는 백엔드 연동 단계에서 수행한다.
@@ -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 값을 현재 화면 모델과 코드 기준으로 매핑한다.
@@ -30,6 +30,29 @@ export interface ExmnSurveyDetailRow {
defectCounts: Record<string, ExmnSeverityCounts>;
}
/** 관로 상세보기에서 전체 관로 집계와 본단위구간 등급표를 전환하는 표시 모드다. */
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;
@@ -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,
})),
);
@@ -0,0 +1,190 @@
"use client";
import {
type ExmnDetailSeverity,
type ExmnPplUnitDetailRow,
} from "../model/exmn-detail-table";
const SEVERITY_LABELS: Record<ExmnDetailSeverity, string> = {
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<string, RowSpanInfo>();
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 (
<div className="min-h-0 flex-1 overflow-auto border border-border bg-background">
<table
className="border-separate border-spacing-0 text-center text-[1.05rem] text-foreground"
style={{ width: `${tableWidth}px`, minWidth: `${tableWidth}px` }}
>
<colgroup>
{COLUMN_WIDTHS.map((width, index) => (
<col key={`unit-column-${index}`} style={{ width: `${width}px` }} />
))}
</colgroup>
<thead className="sticky top-0 z-30 bg-[#eaf3fb] text-[1.2rem] font-medium leading-[1.2] text-[#647784]">
<tr>
<th rowSpan={2} className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">관로번호</th>
<th rowSpan={2} className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">단위<br />구간</th>
<th rowSpan={2} className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">단위<br />길이<br />(m)</th>
<th rowSpan={2} className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">결함<br />위치<br />(m)</th>
<th colSpan={6} className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">결함</th>
<th colSpan={2} className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">등급점수(점)</th>
<th rowSpan={2} className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">조사중단사유</th>
<th rowSpan={2} className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">비고</th>
</tr>
<tr>
<th className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">종류</th>
<th className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">코드</th>
<th className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">등급</th>
<th className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">발생<br />위치</th>
<th className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">침입수<br />가점</th>
<th className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">연속<br />결함</th>
<th className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">구조적</th>
<th className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">운영적</th>
</tr>
</thead>
<tbody>
{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 (
<tr
key={`${row.pipeNo}-${row.unitSeq}-${row.defectPositionM}-${row.code}-${index}`}
className="sn-popup-grid-row sn-popup-grid-row-hover"
>
{showPipeCell && (
<td
rowSpan={pipeSpan?.count}
className="border-b border-r border-border bg-background px-2 py-2.5 align-middle font-medium text-primary"
>
{row.pipeNo}
</td>
)}
{showUnitCells && (
<>
<td
rowSpan={unitSpan?.count}
className="border-b border-r border-border bg-background px-2 py-2.5 align-middle font-medium"
>
{row.unitSeq}
</td>
<td
rowSpan={unitSpan?.count}
className="border-b border-r border-border bg-background px-2 py-2.5 align-middle"
>
{row.unitLengthM}
</td>
</>
)}
<td className="border-b border-r border-border px-2 py-2.5">{row.defectPositionM}</td>
<td className="border-b border-r border-border px-2 py-2.5 text-left">{row.defectName}</td>
<td className="border-b border-r border-border px-2 py-2.5">{row.code}</td>
<td className="border-b border-r border-border px-2 py-2.5">
{row.severity ? SEVERITY_LABELS[row.severity] : "-"}
</td>
<td className="border-b border-r border-border px-2 py-2.5">{formatOptionalValue(row.occurrencePosition)}</td>
<td className="border-b border-r border-border px-2 py-2.5">{row.infiltrationBonus}</td>
<td className="border-b border-r border-border px-2 py-2.5">
{row.continuous == null ? "-" : row.continuous ? "Y" : "N"}
</td>
<td className="border-b border-r border-border px-2 py-2.5">{formatOptionalValue(row.structuralScore)}</td>
<td className="border-b border-r border-border px-2 py-2.5">{formatOptionalValue(row.operationalScore)}</td>
{showUnitCells && (
<>
<td
rowSpan={unitSpan?.count}
className="border-b border-r border-border bg-background px-2 py-2.5 align-middle"
>
{formatOptionalValue(row.surveyStopReason)}
</td>
<td
rowSpan={unitSpan?.count}
className="border-b border-r border-border bg-background px-2 py-2.5 align-middle"
>
{formatOptionalValue(row.remark)}
</td>
</>
)}
</tr>
);
}) : (
<tr>
<td
colSpan={COLUMN_WIDTHS.length}
className="sn-popup-empty-cell h-28 border-b border-r border-border text-center"
>
{emptyMessage}
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}
@@ -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<ExmnDetailSeverity, string> = {
L: "대",
@@ -21,10 +29,10 @@ const SEVERITY_LABELS: Record<ExmnDetailSeverity, string> = {
S: "소",
};
/** 상세보기 상단 조회 조건의 조사대상 표시 문구다. */
const DETAIL_SCOPE_LABELS: Record<ExmnJobType, string> = {
PPL: "관로 / 본단위구간",
MNHL: "맨홀",
/** 관로 상세보기에서 조회 결과 표를 전환하는 모드 표시 문구다. */
const PPL_DETAIL_MODE_LABELS: Record<ExmnPplDetailMode, string> = {
PIPE: "관로",
UNIT: "본단위구간",
};
const DEFECT_TYPE_LABELS: Record<ExmnDetailDefectType, string> = {
@@ -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<ExmnPplDetailMode>("PIPE");
/** 관로·맨홀 번호 조회 입력값과 실제 적용값을 분리해 조회 버튼 동작을 재현한다. */
const [detailNumberKeyword, setDetailNumberKeyword] = useState("");
const [appliedDetailNumberKeyword, setAppliedDetailNumberKeyword] = useState("");
@@ -142,6 +164,10 @@ export function ExmnSurveyDetailPopupPage({
const [notice, setNotice] = useState<Notice>(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<string, string> = {
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<string, string> = {
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<string, string | number>[] = rows.map((row) => {
const anomalyTotals = getVisibleAnomalyTotals(row, visibleDefectColumns);
const result: Record<string, string | number> = {
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<string, string | number> = {
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<string, string | number> = {
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({
)}
<section className="sn-popup-section overflow-y-hidden">
<div
className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:items-end"
style={{ gridTemplateColumns: "180px 220px 300px minmax(0, 1fr)" }}
>
<label className="sn-popup-field">
<div className="sn-popup-field-label">처리분구</div>
<select
className="sn-popup-input !text-[1.1rem]"
defaultValue={trtdstId}
aria-label="처리분구"
>
<option value={trtdstId}>{trtdstNm}</option>
</select>
</label>
{!isDefectTypeView && (
<div className="flex flex-wrap items-end justify-between gap-4 border-b border-slate-200 bg-slate-50 pb-4">
<div className="flex flex-wrap items-end gap-3">
<label className="sn-popup-field" style={{ width: "180px" }}>
<div className="sn-popup-field-label">처리분구</div>
<select
className="sn-popup-input !text-[1.1rem]"
defaultValue={trtdstId}
aria-label="처리분구"
>
<option value={trtdstId}>{trtdstNm}</option>
</select>
</label>
<label className="sn-popup-field">
<div className="sn-popup-field-label">조사대상</div>
<select
className="sn-popup-input !text-[1.1rem]"
defaultValue={jobType}
aria-label="조사대상"
>
<option value={jobType}>
{DETAIL_SCOPE_LABELS[jobType]}
{defectType === "ALL" ? "" : ` / ${defectTypeLabel}`}
</option>
</select>
</label>
<label className="sn-popup-field">
<div className="sn-popup-field-label">
{jobType === "PPL" ? "관로번호" : "맨홀번호"}
</div>
<Input
className="sn-popup-input !text-[1.1rem]"
value={detailNumberKeyword}
placeholder={`${jobType === "PPL" ? "관로" : "맨홀"}번호 입력`}
onChange={(event) => setDetailNumberKeyword(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") handleSearch();
}}
/>
</label>
<div className="sn-popup-actions">
<Button
type="button"
size="sm"
color="primary"
className="sn-popup-primary-button !text-[1.1rem]"
onClick={handleSearch}
>
조회
</Button>
<Button
type="button"
size="sm"
color="green"
className="sn-popup-primary-button !text-[1.1rem]"
onClick={handleDownload}
>
다운로드
</Button>
<label className="sn-popup-field" style={{ width: "300px" }}>
<div className="sn-popup-field-label">관리번호</div>
<Input
className="sn-popup-input !text-[1.1rem]"
value={detailNumberKeyword}
placeholder="관리번호 입력"
onChange={(event) => setDetailNumberKeyword(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") handleSearch();
}}
/>
</label>
</div>
</div>
{!sampleMode && (
<div className="flex flex-wrap items-end gap-3">
{jobType === "PPL" && (
<fieldset className="sn-popup-field" style={{ width: "180px" }}>
<div
className="relative grid h-[2.8rem] grid-cols-2 rounded-full border border-[#cbd5e1] bg-white p-[2px] shadow-inner"
role="group"
aria-label="관로 상세보기 모드"
>
<span
aria-hidden="true"
className="pointer-events-none absolute bottom-[2px] left-[2px] top-[2px] w-[calc(50%-2px)] rounded-full bg-primary shadow-sm transition-transform duration-200 ease-out"
style={{
transform: detailMode === "UNIT"
? "translateX(100%)"
: "translateX(0)",
}}
/>
{(Object.keys(PPL_DETAIL_MODE_LABELS) as ExmnPplDetailMode[]).map(
(mode) => (
<button
key={mode}
type="button"
className={[
"relative z-10 rounded-full px-2 text-[0.95rem] font-semibold transition-colors duration-200",
detailMode === mode ? "text-white" : "text-[#64748b]",
].join(" ")}
aria-pressed={detailMode === mode}
onClick={() => handleDetailModeChange(mode)}
>
{PPL_DETAIL_MODE_LABELS[mode]}
</button>
),
)}
</div>
</fieldset>
)}
<div className="sn-popup-actions">
<Button
type="button"
size="sm"
color="primary"
className="sn-popup-primary-button !text-[1.1rem]"
onClick={handleSearch}
>
조회
</Button>
<Button
type="button"
size="sm"
color="green"
className="sn-popup-primary-button !text-[1.1rem]"
onClick={() => void handleDownload()}
>
다운로드
</Button>
</div>
</div>
</div>
)}
{!sampleMode && !isDefectTypeView && (
<div className="shrink-0 border-b border-border bg-secondary px-4 py-2 text-[1.05rem] text-primary">
실제 상세 데이터 API는 후속 백엔드 연동 단계에서 연결합니다. 화면 샘플은 `sample=Y`에서 확인할 수 있습니다.
</div>
@@ -416,19 +678,49 @@ export function ExmnSurveyDetailPopupPage({
{tableTitle}
</h2>
<span className="truncate text-[1.05rem] text-label">
조회 결과 총 {rows.length.toLocaleString("ko-KR")}건
조회 결과 총 {(isUnitMode ? unitRows.length : rows.length).toLocaleString("ko-KR")}건
</span>
</div>
<div className="flex shrink-0 items-center gap-7 whitespace-nowrap text-[1.05rem] text-label">
<span><strong className="mr-2 text-foreground">연장거리 합계</strong>{formatNumber(totals.extensionM)}m</span>
<span><strong className="mr-2 text-foreground">주행거리 합계</strong>{formatNumber(totals.drivingDistanceM)}m</span>
{isUnitMode ? (
<>
<span><strong className="mr-2 text-foreground">관리번호</strong>{appliedDetailNumberKeyword || "전체"}</span>
<span><strong className="mr-2 text-foreground">단위구간</strong>{unitSectionCount.toLocaleString("ko-KR")}개</span>
</>
) : (
<>
<span><strong className="mr-2 text-foreground">연장거리 합계</strong>{formatNumber(totals.extensionM)}m</span>
<span><strong className="mr-2 text-foreground">주행거리 합계</strong>{formatNumber(totals.drivingDistanceM)}m</span>
{isDefectTypeView && (
<Button
type="button"
size="sm"
color="green"
className="sn-popup-primary-button !ml-1 !text-[1.05rem]"
onClick={() => void handleDownload()}
>
다운로드
</Button>
)}
</>
)}
</div>
</div>
<div
className="min-h-0 flex-1 overflow-auto border border-border bg-background"
onScroll={handleTableScroll}
>
{isUnitMode ? (
<ExmnPplUnitDetailTable
rows={unitRows}
emptyMessage={
appliedDetailNumberKeyword
? "입력한 관리번호에 해당하는 본단위구간이 없습니다."
: "조회된 본단위구간이 없습니다."
}
/>
) : (
<div
className="min-h-0 flex-1 overflow-auto border border-border bg-background"
onScroll={handleTableScroll}
>
<table
className="border-separate border-spacing-0 text-center text-[1.05rem] text-foreground"
style={{ width: `${tableWidth}px`, minWidth: `${tableWidth}px` }}
@@ -732,7 +1024,8 @@ export function ExmnSurveyDetailPopupPage({
)}
</tbody>
</table>
</div>
</div>
)}
</section>
</main>
);