그리드 css 통합

This commit is contained in:
2026-09-10 13:51:42 +09:00
parent 76130caa6d
commit 44361af392
4 changed files with 392 additions and 209 deletions
@@ -1,6 +1,6 @@
"use client";
import { Fragment, useMemo, type CSSProperties, type ReactNode } from "react";
import { useMemo, type ReactNode } from "react";
import {
getCoreRowModel,
useReactTable,
@@ -8,24 +8,28 @@ import {
} from "@tanstack/react-table";
import {
StandardDataTable,
type StandardDataTableLayout,
type UiHeadProps,
} from "@common/business_components";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
import type {
PipeAnalysisSummaryViewRow,
PipeAnalysisYearMaterialGroup,
PipeAnalysisYearMaterialGroupRow,
} from "./pipeAnalysisUtils";
import "./pipe-analysis-summary-table.css";
type SummaryRow = PipeAnalysisSummaryViewRow | PipeAnalysisYearMaterialGroup;
type SummaryRow =
| {
kind: "flat";
value: PipeAnalysisSummaryViewRow;
sourceIndex: number;
}
| {
kind: "grouped";
group: PipeAnalysisYearMaterialGroup;
value: PipeAnalysisYearMaterialGroupRow;
groupIndex: number;
groupRowIndex: number;
};
type Props = {
rows: PipeAnalysisSummaryViewRow[];
groups: PipeAnalysisYearMaterialGroup[];
@@ -53,6 +57,16 @@ const FIXED_WIDTHS: Record<string, number> = {
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 "-";
@@ -61,6 +75,24 @@ const formatValue = (value: unknown) => {
: 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,
@@ -76,15 +108,91 @@ export default function PipeAnalysisSummaryTable({
onCellClick,
onGroupedCellClick,
}: Props) {
// 표준 페이징이 기존 행 또는 처리구역 그룹 수를 기준으로 동작하도록 연결한다.
// 병합 표도 공통 렌더러가 행을 순회할 수 있도록 처리구역의 4개 경과년도 행만 펼친다.
const data = useMemo<SummaryRow[]>(
() => (grouped ? groups : rows),
() =>
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],
);
// 실제 셀은 renderTable에서 그리며 표준 테이블에는 열 식별자를 등록한다.
// 공통 렌더러가 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>[]>(
() => heads.map((head) => ({ id: head.id })),
[heads],
() =>
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 };
@@ -95,6 +203,10 @@ export default function PipeAnalysisSummaryTable({
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;
@@ -102,51 +214,6 @@ export default function PipeAnalysisSummaryTable({
onPageChange(next.pageIndex + 1);
},
});
// 열별 픽셀 너비와 왼쪽 고정 위치를 한곳에서 계산해 헤더·본문을 일치시킨다.
const layout = useMemo(() => {
let left = 0;
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;
const style: CSSProperties = fixed ? { left } : {};
if (fixed) left += width;
return { ...head, width, fixed, style };
});
}, [heads]);
// 모든 재질 열의 너비 합계를 최소 폭으로 사용해 좁은 팝업에서 열 압축을 막는다.
const tableWidth = layout.reduce((sum, head) => sum + head.width, 0);
/** 실제 결과가 있는 수치만 기존 상세 조회 핸들러로 연결한다. */
const cell = (
head: (typeof layout)[number],
value: unknown,
onClick?: () => void,
rowSpan?: number,
) => (
<TableCell
key={head.id}
rowSpan={rowSpan}
style={head.style}
className={`${head.fixed ? "pipe-summary-fixed" : ""} ${head.id === "totalLengthKm" ? "pipe-summary-fixed-end" : ""}`}
align={head.align === "right" ? "right" : "center"}
>
{onClick ? (
<button type="button" className="pipe-summary-value" onClick={onClick}>
{formatValue(value)}
</button>
) : (
<span className="pipe-summary-text" title={formatValue(value)}>
{formatValue(value)}
</span>
)}
</TableCell>
);
return (
<StandardDataTable
@@ -154,99 +221,29 @@ export default function PipeAnalysisSummaryTable({
table={table}
title={title}
flushOuterMargin
className="sn-pipe-summary-grid"
scrollMode="both"
maxHeight="calc(100dvh - 34rem)"
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}
renderTable={() => (
<Table
variant="default"
style={{
width: `max(100%, ${tableWidth}px)`,
minWidth: tableWidth,
tableLayout: "fixed",
}}
>
<colgroup>
{layout.map((head) => (
<col key={head.id} style={{ width: head.width }} />
))}
</colgroup>
<TableHeader>
<TableRow>
{layout.map((head) => (
<TableHead
key={head.id}
scope="col"
style={head.style}
className={`${head.fixed ? "pipe-summary-fixed" : ""} ${head.id === "totalLengthKm" ? "pipe-summary-fixed-end" : ""}`}
>
<span title={String(head.title)}>{head.title}</span>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{grouped
? groups.map((group, groupIndex) => (
<Fragment
key={`${group.base.ctpvCd}-${group.base.sggCd}-${group.base.swtrmtZoneNm}-${groupIndex}`}
>
{group.rows.map((row, rowIndex) => (
<TableRow
key={row.elapsedYearBucket}
className={
rowIndex === 0 ? "pipe-summary-group-start" : ""
}
>
{layout.map((head) => {
if (head.fixed) {
if (rowIndex !== 0) return null;
return cell(
head,
group[
head.id as keyof PipeAnalysisYearMaterialGroup
],
undefined,
group.rows.length,
);
}
if (head.id === "elapsedYearLabel")
return cell(head, row.elapsedYearLabel);
const value = Number(row.metrics[head.id] ?? 0);
return cell(
head,
value,
value > 0 && row.cellFilters[head.id]
? () => onGroupedCellClick(group, row, head.id)
: undefined,
);
})}
</TableRow>
))}
</Fragment>
))
: rows.map((row, index) => (
<TableRow
key={`${row.__base.ctpvCd}-${row.__base.sggCd}-${row.__base.swtrmtZoneNm}-${index}`}
>
{layout.map((head) =>
cell(
head,
head.id === "rowNo"
? total - ((page - 1) * pageSize + index)
: row[head.id],
row.__cellFilters[head.id] && Number(row[head.id]) > 0
? () => onCellClick(row, head.id)
: undefined,
),
)}
</TableRow>
))}
</TableBody>
</Table>
)}
/>
);
}
@@ -1,54 +0,0 @@
/* 관로 집계 전용: 퍼센트 열 압축과 중첩 가로 스크롤 없이 병합 셀을 유지한다. */
.sn-pipe-summary-grid .standard-data-table__scroll {
overflow: auto;
max-height: max(240px, calc(100dvh - 34rem));
overscroll-behavior: contain;
}
.sn-pipe-summary-grid .table-ui {
border-collapse: separate;
border-spacing: 0;
}
.sn-pipe-summary-grid .table-ui thead {
position: sticky;
top: 0;
z-index: 4;
}
.sn-pipe-summary-grid .table-ui th {
height: 64px;
word-break: keep-all;
overflow-wrap: anywhere;
white-space: normal;
}
.sn-pipe-summary-grid .table-ui td {
white-space: nowrap;
}
.sn-pipe-summary-grid .pipe-summary-fixed {
position: sticky;
z-index: 2;
}
.sn-pipe-summary-grid td.pipe-summary-fixed {
background: var(--background, #fff);
}
.sn-pipe-summary-grid th.pipe-summary-fixed {
z-index: 5;
}
.sn-pipe-summary-grid .pipe-summary-text {
display: block;
overflow: hidden;
text-overflow: ellipsis;
}
.sn-pipe-summary-grid .pipe-summary-value {
width: 100%;
font: inherit;
text-align: inherit;
color: var(--primary);
text-decoration: underline;
text-underline-offset: 2px;
}
.sn-pipe-summary-grid .pipe-summary-value:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.sn-pipe-summary-grid .pipe-summary-group-start td {
border-top: 1px solid var(--border, #ddd);
}
@@ -10,13 +10,24 @@ import {
type ReactNode,
type UIEventHandler,
} from "react";
import type { Table as TableInstance } from "@tanstack/react-table";
import {
flexRender,
type Cell,
type Row,
type Table as TableInstance,
} from "@tanstack/react-table";
import { ChevronLeft, ChevronRight } from "lucide-react";
import {
Button,
DataTable,
LoadingWithAnimation,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
cn,
} from "@common/components";
@@ -26,6 +37,28 @@ type StandardDataTableRenderContext<T> = {
table: TableInstance<T>;
};
type StandardDataTableLayout = {
tableLayout?: "auto" | "fixed";
stickyHeader?: boolean;
headerHeight?: string;
headerWrap?: boolean;
cellOverflow?: "wrap" | "nowrap" | "ellipsis";
};
type StandardDataTableCellContext<T> = {
row: Row<T>;
cell: Cell<T, unknown>;
rowIndex: number;
columnId: string;
};
type StandardDataTableCellProps = {
hidden?: boolean;
rowSpan?: number;
className?: string;
style?: CSSProperties;
};
type StandardDataTableProps<T> = {
table: TableInstance<T>;
title?: ReactNode;
@@ -47,12 +80,25 @@ type StandardDataTableProps<T> = {
renderTable?: (context: StandardDataTableRenderContext<T>) => ReactNode;
/** 고정 열 경계처럼 가로 스크롤 위치가 필요한 소비 화면에 공통 스크롤 이벤트를 전달한다. */
onScroll?: UIEventHandler<HTMLDivElement>;
/** 표 레이아웃·고정 헤더·셀 넘침처럼 업무와 무관한 공통 표시 방식을 선택한다. */
layout?: StandardDataTableLayout;
/** 왼쪽에 고정할 열 ID이며 실제 left 위치는 등록된 열 너비를 기준으로 계산한다. */
pinnedColumns?: string[];
/** 병합 셀처럼 행별 HTML 셀 속성이 필요한 복합표의 표시 계약만 전달한다. */
getCellProps?: (
context: StandardDataTableCellContext<T>,
) => StandardDataTableCellProps | undefined;
onRowClick?: (row: T) => void;
};
type StandardDataTableStyle = CSSProperties & {
"--standard-data-table-min-width"?: string;
"--standard-data-table-max-height"?: string;
"--standard-data-table-header-height"?: string;
};
type StandardPinnedCellStyle = CSSProperties & {
"--standard-data-table-pinned-left"?: string;
};
const DEFAULT_PAGINATION_MARGIN_TOP = 24;
@@ -179,6 +225,129 @@ function StandardDataTablePagination<T>({
);
}
/**
* 고정 열과 행별 병합 속성이 필요한 표를 공통 Table 마크업으로 렌더링한다.
* 일반 표는 기존 DataTable 경로를 유지해 확장 옵션을 사용하지 않는 화면의 동작을 바꾸지 않는다.
*/
function StandardDataTableRenderer<T>({
table,
variant,
layout,
pinnedColumns,
getCellProps,
onRowClick,
}: {
table: TableInstance<T>;
variant: "default" | "secondary" | "line";
layout?: StandardDataTableLayout;
pinnedColumns: string[];
getCellProps?: (
context: StandardDataTableCellContext<T>,
) => StandardDataTableCellProps | undefined;
onRowClick?: (row: T) => void;
}) {
const pinnedColumnIds = new Set(pinnedColumns);
const pinnedLeftByColumnId = new Map<string, number>();
let pinnedLeft = 0;
table.getVisibleLeafColumns().forEach((column) => {
if (!pinnedColumnIds.has(column.id)) return;
pinnedLeftByColumnId.set(column.id, pinnedLeft);
pinnedLeft += column.getSize();
});
/** 열 ID에 대응하는 공통 고정 위치를 헤더와 본문에 동일하게 전달한다. */
const getPinnedProps = (columnId: string) => {
const left = pinnedLeftByColumnId.get(columnId);
if (left === undefined) return { pinned: undefined, style: undefined };
const style: StandardPinnedCellStyle = {
"--standard-data-table-pinned-left": `${left}px`,
};
return { pinned: "left", style };
};
return (
<Table variant={variant} style={{ tableLayout: layout?.tableLayout }}>
<colgroup>
{table.getVisibleLeafColumns().map((column) => (
<col key={column.id} style={{ width: column.getSize() }} />
))}
</colgroup>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const pinned = getPinnedProps(header.column.id);
return (
<TableHead
key={header.id}
colSpan={header.colSpan}
data-pinned={pinned.pinned}
style={pinned.style}
align={header.column.columnDef.meta?.align}
>
<div className="standard-data-table__header-content">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</div>
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
onClick={() => onRowClick?.(row.original)}
className={cn(
onRowClick && "cursor-pointer hover:bg-accent",
row.getIsSelected() && "!bg-primary-transparent",
)}
>
{row.getVisibleCells().map((cell) => {
const presentation = getCellProps?.({
row,
cell,
rowIndex: row.index,
columnId: cell.column.id,
});
if (presentation?.hidden) return null;
const pinned = getPinnedProps(cell.column.id);
const style: StandardPinnedCellStyle = {
...presentation?.style,
...pinned.style,
};
return (
<TableCell
key={cell.id}
rowSpan={presentation?.rowSpan}
className={presentation?.className}
data-pinned={pinned.pinned}
style={style}
align={cell.column.columnDef.meta?.align}
>
<div className="standard-data-table__cell-content">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
</TableCell>
);
})}
</TableRow>
))}
</TableBody>
</Table>
);
}
function StandardDataTable<T>({
table,
title,
@@ -196,6 +365,9 @@ function StandardDataTable<T>({
fillAvailableHeight = false,
renderTable,
onScroll,
layout,
pinnedColumns = [],
getCellProps,
onRowClick,
}: StandardDataTableProps<T>) {
const rowCount = table.getRowModel().rows.length;
@@ -214,6 +386,12 @@ function StandardDataTable<T>({
"--standard-data-table-min-width": minTableWidth,
"--standard-data-table-max-height": maxHeight,
};
const layoutStyle: StandardDataTableStyle = {
"--standard-data-table-header-height": layout?.headerHeight,
};
const usesExtendedRenderer = Boolean(
layout || pinnedColumns.length > 0 || getCellProps,
);
/** 실제 가로 스크롤바가 차지하는 높이만큼 페이징 여백을 줄여 CS의 24px 체감 간격을 유지한다. */
useEffect(() => {
@@ -253,11 +431,16 @@ function StandardDataTable<T>({
return (
<div
className={cn(
"standard-data-table card card-border w-full max-w-full min-w-0 overflow-hidden",
"standard-data-table card card-border w-full min-w-0 max-w-full overflow-hidden",
flushOuterMargin && "!m-0",
shouldFillAvailableHeight ? "flex min-h-0 flex-1 flex-col" : "shrink-0",
className,
)}
data-sticky-header={layout?.stickyHeader ? "true" : undefined}
data-header-wrap={layout?.headerWrap ? "true" : undefined}
data-cell-overflow={layout?.cellOverflow}
data-has-pinned-columns={pinnedColumns.length > 0 ? "true" : undefined}
style={layoutStyle}
>
{title || actions ? (
<div className="card-header flex flex-wrap items-center justify-between gap-4">
@@ -310,7 +493,17 @@ function StandardDataTable<T>({
)}
</div>
) : (
(renderTable?.({ table }) ?? (
(renderTable?.({ table }) ??
(usesExtendedRenderer ? (
<StandardDataTableRenderer
table={table}
variant={variant}
layout={layout}
pinnedColumns={pinnedColumns}
getCellProps={getCellProps}
onRowClick={onRowClick}
/>
) : (
<DataTable
table={table}
variant={variant}
@@ -318,7 +511,7 @@ function StandardDataTable<T>({
viewPagination={false}
onRowClick={onRowClick}
/>
))
)))
)}
</div>
{viewPagination && !loading && rowCount > 0 ? (
@@ -335,6 +528,9 @@ function StandardDataTable<T>({
export { STANDARD_ROW_NUMBER_HEADER, StandardDataTable, getStandardRowNumber };
export type {
ScrollMode,
StandardDataTableCellContext,
StandardDataTableCellProps,
StandardDataTableLayout,
StandardDataTableProps,
StandardDataTableRenderContext,
StandardRowNumberParams,
@@ -20,4 +20,48 @@
.standard-data-table__scroll thead th {
text-align: center;
}
.standard-data-table[data-has-pinned-columns="true"] .table-ui {
border-collapse: separate;
border-spacing: 0;
}
.standard-data-table[data-sticky-header="true"] .table-ui thead {
position: sticky;
top: 0;
z-index: 4;
}
.standard-data-table[data-header-wrap="true"] .table-ui th {
height: var(--standard-data-table-header-height, auto);
overflow-wrap: anywhere;
white-space: normal;
word-break: keep-all;
}
.standard-data-table[data-cell-overflow="nowrap"] .table-ui td,
.standard-data-table[data-cell-overflow="ellipsis"] .table-ui td {
white-space: nowrap;
}
.standard-data-table[data-cell-overflow="ellipsis"]
.standard-data-table__cell-content {
display: block;
overflow: hidden;
text-overflow: ellipsis;
}
.standard-data-table .table-ui [data-pinned="left"] {
left: var(--standard-data-table-pinned-left);
position: sticky;
z-index: 2;
}
.standard-data-table .table-ui td[data-pinned="left"] {
background: var(--background, #fff);
}
.standard-data-table .table-ui th[data-pinned="left"] {
z-index: 5;
}
}