356 lines
16 KiB
TypeScript
356 lines
16 KiB
TypeScript
"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[];
|
|
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;
|
|
formatCellValue: (value: any, format?: string, columnName?: string, rowData?: Record<string, any>) => string;
|
|
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;
|
|
}
|
|
|
|
export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
|
visibleColumns,
|
|
columns,
|
|
data,
|
|
columnLabels,
|
|
sortColumn,
|
|
sortDirection,
|
|
tableConfig,
|
|
isDesignMode = false,
|
|
isAllSelected = false,
|
|
handleSort,
|
|
onSort,
|
|
handleSelectAll,
|
|
handleRowClick,
|
|
renderCheckboxCell,
|
|
renderCheckboxHeader,
|
|
formatCellValue,
|
|
getColumnWidth,
|
|
containerWidth,
|
|
loading = false,
|
|
error = null,
|
|
// 인라인 편집 관련 props
|
|
onCellDoubleClick,
|
|
editingCell,
|
|
editingValue,
|
|
onEditingValueChange,
|
|
onEditKeyDown,
|
|
editInputRef,
|
|
// 검색 하이라이트 관련 props
|
|
searchHighlights,
|
|
currentSearchIndex = 0,
|
|
searchTerm = "",
|
|
}) => {
|
|
const checkboxConfig = tableConfig?.checkbox || {};
|
|
const actualColumns = visibleColumns || columns || [];
|
|
const sortHandler = onSort || handleSort || (() => {});
|
|
|
|
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",
|
|
}}
|
|
>
|
|
<TableHeader
|
|
className={cn(
|
|
"border-b bg-background",
|
|
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;
|
|
|
|
return (
|
|
<TableHead
|
|
key={column.columnName}
|
|
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",
|
|
`text-${column.align}`,
|
|
column.sortable && "hover:bg-primary/10",
|
|
// 고정 컬럼 스타일
|
|
column.fixed === "left" &&
|
|
"sticky z-40 border-r border-border bg-background shadow-sm",
|
|
column.fixed === "right" &&
|
|
"sticky z-40 border-l border-border bg-background 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))",
|
|
// sticky 위치 설정
|
|
...(column.fixed === "left" && { left: leftFixedWidth }),
|
|
...(column.fixed === "right" && { right: rightFixedWidth }),
|
|
}}
|
|
onClick={() => column.sortable && sortHandler(column.columnName)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
{column.columnName === "__checkbox__" ? (
|
|
checkboxConfig.selectAll && (
|
|
<Checkbox
|
|
checked={isAllSelected}
|
|
onCheckedChange={handleSelectAll}
|
|
aria-label="전체 선택"
|
|
style={{ zIndex: 1 }}
|
|
/>
|
|
)
|
|
) : (
|
|
<>
|
|
<span className="flex-1 truncate">
|
|
{columnLabels[column.columnName] || column.displayName || column.columnName}
|
|
</span>
|
|
{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">
|
|
{sortDirection === "asc" ? (
|
|
<ArrowUp className="h-2.5 w-2.5 text-primary sm:h-3.5 sm:w-3.5" />
|
|
) : (
|
|
<ArrowDown className="h-2.5 w-2.5 text-primary sm:h-3.5 sm:w-3.5" />
|
|
)}
|
|
</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">
|
|
<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-sm font-medium text-muted-foreground">데이터가 없습니다</span>
|
|
<span className="rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground">
|
|
조건을 변경하여 다시 검색해보세요
|
|
</span>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
data.map((row, index) => (
|
|
<TableRow
|
|
key={`row-${index}`}
|
|
className={cn(
|
|
"h-14 cursor-pointer border-b transition-colors bg-background sm:h-16",
|
|
tableConfig.tableStyle?.hoverEffect && "hover:bg-muted/50",
|
|
)}
|
|
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}
|
|
</>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<TableCell
|
|
key={`cell-${column.columnName}`}
|
|
id={isCurrentSearchResult ? "current-search-result" : undefined}
|
|
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",
|
|
`text-${column.align}`,
|
|
// 고정 컬럼 스타일
|
|
column.fixed === "left" &&
|
|
"sticky z-10 border-r border-border bg-background/90 backdrop-blur-sm",
|
|
column.fixed === "right" &&
|
|
"sticky z-10 border-l border-border bg-background/90 backdrop-blur-sm",
|
|
// 편집 가능 셀 스타일
|
|
onCellDoubleClick && column.columnName !== "__checkbox__" && "cursor-text",
|
|
)}
|
|
style={{
|
|
width: getColumnWidth(column),
|
|
minWidth: "100px", // 최소 너비 보장
|
|
maxWidth: "300px", // 최대 너비 제한
|
|
boxSizing: "border-box",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
// 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]);
|
|
}
|
|
}}
|
|
>
|
|
{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()
|
|
)}
|
|
</TableCell>
|
|
);
|
|
})}
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|