diff --git a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisSummaryTable.tsx b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisSummaryTable.tsx index 79152a913..841d8ac0e 100644 --- a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisSummaryTable.tsx +++ b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisSummaryTable.tsx @@ -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 = { 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 ? ( + + ) : ( + + {formatted} + + ); +}; + /** 표준 카드·페이징 안에서 관로 집계의 병합 셀과 고정 열을 렌더링한다. */ export default function PipeAnalysisSummaryTable({ rows, @@ -76,15 +108,91 @@ export default function PipeAnalysisSummaryTable({ onCellClick, onGroupedCellClick, }: Props) { - // 표준 페이징이 기존 행 또는 처리구역 그룹 수를 기준으로 동작하도록 연결한다. + // 병합 표도 공통 렌더러가 행을 순회할 수 있도록 처리구역의 4개 경과년도 행만 펼친다. const data = useMemo( - () => (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[]>( - () => heads.map((head) => ({ id: head.id })), - [heads], + () => + layout.map((head) => ({ + id: head.id, + size: head.width, + minSize: head.width, + maxSize: head.width, + header: () => {head.title}, + 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, - ) => ( - - {onClick ? ( - - ) : ( - - {formatValue(value)} - - )} - - ); return ( { + 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={() => ( - - - {layout.map((head) => ( - - ))} - - - - {layout.map((head) => ( - - {head.title} - - ))} - - - - {grouped - ? groups.map((group, groupIndex) => ( - - {group.rows.map((row, rowIndex) => ( - - {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, - ); - })} - - ))} - - )) - : rows.map((row, 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, - ), - )} - - ))} - -
- )} /> ); } diff --git a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/pipe-analysis-summary-table.css b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/pipe-analysis-summary-table.css deleted file mode 100644 index 59b06bef0..000000000 --- a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/pipe-analysis-summary-table.css +++ /dev/null @@ -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); -} diff --git a/packages/common/business_components/src/ui/standard_data_table/index.tsx b/packages/common/business_components/src/ui/standard_data_table/index.tsx index f5f67714f..643d6c06a 100644 --- a/packages/common/business_components/src/ui/standard_data_table/index.tsx +++ b/packages/common/business_components/src/ui/standard_data_table/index.tsx @@ -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 = { table: TableInstance; }; +type StandardDataTableLayout = { + tableLayout?: "auto" | "fixed"; + stickyHeader?: boolean; + headerHeight?: string; + headerWrap?: boolean; + cellOverflow?: "wrap" | "nowrap" | "ellipsis"; +}; + +type StandardDataTableCellContext = { + row: Row; + cell: Cell; + rowIndex: number; + columnId: string; +}; + +type StandardDataTableCellProps = { + hidden?: boolean; + rowSpan?: number; + className?: string; + style?: CSSProperties; +}; + type StandardDataTableProps = { table: TableInstance; title?: ReactNode; @@ -47,12 +80,25 @@ type StandardDataTableProps = { renderTable?: (context: StandardDataTableRenderContext) => ReactNode; /** 고정 열 경계처럼 가로 스크롤 위치가 필요한 소비 화면에 공통 스크롤 이벤트를 전달한다. */ onScroll?: UIEventHandler; + /** 표 레이아웃·고정 헤더·셀 넘침처럼 업무와 무관한 공통 표시 방식을 선택한다. */ + layout?: StandardDataTableLayout; + /** 왼쪽에 고정할 열 ID이며 실제 left 위치는 등록된 열 너비를 기준으로 계산한다. */ + pinnedColumns?: string[]; + /** 병합 셀처럼 행별 HTML 셀 속성이 필요한 복합표의 표시 계약만 전달한다. */ + getCellProps?: ( + context: StandardDataTableCellContext, + ) => 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({ ); } +/** + * 고정 열과 행별 병합 속성이 필요한 표를 공통 Table 마크업으로 렌더링한다. + * 일반 표는 기존 DataTable 경로를 유지해 확장 옵션을 사용하지 않는 화면의 동작을 바꾸지 않는다. + */ +function StandardDataTableRenderer({ + table, + variant, + layout, + pinnedColumns, + getCellProps, + onRowClick, +}: { + table: TableInstance; + variant: "default" | "secondary" | "line"; + layout?: StandardDataTableLayout; + pinnedColumns: string[]; + getCellProps?: ( + context: StandardDataTableCellContext, + ) => StandardDataTableCellProps | undefined; + onRowClick?: (row: T) => void; +}) { + const pinnedColumnIds = new Set(pinnedColumns); + const pinnedLeftByColumnId = new Map(); + 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.getVisibleLeafColumns().map((column) => ( + + ))} + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const pinned = getPinnedProps(header.column.id); + return ( + +
+ {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} +
+
+ ); + })} +
+ ))} +
+ + {table.getRowModel().rows.map((row) => ( + 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 ( + +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ ); + })} +
+ ))} +
+
+ ); +} + function StandardDataTable({ table, title, @@ -196,6 +365,9 @@ function StandardDataTable({ fillAvailableHeight = false, renderTable, onScroll, + layout, + pinnedColumns = [], + getCellProps, onRowClick, }: StandardDataTableProps) { const rowCount = table.getRowModel().rows.length; @@ -214,6 +386,12 @@ function StandardDataTable({ "--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({ return (
0 ? "true" : undefined} + style={layoutStyle} > {title || actions ? (
@@ -310,7 +493,17 @@ function StandardDataTable({ )}
) : ( - (renderTable?.({ table }) ?? ( + (renderTable?.({ table }) ?? + (usesExtendedRenderer ? ( + + ) : ( ({ viewPagination={false} onRowClick={onRowClick} /> - )) + ))) )}
{viewPagination && !loading && rowCount > 0 ? ( @@ -335,6 +528,9 @@ function StandardDataTable({ export { STANDARD_ROW_NUMBER_HEADER, StandardDataTable, getStandardRowNumber }; export type { ScrollMode, + StandardDataTableCellContext, + StandardDataTableCellProps, + StandardDataTableLayout, StandardDataTableProps, StandardDataTableRenderContext, StandardRowNumberParams, diff --git a/packages/common/business_components/src/ui/standard_data_table/standard-data-table.css b/packages/common/business_components/src/ui/standard_data_table/standard-data-table.css index ec5c00cbb..ea62563df 100644 --- a/packages/common/business_components/src/ui/standard_data_table/standard-data-table.css +++ b/packages/common/business_components/src/ui/standard_data_table/standard-data-table.css @@ -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; + } }