Files
invyone/frontend/lib/registry/components/table/_shared/SingleTableWithSticky.tsx
T
DDD1542 7d204bfffd
Build & Deploy to K8s / build-and-deploy (push) Failing after 14m3s
refactor: complete canonical table cleanup
2026-05-21 11:55:08 +09:00

392 lines
17 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/**
* SingleTableWithSticky — shared table renderer
*
* 2026-05-19 FlowWidget 의 sticky 테이블 구현을 흡수한 헬퍼.
* 현재는 `frontend/components/screen/widgets/FlowWidget.tsx` 에서만 사용한다.
*
* 본 파일은 자체 minimal `ColumnConfig` 를 export 하며 외부 타입을 의존하지 않는다.
* canonical table runtime 은 `frontend/lib/registry/components/table/TableComponent.tsx` 사용.
*/
import React from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { ArrowUp, ArrowDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
/**
* SingleTableWithSticky 가 실제 사용하는 컬럼 메타 minimal 정의.
*/
export interface ColumnConfig {
columnName: string;
displayName?: string;
sortable?: boolean;
align?: "left" | "center" | "right";
format?: string;
fixed?: "left" | "right" | false;
hidden?: boolean;
width?: number;
langKey?: string;
[key: string]: any;
}
interface SingleTableWithStickyProps<TColumn extends ColumnConfig = ColumnConfig> {
visibleColumns?: TColumn[];
columns?: TColumn[];
data: Record<string, any>[];
columnLabels: Record<string, string>;
sortColumn: string | null;
sortDirection: "asc" | "desc";
tableConfig?: any;
isDesignMode?: boolean;
isAllSelected?: boolean;
handleSort?: (columnName: string) => void;
onSort?: (columnName: string) => void;
handleSelectAll?: (checked: boolean) => void;
handleRowClick?: (row: any, index: number, e: React.MouseEvent) => void;
renderCheckboxCell?: (row: any, index: number) => React.ReactNode;
renderCheckboxHeader?: () => React.ReactNode;
formatCellValue: (
value: any,
format?: string,
columnName?: string,
rowData?: Record<string, any>,
) => React.ReactNode;
getColumnWidth: (column: TColumn) => number;
containerWidth?: string;
loading?: boolean;
error?: string | null;
onCellDoubleClick?: (rowIndex: number, colIndex: number, columnName: string, value: any) => void;
editingCell?: { rowIndex: number; colIndex: number; columnName: string; originalValue: any } | null;
editingValue?: string;
onEditingValueChange?: (value: string) => void;
onEditKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
editInputRef?: React.RefObject<HTMLInputElement | null>;
searchHighlights?: Set<string>;
currentSearchIndex?: number;
searchTerm?: string;
}
export function SingleTableWithSticky<TColumn extends ColumnConfig = ColumnConfig>({
visibleColumns,
columns,
data,
columnLabels,
sortColumn,
sortDirection,
tableConfig,
isDesignMode = false,
isAllSelected = false,
handleSort,
onSort,
handleSelectAll,
handleRowClick,
renderCheckboxCell,
renderCheckboxHeader,
formatCellValue,
getColumnWidth,
containerWidth,
loading = false,
error = null,
onCellDoubleClick,
editingCell,
editingValue,
onEditingValueChange,
onEditKeyDown,
editInputRef,
searchHighlights,
currentSearchIndex = 0,
searchTerm = "",
}: SingleTableWithStickyProps<TColumn>) {
const { getTranslatedText } = useScreenMultiLang();
const checkboxConfig = tableConfig?.checkbox || {};
const actualColumns = visibleColumns || columns || [];
const sortHandler = onSort || handleSort || (() => {});
const actualData = data || [];
return (
<div
className="bg-background relative flex flex-1 flex-col overflow-hidden shadow-sm"
style={{
width: "100%",
height: "100%",
boxSizing: "border-box",
}}
>
<div className="relative flex-1 overflow-auto">
<Table
noWrapper
className="w-full"
style={{ width: "100%", tableLayout: "auto", boxSizing: "border-box" }}
>
<TableHeader
className={cn(
"bg-background border-b",
tableConfig?.stickyHeader && "sticky top-0 z-30 shadow-sm",
)}
>
<TableRow className="border-b">
{actualColumns.map((column, colIndex) => {
const leftFixedWidth = actualColumns
.slice(0, colIndex)
.filter((col) => col.fixed === "left")
.reduce((sum, col) => sum + getColumnWidth(col), 0);
const rightFixedColumns = actualColumns.filter((col) => col.fixed === "right");
const rightFixedIndex = rightFixedColumns.findIndex((col) => col.columnName === column.columnName);
const rightFixedWidth =
rightFixedIndex >= 0
? rightFixedColumns.slice(rightFixedIndex + 1).reduce((sum, col) => sum + getColumnWidth(col), 0)
: 0;
const isCheckboxCol = column.columnName === "__checkbox__";
const headCheckboxBaseClass =
"bg-background h-9 border-0 px-3 py-1.5 text-center align-middle sm:px-4 sm:py-2";
const headDataBaseClass =
"text-foreground hover:text-foreground bg-background h-9 cursor-pointer border-0 px-3 py-1.5 text-left align-middle text-xs font-semibold whitespace-nowrap transition-all duration-200 select-none sm:px-4 sm:py-2 sm:text-sm";
return (
<TableHead
key={column.columnName}
className={cn(
isCheckboxCol ? headCheckboxBaseClass : headDataBaseClass,
`text-${column.align}`,
column.sortable && "hover:bg-primary/10",
column.fixed === "left" && "border-border bg-background sticky z-40 border-r shadow-sm",
column.fixed === "right" && "border-border bg-background sticky z-40 border-l shadow-sm",
isDesignMode && column.hidden && "bg-muted/50 opacity-40",
)}
style={{
width: getColumnWidth(column),
minWidth: "100px",
maxWidth: "300px",
boxSizing: "border-box",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
backgroundColor: "hsl(var(--background))",
...(column.fixed === "left" && { left: leftFixedWidth }),
...(column.fixed === "right" && { right: rightFixedWidth }),
}}
onClick={() => column.sortable && sortHandler(column.columnName)}
>
<div className="flex items-center gap-2">
{isCheckboxCol ? (
checkboxConfig.selectAll && (
<Checkbox
checked={isAllSelected}
onCheckedChange={handleSelectAll}
aria-label="전체 선택"
style={{ zIndex: 1 }}
/>
)
) : (
<>
<span className="flex-1 truncate">
{(column as any).langKey
? getTranslatedText(
(column as any).langKey,
columnLabels[column.columnName] || column.displayName || column.columnName,
)
: columnLabels[column.columnName] || column.displayName || column.columnName}
</span>
{column.sortable && sortColumn === column.columnName && (
<span className="bg-background/50 ml-1 flex h-4 w-4 items-center justify-center rounded-md shadow-sm sm:ml-2 sm:h-5 sm:w-5">
{sortDirection === "asc" ? (
<ArrowUp className="text-primary h-2.5 w-2.5 sm:h-3.5 sm:w-3.5" />
) : (
<ArrowDown className="text-primary h-2.5 w-2.5 sm:h-3.5 sm:w-3.5" />
)}
</span>
)}
</>
)}
</div>
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody>
{actualData.length === 0 ? (
<TableRow>
<TableCell colSpan={actualColumns.length || 1} className="py-12 text-center">
<div className="flex flex-col items-center justify-center space-y-3">
<div className="bg-muted flex h-12 w-12 items-center justify-center rounded-full">
<svg
className="text-muted-foreground h-6 w-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<span className="text-muted-foreground text-sm font-medium"> </span>
<span className="bg-muted text-muted-foreground rounded-full px-3 py-1 text-xs">
</span>
</div>
</TableCell>
</TableRow>
) : (
actualData.map((row, index) => {
const rowClass = cn(
"bg-background h-10 cursor-pointer border-b transition-colors",
tableConfig?.tableStyle?.hoverEffect && "hover:bg-muted/50",
);
return (
<TableRow key={`row-${index}`} className={rowClass} onClick={(e) => handleRowClick?.(row, index, e)}>
{actualColumns.map((column, colIndex) => {
const leftFixedWidth = actualColumns
.slice(0, colIndex)
.filter((col) => col.fixed === "left")
.reduce((sum, col) => sum + getColumnWidth(col), 0);
const rightFixedColumns = actualColumns.filter((col) => col.fixed === "right");
const rightFixedIndex = rightFixedColumns.findIndex(
(col) => col.columnName === column.columnName,
);
const rightFixedWidth =
rightFixedIndex >= 0
? rightFixedColumns
.slice(rightFixedIndex + 1)
.reduce((sum, col) => sum + getColumnWidth(col), 0)
: 0;
const isEditing = editingCell?.rowIndex === index && editingCell?.colIndex === colIndex;
const isCheckboxCol = column.columnName === "__checkbox__";
const cellKey = `${index}-${colIndex}`;
const cellValueStr = String(row[column.columnName] ?? "").toLowerCase();
const hasSearchTerm = searchTerm ? cellValueStr.includes(searchTerm.toLowerCase()) : false;
const isHighlighted =
!isCheckboxCol && hasSearchTerm && (searchHighlights?.has(cellKey) ?? false);
const highlightArray = searchHighlights ? Array.from(searchHighlights) : [];
const isCurrentSearchResult =
isHighlighted &&
currentSearchIndex >= 0 &&
currentSearchIndex < highlightArray.length &&
highlightArray[currentSearchIndex] === cellKey;
const rawCellValue: React.ReactNode =
formatCellValue(row[column.columnName], column.format, column.columnName, row) || " ";
const renderCellContent = () => {
if (!isHighlighted || !searchTerm || isCheckboxCol) {
return rawCellValue;
}
const strValue = String(rawCellValue);
const lowerValue = strValue.toLowerCase();
const lowerTerm = searchTerm.toLowerCase();
const startIndex = lowerValue.indexOf(lowerTerm);
if (startIndex === -1) return rawCellValue;
const before = strValue.slice(0, startIndex);
const match = strValue.slice(startIndex, startIndex + searchTerm.length);
const after = strValue.slice(startIndex + searchTerm.length);
return (
<>
{before}
<mark
className={cn(
"rounded px-0.5",
isCurrentSearchResult
? "bg-orange-400 font-semibold text-white"
: "bg-yellow-200 text-yellow-900",
)}
>
{match}
</mark>
{after}
</>
);
};
const cellClass = cn(
"text-foreground h-10 px-3 py-1.5 align-middle text-xs whitespace-nowrap transition-colors sm:px-4 sm:py-2 sm:text-sm",
`text-${column.align}`,
column.fixed === "left" &&
"border-border bg-background/90 sticky z-10 border-r backdrop-blur-sm",
column.fixed === "right" &&
"border-border bg-background/90 sticky z-10 border-l backdrop-blur-sm",
onCellDoubleClick && !isCheckboxCol && "cursor-text",
);
return (
<TableCell
key={`cell-${column.columnName}`}
id={isCurrentSearchResult ? "current-search-result" : undefined}
className={cellClass}
style={{
width: getColumnWidth(column),
minWidth: "100px",
maxWidth: "300px",
boxSizing: "border-box",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
...(column.fixed === "left" && { left: leftFixedWidth }),
...(column.fixed === "right" && { right: rightFixedWidth }),
}}
onDoubleClick={(e) => {
if (onCellDoubleClick && !isCheckboxCol) {
e.stopPropagation();
onCellDoubleClick(index, colIndex, column.columnName, row[column.columnName]);
}
}}
>
{isCheckboxCol ? (
renderCheckboxCell?.(row, index)
) : isEditing ? (
<input
ref={editInputRef}
type="text"
value={editingValue ?? ""}
onChange={(e) => onEditingValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown}
onBlur={() => {
if (onEditKeyDown) {
const fakeEvent = {
key: "Enter",
preventDefault: () => {},
} as React.KeyboardEvent<HTMLInputElement>;
onEditKeyDown(fakeEvent);
}
}}
className="border-primary bg-background focus:ring-primary h-8 w-full rounded border px-2 text-xs focus:ring-2 focus:outline-none sm:text-sm"
onClick={(e) => e.stopPropagation()}
/>
) : (
renderCellContent()
)}
</TableCell>
);
})}
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</div>
);
}