Files
frontend/apps/sn/app/(map-layout)/map/components/pipe-analysis/pipe-analysis-summary-table.tsx
T

250 lines
7.9 KiB
TypeScript

"use client";
import { useMemo, type ReactNode } from "react";
import {
getCoreRowModel,
useReactTable,
type ColumnDef,
} from "@tanstack/react-table";
import {
StandardDataTable,
type StandardDataTableLayout,
type UiHeadProps,
} from "@common/business_components";
import type {
PipeAnalysisSummaryViewRow,
PipeAnalysisYearMaterialGroup,
PipeAnalysisYearMaterialGroupRow,
} from "./pipe-analysis-utils";
type SummaryRow =
| {
kind: "flat";
value: PipeAnalysisSummaryViewRow;
sourceIndex: number;
}
| {
kind: "grouped";
group: PipeAnalysisYearMaterialGroup;
value: PipeAnalysisYearMaterialGroupRow;
groupIndex: number;
groupRowIndex: number;
};
type Props = {
rows: PipeAnalysisSummaryViewRow[];
groups: PipeAnalysisYearMaterialGroup[];
grouped: boolean;
heads: UiHeadProps[];
title: string;
total: number;
page: number;
pageSize: number;
actions: ReactNode;
onPageChange: (page: number) => void;
onCellClick: (row: PipeAnalysisSummaryViewRow, columnId: string) => void;
onGroupedCellClick: (
group: PipeAnalysisYearMaterialGroup,
row: PipeAnalysisYearMaterialGroupRow,
columnId: string,
) => void;
};
/** 지역 식별 열은 가로 스크롤해도 남겨 두고, 모든 집계 방식에 같은 너비를 사용한다. */
const FIXED_WIDTHS: Record<string, number> = {
rowNo: 56,
ctpvNm: 120,
sggNm: 120,
swtrmtZoneNm: 160,
totalLengthKm: 100,
};
/** 공통 그리드가 같은 순서로 왼쪽 고정 위치를 계산할 관로 식별 열이다. */
const PINNED_COLUMN_IDS = Object.keys(FIXED_WIDTHS);
/** 관로분석에서 선택하는 공통 표 레이아웃이며 화면 전용 CSS를 대체한다. */
const PIPE_SUMMARY_LAYOUT: StandardDataTableLayout = {
tableLayout: "fixed",
stickyHeader: true,
headerHeight: "64px",
headerWrap: true,
cellOverflow: "ellipsis",
};
/** 기존 집계와 같은 천 단위 구분 및 소수점 세 자리 표시를 유지한다. */
const formatValue = (value: unknown) => {
if (value === null || value === undefined || value === "") return "-";
return typeof value === "number"
? value.toLocaleString("ko-KR", { maximumFractionDigits: 3 })
: String(value);
};
/** 결과가 있는 셀만 링크형 버튼으로 표시하고 나머지는 전체값 title을 유지한다. */
const renderValue = (value: unknown, onClick?: () => void) => {
const formatted = formatValue(value);
return onClick ? (
<button
type="button"
className="w-full text-inherit text-primary underline underline-offset-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
onClick={onClick}
>
{formatted}
</button>
) : (
<span className="block overflow-hidden text-ellipsis" title={formatted}>
{formatted}
</span>
);
};
/** 표준 카드·페이징 안에서 관로 집계의 병합 셀과 고정 열을 렌더링한다. */
export default function PipeAnalysisSummaryTable({
rows,
groups,
grouped,
heads,
title,
total,
page,
pageSize,
actions,
onPageChange,
onCellClick,
onGroupedCellClick,
}: Props) {
// 병합 표도 공통 렌더러가 행을 순회할 수 있도록 처리구역의 4개 경과년도 행만 펼친다.
const data = useMemo<SummaryRow[]>(
() =>
grouped
? groups.flatMap((group, groupIndex) =>
group.rows.map((value, groupRowIndex) => ({
kind: "grouped" as const,
group,
value,
groupIndex,
groupRowIndex,
})),
)
: rows.map((value, sourceIndex) => ({
kind: "flat" as const,
value,
sourceIndex,
})),
[grouped, groups, rows],
);
// 공통 렌더러가 colgroup과 고정 위치에 사용할 열별 픽셀 너비를 한곳에서 계산한다.
const layout = useMemo(() => {
return heads.map((head) => {
const width =
FIXED_WIDTHS[head.id] ??
(head.id === "elapsedYearLabel"
? 112
: String(head.title).length > 10
? 160
: 120);
const fixed = head.id in FIXED_WIDTHS;
return { ...head, width, fixed };
});
}, [heads]);
// 모든 재질 열의 너비 합계를 최소 폭으로 사용해 좁은 팝업에서 열 압축을 막는다.
const tableWidth = layout.reduce((sum, head) => sum + head.width, 0);
// 열은 값·업무 클릭만 정의하고 실제 th/td, 고정 열과 overflow는 공통 렌더러가 소유한다.
const columns = useMemo<ColumnDef<SummaryRow>[]>(
() =>
layout.map((head) => ({
id: head.id,
size: head.width,
minSize: head.width,
maxSize: head.width,
header: () => <span title={String(head.title)}>{head.title}</span>,
cell: ({ row }) => {
const summaryRow = row.original;
if (summaryRow.kind === "flat") {
const value =
head.id === "rowNo"
? (page - 1) * pageSize + summaryRow.sourceIndex + 1
: summaryRow.value[head.id];
const onClick =
summaryRow.value.__cellFilters[head.id] && Number(value) > 0
? () => onCellClick(summaryRow.value, head.id)
: undefined;
return renderValue(value, onClick);
}
if (head.fixed) {
return renderValue(
summaryRow.group[head.id as keyof PipeAnalysisYearMaterialGroup],
);
}
if (head.id === "elapsedYearLabel") {
return renderValue(summaryRow.value.elapsedYearLabel);
}
const value = Number(summaryRow.value.metrics[head.id] ?? 0);
const onClick =
value > 0 && summaryRow.value.cellFilters[head.id]
? () =>
onGroupedCellClick(
summaryRow.group,
summaryRow.value,
head.id,
)
: undefined;
return renderValue(value, onClick);
},
meta: {
align: head.align === "right" ? "right" : "center",
},
})),
[layout, onCellClick, onGroupedCellClick, page, pageSize, total],
);
// 페이지 단위는 기존과 동일하게 일반 표는 행, 병합 표는 처리구역 그룹이다.
const pagination = { pageIndex: Math.max(0, page - 1), pageSize };
const table = useReactTable({
data,
columns,
state: { pagination },
rowCount: total,
manualPagination: true,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) =>
row.kind === "flat"
? `flat-${row.sourceIndex}`
: `group-${row.groupIndex}-${row.value.elapsedYearBucket}`,
onPaginationChange: (updater) => {
const next =
typeof updater === "function" ? updater(pagination) : updater;
if (next.pageIndex !== pagination.pageIndex)
onPageChange(next.pageIndex + 1);
},
});
return (
<StandardDataTable
key={`${title}-${page}`}
table={table}
title={title}
flushOuterMargin
scrollMode="both"
maxHeight="max(240px, calc(100dvh - 34rem))"
minTableWidth={`${tableWidth}px`}
layout={PIPE_SUMMARY_LAYOUT}
pinnedColumns={PINNED_COLUMN_IDS}
getCellProps={({ row, columnId }) => {
const summaryRow = row.original;
const isPinnedColumn = PINNED_COLUMN_IDS.includes(columnId);
const isGrouped = summaryRow.kind === "grouped";
return {
hidden: isGrouped && isPinnedColumn && summaryRow.groupRowIndex > 0,
rowSpan:
isGrouped && isPinnedColumn && summaryRow.groupRowIndex === 0
? summaryRow.group.rows.length
: undefined,
className:
isGrouped && summaryRow.groupRowIndex === 0
? "border-t border-border"
: undefined,
};
}}
actions={actions}
/>
);
}