조사상세 본단위구간 모드 및 결함별 엑셀 다운로드 구현

This commit is contained in:
kiminchan
2026-07-27 10:15:06 +09:00
parent 73f1940cb0
commit 7d9b97aac9
8 changed files with 903 additions and 92 deletions
@@ -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>
);