Files
invyone/frontend/lib/registry/components/table/_shared/SingleTableWithSticky.tsx
T
DDD1542 2f398ae0b3 chore: 제어모드 IDE 작업 + v2/legacy 레지스트리 컴포넌트 폐기
- 제어모드 IDE: ControlCardPanel, control/ide/* (Canvas/LeftRail/RightRail/PanZoomStage/V3RuleNode 등), schemas, lib/api/control
- 레지스트리 정리: aggregation-widget, status-count, section-card/paper, table-list(legacy/v2), tabs-widget 폐기 → table/_shared/ 로 통합
- InvLegacyButtonConfigPanel cp 마이그레이션
- canonical data view cleanup 후속 노트
2026-05-19 21:31:03 +09:00

592 lines
29 KiB
TypeScript

"use client";
/**
* SingleTableWithSticky — shared table renderer
*
* 2026-05-19 FlowWidget hard blocker 제거를 위해 legacy
* `lib/registry/components/table-list/SingleTableWithSticky.tsx` 에서 이동.
*
* 2026-05-20 v2-table-list 의 중복 sticky 구현을 흡수. `variant="v2"` prop 으로
* v2 전용 헤더/행/셀 스타일, 인라인 편집(category/code select, date picker fallback,
* number input), mobile scroll/minWidth, null/undefined/"" → "-" 표시(0 은 값) 분기.
*
* 이 파일은 legacy `table-list` / `v2-table-list` / FlowWidget 모두에서 공유한다.
* legacy table-list / v2-table-list local type files 를 import 하지 않는다. 본 파일이 자체 minimal
* `ColumnConfig` 를 export 한다.
*/
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 정의.
*
* legacy table-list / v2-table-list 의 `ColumnConfig` 가
* 이 type 의 superset 이므로 구조적으로 호환된다. 따라서 legacy 호출부에서는
* 별도 변환 없이 그대로 전달 가능.
*/
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;
}
export type SingleTableVariant = "default" | "v2";
interface SingleTableWithStickyProps<TColumn extends ColumnConfig = ColumnConfig> {
/** 시각/동작 분기 — 기본은 table-list / FlowWidget 기존 스타일, "v2" 는 v2-table-list 흡수 분기 */
variant?: SingleTableVariant;
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;
/** v2 에서는 ReactNode (이미지/JSX) 반환 가능. 기본 호출부는 string 반환해도 ReactNode subset 이라 호환 */
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>;
/** v2 전용: Enter/blur 시 저장 콜백 (date picker fallback 포함 통합 저장 경로) */
onEditSave?: () => void;
/** v2 전용: 컬럼별 inputType (select/category/code, number, date, datetime) */
columnMeta?: Record<string, { inputType?: string }>;
/** v2 전용: category/code 컬럼의 옵션 매핑 */
categoryMappings?: Record<string, Record<string, { label: string }>>;
searchHighlights?: Set<string>;
currentSearchIndex?: number;
searchTerm?: string;
}
export function SingleTableWithSticky<TColumn extends ColumnConfig = ColumnConfig>({
variant = "default",
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,
onEditSave,
columnMeta,
categoryMappings,
searchHighlights,
currentSearchIndex = 0,
searchTerm = "",
}: SingleTableWithStickyProps<TColumn>) {
const { getTranslatedText } = useScreenMultiLang();
const checkboxConfig = tableConfig?.checkbox || {};
const actualColumns = visibleColumns || columns || [];
const sortHandler = onSort || handleSort || (() => {});
const actualData = data || [];
const isV2 = variant === "v2";
// ── 컨테이너/스크롤 분기 (v2 만 mobile scroll + minWidth 적용) ──
const scrollContainerStyle: React.CSSProperties = isV2 ? { WebkitOverflowScrolling: "touch" } : {};
const tableStyle: React.CSSProperties = {
width: "100%",
tableLayout: "auto",
boxSizing: "border-box",
...(isV2 ? { minWidth: `${Math.max(actualColumns.length * 80, 400)}px` } : {}),
};
// ── 헤더 스타일 분기 ──
const headerBaseClass = isV2 ? "border-b border-border/60" : "bg-background border-b";
const headerStyle: React.CSSProperties = isV2 ? { backgroundColor: "hsl(var(--muted) / 0.4)" } : {};
const headerRowClass = isV2 ? "border-b border-border/60" : "border-b";
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" style={scrollContainerStyle}>
<Table noWrapper className="w-full" style={tableStyle}>
<TableHeader
className={cn(headerBaseClass, tableConfig?.stickyHeader && "sticky top-0 z-30 shadow-sm")}
style={headerStyle}
>
<TableRow className={headerRowClass}>
{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 = isV2
? "h-9 border-0 px-3 py-1.5 text-center align-middle sm:px-4 sm:py-2"
: "bg-background h-9 border-0 px-3 py-1.5 text-center align-middle sm:px-4 sm:py-2";
const headDataBaseClass = isV2
? "text-muted-foreground hover:text-foreground h-9 cursor-pointer border-0 px-3 py-1.5 text-left align-middle text-[10px] font-bold uppercase tracking-[0.04em] whitespace-nowrap transition-all duration-200 select-none sm:px-4 sm:py-2 sm:text-xs"
: "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";
const sortableHoverClass = isV2 ? "hover:bg-muted/50" : "hover:bg-primary/10";
// ── 셀 너비 / 헤더 width 분기 (v2 의 checkbox 48px 강제) ──
const checkboxFixedWidth = 48;
const headWidth = isV2 && isCheckboxCol ? checkboxFixedWidth : getColumnWidth(column);
const headMinWidth = isV2 && isCheckboxCol ? "48px" : "100px";
const headMaxWidth = isV2 && isCheckboxCol ? "48px" : "300px";
const headBackground = isV2 ? "hsl(var(--muted) / 0.4)" : "hsl(var(--background))";
return (
<TableHead
key={column.columnName}
className={cn(
isCheckboxCol ? headCheckboxBaseClass : headDataBaseClass,
`text-${column.align}`,
column.sortable && sortableHoverClass,
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: headWidth,
minWidth: headMinWidth,
maxWidth: headMaxWidth,
boxSizing: "border-box",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
backgroundColor: headBackground,
...(column.fixed === "left" && { left: leftFixedWidth }),
...(column.fixed === "right" && { right: rightFixedWidth }),
}}
onClick={() => column.sortable && sortHandler(column.columnName)}
>
<div className={cn("flex items-center", isV2 && isCheckboxCol ? "justify-center" : "gap-2")}>
{isCheckboxCol ? (
checkboxConfig.selectAll && (
<Checkbox
checked={isAllSelected}
onCheckedChange={handleSelectAll}
aria-label="전체 선택"
style={
isV2
? {
width: 16,
height: 16,
borderWidth: 1.5,
borderColor: isAllSelected
? "hsl(var(--primary))"
: "hsl(var(--muted-foreground) / 0.5)",
zIndex: 1,
}
: { 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) => {
// ── 행 className 분기 (v2 alternate background + hoverEffect 기본 true) ──
const rowClass = isV2
? cn(
"cursor-pointer border-b border-border/50 transition-[background] duration-75",
index % 2 === 0 ? "bg-background" : "bg-muted/20",
tableConfig?.tableStyle?.hoverEffect !== false && "hover:bg-accent",
)
: 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;
// ── 셀 값 분기 ──
// v2: null/undefined/"" → "-" 표시 (0 은 값 그대로), ReactElement 가능
// default: falsy 면 nbsp fallback
let rawCellValue: React.ReactNode;
let isReactElement = false;
if (isV2) {
const formatted = formatCellValue(row[column.columnName], column.format, column.columnName, row);
if (formatted === null || formatted === undefined || formatted === "") {
rawCellValue = <span className="text-muted-foreground/50">-</span>;
isReactElement = true;
} else {
rawCellValue = formatted;
isReactElement = typeof formatted === "object" && React.isValidElement(formatted);
}
} else {
rawCellValue =
formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0";
}
const renderCellContent = () => {
// ReactElement (v2 의 이미지/JSX) 는 그대로 렌더
if (isReactElement) {
return rawCellValue;
}
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}
</>
);
};
// ── 셀 className 분기 ──
const cellClass = isV2
? cn(
"text-foreground h-10 align-middle text-[11px] transition-colors",
isCheckboxCol ? "px-0 py-[7px] text-center" : "px-3 py-[7px]",
!isReactElement && "whitespace-nowrap",
!isCheckboxCol && `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",
)
: 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",
);
// ── 셀 width/style 분기 (v2 의 checkbox 48px) ──
const cellFixedWidth = isV2 && isCheckboxCol ? 48 : getColumnWidth(column);
const cellMinWidth = isV2 && isCheckboxCol ? "48px" : "100px";
const cellMaxWidth = isV2 && isCheckboxCol ? "48px" : "300px";
const cellOverflowStyle: React.CSSProperties =
isV2 && isReactElement
? {}
: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" };
return (
<TableCell
key={`cell-${column.columnName}`}
id={isCurrentSearchResult ? "current-search-result" : undefined}
className={cellClass}
style={{
width: cellFixedWidth,
minWidth: cellMinWidth,
maxWidth: cellMaxWidth,
boxSizing: "border-box",
...cellOverflowStyle,
...(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 ? (
isV2 ? (
// ── v2 인라인 편집: inputType 에 따라 select(category/code), date/datetime, number, text ──
(() => {
const meta = columnMeta?.[column.columnName];
const inputType = meta?.inputType ?? (column as { inputType?: string }).inputType;
const isNumeric = inputType === "number" || inputType === "decimal";
const isCategoryType = inputType === "category" || inputType === "code";
const categoryOptions = categoryMappings?.[column.columnName];
const hasCategoryOptions =
isCategoryType && categoryOptions && Object.keys(categoryOptions).length > 0;
const commonInputClass =
"border-primary bg-background focus:ring-primary h-8 w-full shrink-0 rounded border px-2 text-xs focus:ring-2 focus:outline-none sm:text-sm";
const handleBlurSave = () => {
if (onEditKeyDown) {
const fakeEvent = {
key: "Enter",
preventDefault: () => {},
} as React.KeyboardEvent<HTMLInputElement>;
onEditKeyDown(fakeEvent);
}
onEditSave?.();
};
if (hasCategoryOptions) {
const selectOptions = Object.entries(categoryOptions).map(([value, info]) => ({
value,
label: info.label,
}));
return (
<select
ref={editInputRef as unknown as React.RefObject<HTMLSelectElement>}
value={editingValue ?? ""}
onChange={(e) => onEditingValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown as unknown as React.KeyboardEventHandler<HTMLSelectElement>}
onBlur={handleBlurSave}
className={cn(commonInputClass, "h-8")}
onClick={(e) => e.stopPropagation()}
>
<option value=""></option>
{selectOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
}
if (inputType === "date" || inputType === "datetime") {
try {
// 외부 의존 모듈 — runtime require 실패 시 일반 text input 으로 폴백
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { InlineCellDatePicker } = require("@/components/screen/filters/InlineCellDatePicker");
return (
<InlineCellDatePicker
value={editingValue ?? ""}
onChange={(v: string) => onEditingValueChange?.(v)}
onSave={handleBlurSave}
onKeyDown={onEditKeyDown}
inputRef={editInputRef}
/>
);
} catch {
return (
<input
ref={editInputRef}
type="text"
value={editingValue ?? ""}
onChange={(e) => onEditingValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown}
onBlur={handleBlurSave}
className={commonInputClass}
onClick={(e) => e.stopPropagation()}
/>
);
}
}
return (
<input
ref={editInputRef}
type={isNumeric ? "number" : "text"}
value={editingValue ?? ""}
onChange={(e) => onEditingValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown}
onBlur={handleBlurSave}
className={commonInputClass}
style={isNumeric ? { textAlign: "right" } : undefined}
onClick={(e) => e.stopPropagation()}
/>
);
})()
) : (
// ── 기본 인라인 편집: 단순 text input (table-list / FlowWidget 기존 동작) ──
<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>
);
}