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

# Conflicts:
#	apps/sn/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx
#	apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx
This commit is contained in:
2026-07-30 17:26:41 +09:00
1206 changed files with 116754 additions and 15674 deletions
@@ -3,6 +3,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button, Table, TableBody, TableCell, TableHeader, TableHead, TableRow } from "@common/components";
import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
import type { UiTableSort } from "@common/business_components/ui";
import UiPageNations from "@common/business_components/ui/table/uiPageNations";
import { getData } from "@/components/lib/api";
import LoadingOverlay from "@/components/ui/LoadingBar";
import useStore from "@/store/useStore";
@@ -32,6 +34,14 @@ const SUMMARY_FILE_PREFIX: Record<string, string> = {
MATERIAL: "관로분석_관재질별",
YEAR_MATERIAL: "관로분석_경과년도별_관재질별",
};
const PIPE_ANALYSIS_SUMMARY_PAGE_SIZE = 10;
const YEAR_MATERIAL_SORTABLE_KEYS = new Set([
"rowNo",
"ctpvNm",
"sggNm",
"swtrmtZoneNm",
"totalLengthKm",
]);
const toExcelColumnName = (index: number) => {
let current = index;
@@ -57,6 +67,90 @@ const formatGridNumber = (value: unknown) => {
});
};
const resolveErrorMessage = (error: unknown, fallbackMessage: string) =>
error instanceof Error && error.message.trim() ? error.message : fallbackMessage;
const KOREAN_GRID_COLLATOR = new Intl.Collator("ko", {
numeric: true,
sensitivity: "base",
});
/** Sorts the complete summary result before client-side pagination. */
const sortSummaryRows = (
sourceRows: PipeAnalysisSummaryViewRow[],
sort?: UiTableSort,
) => {
if (!sort) return sourceRows;
const sortableRows = sourceRows.filter((row) => !row.__isNoZone && !row.__isSummaryTotal);
const pinnedRows = sourceRows.filter((row) => row.__isNoZone || row.__isSummaryTotal);
sortableRows.sort((left, right) => {
const leftValue = left[sort.key];
const rightValue = right[sort.key];
const leftEmpty = leftValue === null || leftValue === undefined || leftValue === "";
const rightEmpty = rightValue === null || rightValue === undefined || rightValue === "";
if (leftEmpty || rightEmpty) {
if (leftEmpty && rightEmpty) return 0;
return leftEmpty ? 1 : -1;
}
const leftNumber = typeof leftValue === "number" ? leftValue : Number.NaN;
const rightNumber = typeof rightValue === "number" ? rightValue : Number.NaN;
const compared = Number.isFinite(leftNumber) && Number.isFinite(rightNumber)
? leftNumber - rightNumber
: KOREAN_GRID_COLLATOR.compare(String(leftValue), String(rightValue));
return sort.direction === "asc" ? compared : -compared;
});
return [...sortableRows, ...pinnedRows];
};
const getYearMaterialGroupSortValue = (
group: PipeAnalysisYearMaterialGroup,
key: string,
) => {
switch (key) {
case "rowNo":
return group.rowNo;
case "ctpvNm":
return group.ctpvNm;
case "sggNm":
return group.sggNm;
case "swtrmtZoneNm":
return group.swtrmtZoneNm;
case "totalLengthKm":
return group.totalLengthKm;
default:
return group.rows.reduce((sum, row) => sum + Number(row.metrics[key] ?? 0), 0);
}
};
const sortYearMaterialGroups = (
groups: PipeAnalysisYearMaterialGroup[],
sort?: UiTableSort,
) => {
if (!sort || !YEAR_MATERIAL_SORTABLE_KEYS.has(sort.key)) return groups;
const sortedGroups = [...groups];
sortedGroups.sort((left, right) => {
const leftValue = getYearMaterialGroupSortValue(left, sort.key);
const rightValue = getYearMaterialGroupSortValue(right, sort.key);
const compared = typeof leftValue === "number" && typeof rightValue === "number"
? leftValue - rightValue
: KOREAN_GRID_COLLATOR.compare(String(leftValue), String(rightValue));
if (compared !== 0) {
return sort.direction === "asc" ? compared : -compared;
}
return KOREAN_GRID_COLLATOR.compare(left.swtrmtZoneNm, right.swtrmtZoneNm);
});
return sortedGroups;
};
export default function PipeAnalysisPopupPage() {
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const userInfo = useUserStore((state) => state.userInfo);
@@ -70,6 +164,8 @@ export default function PipeAnalysisPopupPage() {
const [materialColumns, setMaterialColumns] = useState<PipeAnalysisMaterialColumn[]>([]);
const [rows, setRows] = useState<PipeAnalysisSummaryViewRow[]>([]);
const [zoneOptions, setZoneOptions] = useState<Array<{ value: string; label: string }>>([]);
const [summaryCurrentPage, setSummaryCurrentPage] = useState(1);
const [sort, setSort] = useState<UiTableSort>();
const [isLoading, setIsLoading] = useState(false);
const initializedRef = useRef(false);
@@ -138,10 +234,11 @@ export default function PipeAnalysisPopupPage() {
const scopedFilters = applyScopeToSummaryFilters(nextFilters, scope);
setDraftFilters(scopedFilters);
setAppliedFilters(scopedFilters);
setSummaryCurrentPage(1);
await fetchSummary(scopedFilters);
} catch (error) {
console.error("관로분석 집계 조회 중 오류:", error);
alert("관로분석 집계 조회 중 오류가 발생했습니다.");
alert(resolveErrorMessage(error, "관로분석 집계 조회 중 오류가 발생했습니다."));
setRows([]);
setMaterialColumns([]);
} finally {
@@ -176,7 +273,7 @@ export default function PipeAnalysisPopupPage() {
const ranges: string[] = [];
let currentRow = 2;
yearMaterialGroups.forEach((group) => {
orderedYearMaterialGroups.forEach((group) => {
const rowSpan = group.rows.length;
if (rowSpan > 1) {
["A", "B", "C", "D", "E"].forEach((column) => {
@@ -190,7 +287,7 @@ export default function PipeAnalysisPopupPage() {
})()
: [];
const exportRows = appliedFilters.analysisType === "YEAR_MATERIAL"
? yearMaterialGroups.flatMap((group) =>
? orderedYearMaterialGroups.flatMap((group) =>
group.rows.map((yearRow, rowIndex) => {
const exportRow: Record<string, unknown> = {
rowNo: rowIndex === 0 ? group.rowNo : "",
@@ -208,7 +305,7 @@ export default function PipeAnalysisPopupPage() {
return exportRow;
}),
)
: rows.map((row) => {
: sortedRows.map((row) => {
const exportRow: Record<string, unknown> = {};
exportHeadList.forEach((head) => {
exportRow[head.id] = row[head.id];
@@ -225,13 +322,13 @@ export default function PipeAnalysisPopupPage() {
});
} catch (error) {
console.error("관로분석 집계 다운로드 중 오류:", error);
alert("관로분석 집계 다운로드 중 오류가 발생했습니다.");
alert(resolveErrorMessage(error, "관로분석 집계 다운로드 중 오류가 발생했습니다."));
}
};
const handleCellClick = (index?: number, columnId?: string) => {
if (typeof index !== "number" || !columnId) return;
const row = rows[index];
const row = pagedRows[index];
if (!row) return;
if (row.__isNoZone || row.__isSummaryTotal) return;
const cellFilter = row.__cellFilters[columnId];
@@ -268,18 +365,55 @@ export default function PipeAnalysisPopupPage() {
};
const headList = useMemo(
() => buildPipeAnalysisSummaryHeadList(appliedFilters.analysisType, materialColumns),
() => buildPipeAnalysisSummaryHeadList(appliedFilters.analysisType, materialColumns).map((head) => ({ ...head, sortable: true })),
[appliedFilters.analysisType, materialColumns],
);
const sortedRows = useMemo(
() => sortSummaryRows(rows, sort),
[rows, sort],
);
const yearMaterialGroups = useMemo(
() => appliedFilters.analysisType === "YEAR_MATERIAL"
? buildPipeAnalysisYearMaterialGroups(rows, materialColumns)
: [],
[appliedFilters.analysisType, materialColumns, rows],
);
const sortedYearMaterialGroups = useMemo(
() => sortYearMaterialGroups(yearMaterialGroups, sort),
[sort, yearMaterialGroups],
);
const orderedYearMaterialGroups = useMemo(
() => sortedYearMaterialGroups.map((group, index) => ({
...group,
rowNo: sort?.key === "rowNo" && sort.direction === "desc"
? sortedYearMaterialGroups.length - index
: index + 1,
})),
[sort, sortedYearMaterialGroups],
);
const useHorizontalScrollTable = appliedFilters.analysisType !== "YEAR";
const summaryPageOffset = (summaryCurrentPage - 1) * PIPE_ANALYSIS_SUMMARY_PAGE_SIZE;
const pagedRows = useMemo(
() =>
appliedFilters.analysisType === "YEAR_MATERIAL"
? rows
: sortedRows.slice(summaryPageOffset, summaryPageOffset + PIPE_ANALYSIS_SUMMARY_PAGE_SIZE),
[appliedFilters.analysisType, rows, sortedRows, summaryPageOffset],
);
const visibleYearMaterialGroups = useMemo(
() =>
orderedYearMaterialGroups.slice(
summaryPageOffset,
summaryPageOffset + PIPE_ANALYSIS_SUMMARY_PAGE_SIZE,
),
[orderedYearMaterialGroups, summaryPageOffset],
);
const summaryTotalCount = appliedFilters.analysisType === "YEAR_MATERIAL"
? orderedYearMaterialGroups.length
: rows.length;
const sggOptions = useMemo(
() =>
@@ -290,18 +424,18 @@ export default function PipeAnalysisPopupPage() {
);
return (
<main className="flex h-screen flex-col overflow-hidden bg-white">
<main className="sn-popup-page">
<LoadingOverlay isLoading={isLoading} />
<header className="flex min-h-[44px] items-center justify-between border-b border-[#dbe2e6] bg-primary px-5 py-3">
<h1 className="text-[1.45rem] font-semibold text-white">관로분석</h1>
<header className="sn-popup-header">
<h1 className="sn-popup-title">관로분석</h1>
</header>
<section className="overflow-y-auto p-4">
<div className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_auto] md:items-end">
<section className="sn-popup-section overflow-y-auto">
<div className="grid grid-cols-1 gap-3 border-b border-border bg-secondary pb-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_auto] md:items-end">
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시도</div>
<div className="mb-1 text-[1.05rem] font-medium text-label">시도</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
className="sn-popup-input !text-[1.1rem]"
value={draftFilters.ctpvCd}
disabled={scope.ctpvLocked}
onChange={(event) =>
@@ -323,9 +457,9 @@ export default function PipeAnalysisPopupPage() {
</label>
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시군구</div>
<div className="mb-1 text-[1.05rem] font-medium text-label">시군구</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
className="sn-popup-input !text-[1.1rem]"
value={draftFilters.sggCd}
disabled={scope.sggLocked}
onChange={(event) =>
@@ -346,9 +480,9 @@ export default function PipeAnalysisPopupPage() {
</label>
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">처리구역</div>
<div className="mb-1 text-[1.05rem] font-medium text-label">처리구역</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
className="sn-popup-input !text-[1.1rem]"
value={draftFilters.swtrmtZoneNm}
onChange={(event) =>
setDraftFilters((current) => ({
@@ -366,9 +500,9 @@ export default function PipeAnalysisPopupPage() {
</label>
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">분석구분</div>
<div className="mb-1 text-[1.05rem] font-medium text-label">분석구분</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
className="sn-popup-input !text-[1.1rem]"
value={draftFilters.analysisType}
onChange={(event) =>
setDraftFilters((current) => ({
@@ -410,11 +544,8 @@ export default function PipeAnalysisPopupPage() {
{appliedFilters.analysisType === "YEAR_MATERIAL" ? (
<>
<div className="card-header">
<h3 className="hidden">
경과년도&재질 관로현황 총 {yearMaterialGroups.length.toLocaleString("ko-KR")}건
</h3>
<h3 className="text-2xl font-medium text-label">
{resolvePipeAnalysisTypeLabel(appliedFilters.analysisType)} 관로현황 총 {yearMaterialGroups.length.toLocaleString("ko-KR")}건
{resolvePipeAnalysisTypeLabel(appliedFilters.analysisType)} 관로현황 {summaryTotalCount.toLocaleString("ko-KR")}건
</h3>
</div>
<div className="card !p-0r !m-[1.2rem] overflow-x-auto">
@@ -424,18 +555,67 @@ export default function PipeAnalysisPopupPage() {
{headList.map((head) => (
<TableHead
key={head.id}
className={`border border-border bg-[#eaf3fb] px-4 py-[1.2rem] text-[1.3rem] font-medium text-[#647784] ${
className={`border border-border bg-secondary px-4 py-[1.2rem] text-[1.3rem] font-medium text-label ${
head.align === "right" ? "text-right" : "text-center"
}`}
aria-sort={
head.id === "elapsedYearLabel"
? undefined
: sort?.key === head.id
? sort.direction === "asc"
? "ascending"
: "descending"
: "none"
}
>
{head.title}
{!YEAR_MATERIAL_SORTABLE_KEYS.has(head.id) ? (
head.title
) : (
<button
type="button"
className="inline-flex w-full items-center justify-center gap-1.5"
aria-label={`${head.title} 정렬`}
onClick={() => {
if (sort?.key !== head.id) {
setSort({ key: head.id, direction: "asc" });
setSummaryCurrentPage(1);
return;
}
if (sort.direction === "asc") {
setSort({ key: head.id, direction: "desc" });
setSummaryCurrentPage(1);
return;
}
setSort(undefined);
setSummaryCurrentPage(1);
}}
>
<span>{head.title}</span>
{sort?.key === head.id ? (
<span
className="inline-flex min-w-2 justify-center text-[8px] leading-none text-[#1d4ed8]"
aria-hidden="true"
>
{sort.direction === "asc" ? "▲" : "▼"}
</span>
) : (
<span
className="inline-flex flex-col items-center gap-px text-[6px] leading-none text-[#475569]"
aria-hidden="true"
>
<span>▲</span>
<span>▼</span>
</span>
)}
</button>
)}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody className="text-center">
{yearMaterialGroups.length > 0 ? (
yearMaterialGroups.flatMap((group) =>
{visibleYearMaterialGroups.length > 0 ? (
visibleYearMaterialGroups.flatMap((group) =>
group.rows.map((yearRow, rowIndex) => (
<TableRow key={`${group.base.ctpvCd}-${group.base.sggCd}-${group.base.swtrmtZoneNm}-${yearRow.elapsedYearBucket}`}>
{rowIndex === 0 ? (
@@ -467,7 +647,7 @@ export default function PipeAnalysisPopupPage() {
<TableCell
key={`${group.base.ctpvCd}-${group.base.sggCd}-${group.base.swtrmtZoneNm}-${yearRow.elapsedYearBucket}-${column.key}`}
className={`border border-border px-2 py-[1.2rem] text-right text-[1.3rem] ${
isClickable ? "cursor-pointer font-semibold text-blue-700 underline underline-offset-2" : ""
isClickable ? "cursor-pointer font-semibold text-primary underline underline-offset-2" : ""
}`}
onClick={() => {
if (isClickable) {
@@ -484,7 +664,7 @@ export default function PipeAnalysisPopupPage() {
)
) : (
<TableRow>
<TableCell colSpan={headList.length} className="h-24 border border-border text-center text-[1.3rem] text-gray-500">
<TableCell colSpan={headList.length} className="h-24 border border-border text-center text-[1.3rem] text-label">
조회 결과가 없습니다.
</TableCell>
</TableRow>
@@ -492,6 +672,16 @@ export default function PipeAnalysisPopupPage() {
</TableBody>
</Table>
</div>
{orderedYearMaterialGroups.length > PIPE_ANALYSIS_SUMMARY_PAGE_SIZE ? (
<div className="flex justify-center">
<UiPageNations
total={orderedYearMaterialGroups.length}
cur={summaryCurrentPage}
pageSize={PIPE_ANALYSIS_SUMMARY_PAGE_SIZE}
onPageChange={setSummaryCurrentPage}
/>
</div>
) : null}
</>
) : null}
@@ -503,17 +693,27 @@ export default function PipeAnalysisPopupPage() {
: ""}
headName={
appliedFilters.analysisType === "YEAR_MATERIAL"
? "경과년도&재질 관로현황"
: "관로분석현황"
? `경과년도&재질 관로현황 ${summaryTotalCount.toLocaleString("ko-KR")}건`
: `관로분석현황 ${summaryTotalCount.toLocaleString("ko-KR")}건`
}
tableData={rows}
tableData={pagedRows}
headlist={headList}
pageSize={Math.max(rows.length, 1)}
pageSize={PIPE_ANALYSIS_SUMMARY_PAGE_SIZE}
total={rows.length}
cellClick={(index, columnId) => handleCellClick(index, columnId)}
onPageChange={() => undefined}
curPage={1}
cellClick={(index, columnId) =>
handleCellClick(
typeof index === "number" ? index + summaryPageOffset : index,
columnId,
)}
onPageChange={setSummaryCurrentPage}
curPage={summaryCurrentPage}
widthFixed={!useHorizontalScrollTable}
isHideCount={true}
sort={sort}
onSortChange={(nextSort) => {
setSort(nextSort);
setSummaryCurrentPage(1);
}}
cellClassName={(row, columnId) =>
!row.__isNoZone && row.__cellFilters[columnId]
? "cursor-pointer font-semibold text-blue-700 underline underline-offset-2"