392 lines
17 KiB
TypeScript
392 lines
17 KiB
TypeScript
"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>
|
||
);
|
||
}
|