sn: 다단header 묶음 처리 외

This commit is contained in:
2026-09-07 16:56:35 +09:00
parent aa5a71ae68
commit 479cf5c4a0
5 changed files with 192 additions and 76 deletions
@@ -2,14 +2,23 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Input } from "@common/components";
import { StandardPopupLayout } from "@common/business_components";
import {
StandardDataTable,
StandardPopupLayout,
} from "@common/business_components";
import {
getCoreRowModel,
getPaginationRowModel,
useReactTable,
type ColumnDef,
type PaginationState,
} from "@tanstack/react-table";
import { getRawJson } from "@/components/lib/api";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { useAlertStore } from "@/shared/lib";
import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions";
import {
AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
buildAgedPipeStatusByCtpvHeadList,
buildAgedPipeStatusYearOptions,
getAgedPipeStatusByCtpvValidationMessage,
normalizeAgedPipeStatusByCtpvResponse,
@@ -20,7 +29,6 @@ import {
type AgedPipeStatusByCtpvRow,
} from "./agedPipeStatusByCtpvUtils";
import { downloadAgedPipeStatusByCtpvExcel } from "./agedPipeStatusByCtpvExport";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
// CS 목록과 동일하게 한 페이지에는 최대 10개 시도만 표시한다.
@@ -52,21 +60,48 @@ export default function AgedPipeStatusByCtpvPopupPage() {
const [currentPage, setCurrentPage] = useState(1);
const yearOptions = useMemo(() => buildAgedPipeStatusYearOptions(), []);
const headList = useMemo(
const columns = useMemo<ColumnDef<AgedPipeStatusByCtpvRow>[]>(
() => [
{ id: "rowNumber", title: "순번", widthPercent: 7 },
...buildAgedPipeStatusByCtpvHeadList(years),
{ accessorKey: "rowNumber", header: "순번" },
{ accessorKey: "ctpvNm", header: "시도" },
...years.map((year) => ({
id: `year_${year}`,
header: `${year}년`,
columns: [
{
accessorKey: `year${year}TotalLengthKm`,
header: "전체관로연장(km)",
},
{
accessorKey: `year${year}AgedLengthKm`,
header: "노후관로연장(km)",
},
{
accessorKey: `year${year}AgedRate`,
header: "노후화율(%)",
},
],
})),
],
[years],
);
const pageRows = useMemo(
() =>
rows.slice(
(currentPage - 1) * AGED_PIPE_STATUS_PAGE_SIZE,
currentPage * AGED_PIPE_STATUS_PAGE_SIZE,
),
[currentPage, rows],
);
const pagination: PaginationState = {
pageIndex: currentPage - 1,
pageSize: AGED_PIPE_STATUS_PAGE_SIZE,
};
const table = useReactTable({
data: rows,
columns,
state: { pagination },
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: (updater) => {
const next =
typeof updater === "function" ? updater(pagination) : updater;
setCurrentPage(next.pageIndex + 1);
},
});
const loadRows = useCallback(
async (nextFilters: AgedPipeStatusByCtpvFilters) => {
@@ -249,18 +284,12 @@ export default function AgedPipeStatusByCtpvPopupPage() {
</div>
</PopupSearchPanel>
<MapPopupDataTable
rows={
pageRows as Array<AgedPipeStatusByCtpvRow & Record<string, unknown>>
}
headlist={headList}
<StandardDataTable
table={table}
title="시도별 노후관로 목록"
total={rows.length}
page={currentPage}
pageSize={AGED_PIPE_STATUS_PAGE_SIZE}
onPageChange={setCurrentPage}
scrollMode={years.length > 2 ? "horizontal" : "none"}
minTableWidth={years.length > 2 ? "120rem" : "100%"}
scrollMode="horizontal"
minTableWidth={years.length > 0 ? "120rem" : "100%"}
actions={
<Button type="button" onClick={handleDownload}>
엑셀다운로드
@@ -1,32 +1,80 @@
"use client";
import { postBlobData } from "@/components/lib/api";
import { fileDownload } from "@/components/lib/utils/fileDownload";
import * as XLSX from "xlsx";
import { getRawJson } from "@/components/lib/api";
import {
getAgedPipeStatusByCtpvValidationMessage,
normalizeAgedPipeStatusByCtpvFilters,
type AgedPipeStatusByCtpvFilters,
type AgedPipeStatusByCtpvResponse,
} from "./agedPipeStatusByCtpvUtils";
/** 시도별 노후관로현황 XLSX 다운로드를 요청한다. */
/** 적용조건의 시도별 노후관로현황 전체를 조회해 연도 그룹 Excel로 생성한다. */
export const downloadAgedPipeStatusByCtpvExcel = async (
filters: AgedPipeStatusByCtpvFilters,
) => {
const validationMessage = getAgedPipeStatusByCtpvValidationMessage(filters);
if (validationMessage) throw new Error(validationMessage);
const normalizedFilters = normalizeAgedPipeStatusByCtpvFilters(filters);
const { blob, fileName } = await postBlobData(
"v1/swagePpl/aged-status-by-ctpv/exportJobs/download",
{
startYear: normalizedFilters.startYear,
endYear: normalizedFilters.endYear,
elapsedYears: normalizedFilters.elapsedYears,
format: "xlsx",
},
const normalized = normalizeAgedPipeStatusByCtpvFilters(filters);
const params = new URLSearchParams({
startYear: String(normalized.startYear),
endYear: String(normalized.endYear),
elapsedYears: String(normalized.elapsedYears),
});
const response: any = await getRawJson(
`v1/swagePpl/aged-status-by-ctpv?${params.toString()}`,
);
const payload = (response?.data ?? response) as AgedPipeStatusByCtpvResponse;
const years = Array.isArray(payload?.years)
? payload.years.map(Number).filter(Number.isFinite)
: [];
const rows = Array.isArray(payload?.rows) ? payload.rows : [];
if (rows.length === 0) throw new Error("다운로드할 데이터가 없습니다.");
const resolvedFileName = fileName || "시도별_노후관로현황.xlsx";
fileDownload(blob, resolvedFileName);
return resolvedFileName;
const headers = ["순번", "시도"];
years.forEach((year) => headers.push(`${year}년`, "", ""));
const subHeaders = ["", ""];
years.forEach(() =>
subHeaders.push("전체관로연장(km)", "노후관로연장(km)", "노후화율(%)"),
);
const data = rows.map((row, rowIndex) => {
const statsByYear = new Map(
(row.yearStats ?? []).map((stat) => [Number(stat.year), stat]),
);
const values: Array<string | number> = [rowIndex + 1, row.ctpvNm || "-"];
years.forEach((year) => {
const stat = statsByYear.get(year);
values.push(
stat ? Number(stat.totalLengthKm ?? 0) : "-",
stat ? Number(stat.agedLengthKm ?? 0) : "-",
stat ? Number(stat.agedRate ?? 0) : "-",
);
});
return values;
});
const worksheet = XLSX.utils.aoa_to_sheet([headers, subHeaders, ...data]);
worksheet["!merges"] = [
{ s: { r: 0, c: 0 }, e: { r: 1, c: 0 } },
{ s: { r: 0, c: 1 }, e: { r: 1, c: 1 } },
...years.map((_, index) => ({
s: { r: 0, c: 2 + index * 3 },
e: { r: 0, c: 4 + index * 3 },
})),
];
worksheet["!cols"] = [
{ wch: 8 },
{ wch: 18 },
...years.flatMap(() => [{ wch: 18 }, { wch: 18 }, { wch: 14 }]),
];
worksheet["!autofilter"] = {
ref: `A2:${XLSX.utils.encode_col(headers.length - 1)}${data.length + 2}`,
};
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "시도별 노후관로현황");
XLSX.writeFile(workbook, "시도별_노후관로현황.xlsx", {
bookType: "xlsx",
compression: true,
cellStyles: true,
});
};
@@ -1,5 +1,10 @@
# SFR-011-001 시도별 노후관로현황 FE 작업카드
## 유지보수 반영 (2026-09-04)
- 목록의 연도별 결과 컬럼을 연도 그룹 헤더로 묶어 표시한다.
- 엑셀 다운로드는 조회 결과를 프론트엔드에서 XLSX로 생성하며, 목록과 동일한 연도 그룹 헤더와 하위 컬럼을 사용한다.
## 1. 작업 목적
- 하수관망도 우측 분석도구에서 `시도별 노후관로현황` 팝업을 열고 기준년도 구간별 시도 집계를 조회한다.
@@ -7,7 +12,7 @@
- `AnalysisPopupMenu`에 진입 버튼 추가
- `windowPopup` registry에 새 popup key 추가
- 전용 popup page 구현
- 기준년도 시작/종료, 경과년도 기본값 30년, CSV 다운로드 연결
- 기준년도 시작/종료, 경과년도 기본값 30년, FE XLSX 다운로드 연결
## 3. 구현 기준
- 기존 `노후관로 현황` popup의 레이아웃 톤과 다운로드 UX를 최대한 재사용한다.
@@ -16,4 +21,4 @@
## 4. 연동 API
- `GET /api/v1/swagePpl/aged-status-by-ctpv`
- `POST /api/v1/swagePpl/aged-status-by-ctpv/exportJobs/download`
- 별도 백엔드 Excel API를 호출하지 않고 조회 응답으로 FE에서 XLSX 생성
@@ -77,47 +77,69 @@ function DataTable<T>({
function THead<T>({ table }: { table: TableInstance<T> }) {
return (
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const columnRelativeDepth = header.depth - header.column.depth;
if (
!header.isPlaceholder &&
columnRelativeDepth > 1 &&
header.id === header.column.id
) {
return null;
}
{table
.getHeaderGroups()
.map((headerGroup, headerGroupIndex, headerGroups) => (
<TableRow
key={headerGroup.id}
className={cn(
headerGroups.length > 1 &&
headerGroupIndex === 0 &&
"!border-b-0",
)}
>
{headerGroup.headers.map((header) => {
const columnRelativeDepth = header.depth - header.column.depth;
if (
!header.isPlaceholder &&
columnRelativeDepth > 1 &&
header.id === header.column.id
) {
return null;
}
let rowSpan = 1;
if (header.isPlaceholder) {
const leafs = header.getLeafHeaders();
rowSpan = (leafs?.[leafs.length - 1]?.depth ?? 0) - header.depth;
}
let rowSpan = 1;
if (header.isPlaceholder) {
const leafs = header.getLeafHeaders();
rowSpan =
(leafs?.[leafs.length - 1]?.depth ?? 0) - header.depth;
}
const { meta } = header.column.columnDef;
const { meta } = header.column.columnDef;
return (
<TableHead
key={header.id}
className="sticky top-0 z-10"
colSpan={header.colSpan}
rowSpan={rowSpan}
{...meta}
>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
const isNestedGroupHeader =
!header.isPlaceholder && hasNestedColumns(header.column);
return (
<TableHead
key={header.id}
className={cn(
"sticky top-0 z-10",
isNestedGroupHeader && "data-table-group-header",
)}
colSpan={header.colSpan}
rowSpan={rowSpan}
{...meta}
>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
);
}
function hasNestedColumns<T>(
column: import("@tanstack/react-table").Column<T, unknown>,
) {
return column.columns.length > 0;
}
function Pagination<T>({ table }: { table: TableInstance<T> }) {
const pageCount = table.getPageCount();
const currentPage = table.getState().pagination.pageIndex;
@@ -18,6 +18,18 @@
.table-ui.default th {
@apply bg-[#5987bf] text-[#fff];
}
.table-ui th.data-table-group-header {
background-image: linear-gradient(
to right,
transparent 0.2rem,
var(--background) 0.2rem,
var(--background) calc(100% - 0.2rem),
transparent calc(100% - 0.2rem)
);
background-position: left bottom;
background-repeat: no-repeat;
background-size: 100% 1px;
}
.table-ui.default td {
@apply border-b border-b-border text-label;
}