ERP-node/frontend/lib/registry/components/table-list/SingleTableWithSticky.tsx

356 lines
16 KiB
TypeScript
Raw Normal View History

2025-09-23 15:31:27 +09:00
"use client";
import React from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { ArrowUp, ArrowDown, ArrowUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { ColumnConfig } from "./types";
interface SingleTableWithStickyProps {
visibleColumns?: ColumnConfig[];
columns?: ColumnConfig[];
2025-09-23 15:31:27 +09:00
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) => void;
renderCheckboxCell?: (row: any, index: number) => React.ReactNode;
renderCheckboxHeader?: () => React.ReactNode;
2025-10-28 18:41:45 +09:00
formatCellValue: (value: any, format?: string, columnName?: string, rowData?: Record<string, any>) => string;
2025-09-23 15:31:27 +09:00
getColumnWidth: (column: ColumnConfig) => number;
containerWidth?: string; // 컨테이너 너비 설정
loading?: boolean;
error?: string | null;
// 인라인 편집 관련 props
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>;
// 검색 하이라이트 관련 props
searchHighlights?: Set<string>;
currentSearchIndex?: number;
searchTerm?: string;
2025-09-23 15:31:27 +09:00
}
export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
visibleColumns,
columns,
2025-09-23 15:31:27 +09:00
data,
columnLabels,
sortColumn,
sortDirection,
tableConfig,
isDesignMode = false,
isAllSelected = false,
2025-09-23 15:31:27 +09:00
handleSort,
onSort,
2025-09-23 15:31:27 +09:00
handleSelectAll,
handleRowClick,
renderCheckboxCell,
renderCheckboxHeader,
2025-09-23 15:31:27 +09:00
formatCellValue,
getColumnWidth,
containerWidth,
loading = false,
error = null,
// 인라인 편집 관련 props
onCellDoubleClick,
editingCell,
editingValue,
onEditingValueChange,
onEditKeyDown,
editInputRef,
// 검색 하이라이트 관련 props
searchHighlights,
currentSearchIndex = 0,
searchTerm = "",
2025-09-23 15:31:27 +09:00
}) => {
const checkboxConfig = tableConfig?.checkbox || {};
const actualColumns = visibleColumns || columns || [];
const sortHandler = onSort || handleSort || (() => {});
2025-09-23 15:31:27 +09:00
return (
<div
className="relative flex flex-col bg-background shadow-sm"
style={{
width: "100%",
boxSizing: "border-box",
}}
>
<div className="relative overflow-x-auto">
<Table
className="w-full"
style={{
width: "100%",
tableLayout: "auto", // 테이블 크기 자동 조정
boxSizing: "border-box",
}}
>
2025-10-28 18:41:45 +09:00
<TableHeader
className={cn(
"border-b bg-background",
tableConfig?.stickyHeader && "sticky top-0 z-30 shadow-sm"
)}
2025-10-28 18:41:45 +09:00
>
<TableRow className="border-b">
{actualColumns.map((column, colIndex) => {
2025-09-23 15:31:27 +09:00
// 왼쪽 고정 컬럼들의 누적 너비 계산
const leftFixedWidth = actualColumns
2025-09-23 15:31:27 +09:00
.slice(0, colIndex)
.filter((col) => col.fixed === "left")
.reduce((sum, col) => sum + getColumnWidth(col), 0);
// 오른쪽 고정 컬럼들의 누적 너비 계산
const rightFixedColumns = actualColumns.filter((col) => col.fixed === "right");
2025-09-23 15:31:27 +09:00
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;
return (
<TableHead
key={column.columnName}
2025-09-23 15:31:27 +09:00
className={cn(
column.columnName === "__checkbox__"
? "h-10 border-0 px-3 py-2 text-center align-middle sm:h-12 sm:px-6 sm:py-3 bg-background"
: "h-10 cursor-pointer border-0 px-3 py-2 text-left align-middle font-semibold whitespace-nowrap text-xs text-foreground transition-all duration-200 select-none hover:text-foreground sm:h-12 sm:px-6 sm:py-3 sm:text-sm bg-background",
2025-09-23 15:31:27 +09:00
`text-${column.align}`,
column.sortable && "hover:bg-primary/10",
2025-09-23 15:31:27 +09:00
// 고정 컬럼 스타일
2025-10-28 18:41:45 +09:00
column.fixed === "left" &&
"sticky z-40 border-r border-border bg-background shadow-sm",
2025-10-28 18:41:45 +09:00
column.fixed === "right" &&
"sticky z-40 border-l border-border bg-background shadow-sm",
2025-09-23 15:31:27 +09:00
// 숨김 컬럼 스타일 (디자인 모드에서만)
isDesignMode && column.hidden && "bg-muted/50 opacity-40",
2025-09-23 15:31:27 +09:00
)}
style={{
width: getColumnWidth(column),
minWidth: "100px", // 최소 너비 보장
maxWidth: "300px", // 최대 너비 제한
boxSizing: "border-box",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap", // 텍스트 줄바꿈 방지
backgroundColor: "hsl(var(--background))",
2025-09-23 15:31:27 +09:00
// sticky 위치 설정
...(column.fixed === "left" && { left: leftFixedWidth }),
...(column.fixed === "right" && { right: rightFixedWidth }),
}}
onClick={() => column.sortable && sortHandler(column.columnName)}
2025-09-23 15:31:27 +09:00
>
<div className="flex items-center gap-2">
{column.columnName === "__checkbox__" ? (
checkboxConfig.selectAll && (
2025-10-28 18:41:45 +09:00
<Checkbox
checked={isAllSelected}
onCheckedChange={handleSelectAll}
aria-label="전체 선택"
style={{ zIndex: 1 }}
/>
2025-09-23 15:31:27 +09:00
)
) : (
<>
<span className="flex-1 truncate">
{columnLabels[column.columnName] || column.displayName || column.columnName}
</span>
2025-09-30 18:42:33 +09:00
{column.sortable && sortColumn === column.columnName && (
<span className="ml-1 flex h-4 w-4 items-center justify-center rounded-md bg-background/50 shadow-sm sm:ml-2 sm:h-5 sm:w-5">
2025-09-30 18:42:33 +09:00
{sortDirection === "asc" ? (
<ArrowUp className="h-2.5 w-2.5 text-primary sm:h-3.5 sm:w-3.5" />
2025-09-23 15:31:27 +09:00
) : (
<ArrowDown className="h-2.5 w-2.5 text-primary sm:h-3.5 sm:w-3.5" />
2025-09-23 15:31:27 +09:00
)}
</span>
)}
</>
)}
</div>
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody>
{data.length === 0 ? (
<TableRow>
<TableCell colSpan={visibleColumns.length} className="py-12 text-center">
<div className="flex flex-col items-center justify-center space-y-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<svg className="h-6 w-6 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
2025-10-28 18:41:45 +09:00
<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"
/>
2025-09-29 17:21:47 +09:00
</svg>
</div>
<span className="text-sm font-medium text-muted-foreground"> </span>
<span className="rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground">
2025-10-28 18:41:45 +09:00
</span>
2025-09-29 17:21:47 +09:00
</div>
2025-09-23 15:31:27 +09:00
</TableCell>
</TableRow>
) : (
data.map((row, index) => (
<TableRow
key={`row-${index}`}
2025-09-23 15:31:27 +09:00
className={cn(
"h-14 cursor-pointer border-b transition-colors bg-background sm:h-16",
tableConfig.tableStyle?.hoverEffect && "hover:bg-muted/50",
2025-09-23 15:31:27 +09:00
)}
onClick={() => handleRowClick(row)}
>
{visibleColumns.map((column, colIndex) => {
// 왼쪽 고정 컬럼들의 누적 너비 계산
const leftFixedWidth = visibleColumns
.slice(0, colIndex)
.filter((col) => col.fixed === "left")
.reduce((sum, col) => sum + getColumnWidth(col), 0);
// 오른쪽 고정 컬럼들의 누적 너비 계산
const rightFixedColumns = visibleColumns.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 cellKey = `${index}-${colIndex}`;
const cellValue = String(row[column.columnName] ?? "").toLowerCase();
const hasSearchTerm = searchTerm ? cellValue.includes(searchTerm.toLowerCase()) : false;
// 인덱스 기반 하이라이트 + 실제 값 검증
const isHighlighted = column.columnName !== "__checkbox__" &&
hasSearchTerm &&
(searchHighlights?.has(cellKey) ?? false);
// 현재 검색 결과인지 확인 (currentSearchIndex가 -1이면 현재 페이지에 없음)
const highlightArray = searchHighlights ? Array.from(searchHighlights) : [];
const isCurrentSearchResult = isHighlighted &&
currentSearchIndex >= 0 &&
currentSearchIndex < highlightArray.length &&
highlightArray[currentSearchIndex] === cellKey;
// 셀 값에서 검색어 하이라이트 렌더링
const renderCellContent = () => {
const cellValue = formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0";
if (!isHighlighted || !searchTerm || column.columnName === "__checkbox__") {
return cellValue;
}
// 검색어 하이라이트 처리
const lowerValue = String(cellValue).toLowerCase();
const lowerTerm = searchTerm.toLowerCase();
const startIndex = lowerValue.indexOf(lowerTerm);
if (startIndex === -1) return cellValue;
const before = String(cellValue).slice(0, startIndex);
const match = String(cellValue).slice(startIndex, startIndex + searchTerm.length);
const after = String(cellValue).slice(startIndex + searchTerm.length);
return (
<>
{before}
<mark className={cn(
"rounded px-0.5",
isCurrentSearchResult
? "bg-orange-400 text-white font-semibold"
: "bg-yellow-200 text-yellow-900"
)}>
{match}
</mark>
{after}
</>
);
};
2025-09-23 15:31:27 +09:00
return (
<TableCell
key={`cell-${column.columnName}`}
id={isCurrentSearchResult ? "current-search-result" : undefined}
2025-09-23 15:31:27 +09:00
className={cn(
"h-14 px-3 py-2 align-middle text-xs whitespace-nowrap text-foreground transition-colors sm:h-16 sm:px-6 sm:py-3 sm:text-sm",
2025-09-23 15:31:27 +09:00
`text-${column.align}`,
// 고정 컬럼 스타일
2025-10-28 18:41:45 +09:00
column.fixed === "left" &&
"sticky z-10 border-r border-border bg-background/90 backdrop-blur-sm",
2025-10-28 18:41:45 +09:00
column.fixed === "right" &&
"sticky z-10 border-l border-border bg-background/90 backdrop-blur-sm",
// 편집 가능 셀 스타일
onCellDoubleClick && column.columnName !== "__checkbox__" && "cursor-text",
2025-09-23 15:31:27 +09:00
)}
style={{
width: getColumnWidth(column),
minWidth: "100px", // 최소 너비 보장
maxWidth: "300px", // 최대 너비 제한
boxSizing: "border-box",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
2025-09-23 15:31:27 +09:00
// sticky 위치 설정
...(column.fixed === "left" && { left: leftFixedWidth }),
...(column.fixed === "right" && { right: rightFixedWidth }),
}}
onDoubleClick={(e) => {
if (onCellDoubleClick && column.columnName !== "__checkbox__") {
e.stopPropagation();
onCellDoubleClick(index, colIndex, column.columnName, row[column.columnName]);
}
}}
2025-09-23 15:31:27 +09:00
>
{column.columnName === "__checkbox__" ? (
renderCheckboxCell(row, index)
) : isEditing ? (
// 인라인 편집 입력 필드
<input
ref={editInputRef}
type="text"
value={editingValue ?? ""}
onChange={(e) => onEditingValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown}
onBlur={() => {
// blur 시 저장 (Enter와 동일)
if (onEditKeyDown) {
const fakeEvent = { key: "Enter", preventDefault: () => {} } as React.KeyboardEvent<HTMLInputElement>;
onEditKeyDown(fakeEvent);
}
}}
className="h-8 w-full rounded border border-primary bg-background px-2 text-xs focus:outline-none focus:ring-2 focus:ring-primary sm:text-sm"
onClick={(e) => e.stopPropagation()}
/>
) : (
renderCellContent()
)}
2025-09-23 15:31:27 +09:00
</TableCell>
);
})}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
2025-09-23 15:31:27 +09:00
</div>
);
};