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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user