250 lines
7.9 KiB
TypeScript
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 "./pipeAnalysisUtils";
|
|
|
|
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"
|
|
? total - ((page - 1) * pageSize + summaryRow.sourceIndex)
|
|
: 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}
|
|
/>
|
|
);
|
|
}
|