sn: 다단header 묶음 처리 외
This commit is contained in:
+53
-24
@@ -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}>
|
||||
엑셀다운로드
|
||||
|
||||
+64
-16
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user