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

1159 lines
38 KiB
TypeScript

"use client";
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { TableListConfig, ColumnConfig } from "./types";
import { WebType } from "@/types/common";
import { tableTypeApi } from "@/lib/api/screen";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { codeCache } from "@/lib/caching/codeCache";
import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization";
import { Button } from "@/components/ui/button";
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
RefreshCw,
ArrowUp,
ArrowDown,
TableIcon,
Settings,
X,
} from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { AdvancedSearchFilters } from "@/components/screen/filters/AdvancedSearchFilters";
import { SingleTableWithSticky } from "./SingleTableWithSticky";
import { CardModeRenderer } from "./CardModeRenderer";
// ========================================
// 캐시 및 유틸리티
// ========================================
const tableColumnCache = new Map<string, { columns: any[]; timestamp: number }>();
const tableInfoCache = new Map<string, { tables: any[]; timestamp: number }>();
const TABLE_CACHE_TTL = 5 * 60 * 1000; // 5분
const cleanupTableCache = () => {
const now = Date.now();
for (const [key, entry] of tableColumnCache.entries()) {
if (now - entry.timestamp > TABLE_CACHE_TTL) {
tableColumnCache.delete(key);
}
}
for (const [key, entry] of tableInfoCache.entries()) {
if (now - entry.timestamp > TABLE_CACHE_TTL) {
tableInfoCache.delete(key);
}
}
};
if (typeof window !== "undefined") {
setInterval(cleanupTableCache, 10 * 60 * 1000);
}
const debounceTimers = new Map<string, NodeJS.Timeout>();
const activeRequests = new Map<string, Promise<any>>();
const debouncedApiCall = <T extends any[], R>(key: string, fn: (...args: T) => Promise<R>, delay: number = 300) => {
return (...args: T): Promise<R> => {
const activeRequest = activeRequests.get(key);
if (activeRequest) {
return activeRequest as Promise<R>;
}
return new Promise((resolve, reject) => {
const existingTimer = debounceTimers.get(key);
if (existingTimer) {
clearTimeout(existingTimer);
}
const timer = setTimeout(async () => {
try {
const requestPromise = fn(...args);
activeRequests.set(key, requestPromise);
const result = await requestPromise;
resolve(result);
} catch (error) {
reject(error);
} finally {
debounceTimers.delete(key);
activeRequests.delete(key);
}
}, delay);
debounceTimers.set(key, timer);
});
};
};
// ========================================
// Props 인터페이스
// ========================================
export interface TableListComponentProps {
component: any;
isDesignMode?: boolean;
isSelected?: boolean;
isInteractive?: boolean;
onClick?: () => void;
onDragStart?: (e: React.DragEvent) => void;
onDragEnd?: (e: React.DragEvent) => void;
className?: string;
style?: React.CSSProperties;
formData?: Record<string, any>;
onFormDataChange?: (data: any) => void;
config?: TableListConfig;
size?: { width: number; height: number };
position?: { x: number; y: number; z?: number };
componentConfig?: any;
selectedScreen?: any;
onZoneComponentDrop?: any;
onZoneClick?: any;
tableName?: string;
onRefresh?: () => void;
onClose?: () => void;
screenId?: string;
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void;
onConfigChange?: (config: any) => void;
refreshKey?: number;
}
// ========================================
// 메인 컴포넌트
// ========================================
export const TableListComponent: React.FC<TableListComponentProps> = ({
component,
isDesignMode = false,
isSelected = false,
onClick,
onDragStart,
onDragEnd,
config,
className,
style,
onFormDataChange,
componentConfig,
onSelectedRowsChange,
onConfigChange,
refreshKey,
tableName,
}) => {
// ========================================
// 설정 및 스타일
// ========================================
const tableConfig = {
...config,
...component.config,
...componentConfig,
} as TableListConfig;
// selectedTable 안전하게 추출 (문자열인지 확인)
let finalSelectedTable =
componentConfig?.selectedTable || component.config?.selectedTable || config?.selectedTable || tableName;
console.log("🔍 TableListComponent 초기화:", {
componentConfigSelectedTable: componentConfig?.selectedTable,
componentConfigSelectedTableType: typeof componentConfig?.selectedTable,
componentConfigSelectedTable2: component.config?.selectedTable,
componentConfigSelectedTable2Type: typeof component.config?.selectedTable,
configSelectedTable: config?.selectedTable,
configSelectedTableType: typeof config?.selectedTable,
screenTableName: tableName,
screenTableNameType: typeof tableName,
finalSelectedTable,
finalSelectedTableType: typeof finalSelectedTable,
});
// 객체인 경우 tableName 속성 추출 시도
if (typeof finalSelectedTable === "object" && finalSelectedTable !== null) {
console.warn("⚠️ selectedTable이 객체입니다:", finalSelectedTable);
finalSelectedTable = (finalSelectedTable as any).tableName || (finalSelectedTable as any).name || tableName;
console.log("✅ 객체에서 추출한 테이블명:", finalSelectedTable);
}
tableConfig.selectedTable = finalSelectedTable;
console.log(
"✅ 최종 tableConfig.selectedTable:",
tableConfig.selectedTable,
"타입:",
typeof tableConfig.selectedTable,
);
const buttonColor = component.style?.labelColor || "#212121";
const buttonTextColor = component.config?.buttonTextColor || "#ffffff";
const gridColumns = component.gridColumns || 1;
let calculatedWidth: string;
if (isDesignMode) {
if (gridColumns === 1) {
calculatedWidth = "400px";
} else if (gridColumns === 2) {
calculatedWidth = "800px";
} else {
calculatedWidth = "100%";
}
} else {
calculatedWidth = "100%";
}
const componentStyle: React.CSSProperties = {
width: calculatedWidth,
height: isDesignMode ? "auto" : "100%",
minHeight: isDesignMode ? "300px" : "100%",
display: "flex",
flexDirection: "column",
backgroundColor: "#ffffff",
border: isSelected ? "2px solid #3b82f6" : "1px solid #e5e7eb",
borderRadius: "8px",
overflow: "hidden",
...style,
};
// ========================================
// 상태 관리
// ========================================
const [data, setData] = useState<Record<string, any>[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [totalItems, setTotalItems] = useState(0);
const [searchTerm, setSearchTerm] = useState("");
const [sortColumn, setSortColumn] = useState<string | null>(null);
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({});
const [tableLabel, setTableLabel] = useState<string>("");
const [localPageSize, setLocalPageSize] = useState<number>(tableConfig.pagination?.pageSize || 20);
const [displayColumns, setDisplayColumns] = useState<ColumnConfig[]>([]);
const [joinColumnMapping, setJoinColumnMapping] = useState<Record<string, string>>({});
const [columnMeta, setColumnMeta] = useState<Record<string, { webType?: string; codeCategory?: string }>>({});
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
const [isDragging, setIsDragging] = useState(false);
const [draggedRowIndex, setDraggedRowIndex] = useState<number | null>(null);
const [isAllSelected, setIsAllSelected] = useState(false);
// 필터 설정 관련 상태
const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false);
const [visibleFilterColumns, setVisibleFilterColumns] = useState<Set<string>>(new Set());
const { optimizedConvertCode } = useEntityJoinOptimization(columnMeta, {
enableBatchLoading: true,
preloadCommonCodes: true,
maxBatchSize: 5,
});
// ========================================
// 컬럼 라벨 가져오기
// ========================================
const fetchColumnLabels = async () => {
if (!tableConfig.selectedTable) return;
try {
const cacheKey = `columns_${tableConfig.selectedTable}`;
const cached = tableColumnCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < TABLE_CACHE_TTL) {
const labels: Record<string, string> = {};
const meta: Record<string, { webType?: string; codeCategory?: string }> = {};
cached.columns.forEach((col: any) => {
labels[col.columnName] = col.displayName || col.comment || col.columnName;
meta[col.columnName] = {
webType: col.webType,
codeCategory: col.codeCategory,
};
});
setColumnLabels(labels);
setColumnMeta(meta);
return;
}
const columns = await tableTypeApi.getColumns(tableConfig.selectedTable);
tableColumnCache.set(cacheKey, {
columns,
timestamp: Date.now(),
});
const labels: Record<string, string> = {};
const meta: Record<string, { webType?: string; codeCategory?: string }> = {};
columns.forEach((col: any) => {
labels[col.columnName] = col.displayName || col.comment || col.columnName;
meta[col.columnName] = {
webType: col.webType,
codeCategory: col.codeCategory,
};
});
setColumnLabels(labels);
setColumnMeta(meta);
} catch (error) {
console.error("컬럼 라벨 가져오기 실패:", error);
}
};
// ========================================
// 테이블 라벨 가져오기
// ========================================
const fetchTableLabel = async () => {
if (!tableConfig.selectedTable) return;
try {
const cacheKey = `table_info_${tableConfig.selectedTable}`;
const cached = tableInfoCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < TABLE_CACHE_TTL) {
const tables = cached.tables || [];
const tableInfo = tables.find((t: any) => t.tableName === tableConfig.selectedTable);
const label =
tableInfo?.displayName || (tableInfo as any)?.comment || tableInfo?.description || tableConfig.selectedTable;
setTableLabel(label);
return;
}
const tables = await tableTypeApi.getTables();
tableInfoCache.set(cacheKey, {
tables,
timestamp: Date.now(),
});
const tableInfo = tables.find((t: any) => t.tableName === tableConfig.selectedTable);
const label =
tableInfo?.displayName || (tableInfo as any)?.comment || tableInfo?.description || tableConfig.selectedTable;
setTableLabel(label);
} catch (error) {
console.error("테이블 라벨 가져오기 실패:", error);
}
};
// ========================================
// 데이터 가져오기
// ========================================
const fetchTableDataInternal = useCallback(async () => {
if (!tableConfig.selectedTable || isDesignMode) {
setData([]);
setTotalPages(0);
setTotalItems(0);
return;
}
// 테이블명 확인 로그
console.log("🔍 fetchTableDataInternal - selectedTable:", tableConfig.selectedTable);
console.log("🔍 selectedTable 타입:", typeof tableConfig.selectedTable);
console.log("🔍 전체 tableConfig:", tableConfig);
setLoading(true);
setError(null);
try {
const page = tableConfig.pagination?.currentPage || currentPage;
const pageSize = localPageSize;
const sortBy = sortColumn || undefined;
const sortOrder = sortDirection;
const search = searchTerm || undefined;
const filters = Object.keys(searchValues).length > 0 ? searchValues : undefined;
const entityJoinColumns = (tableConfig.columns || [])
.filter((col) => col.additionalJoinInfo)
.map((col) => ({
sourceTable: col.additionalJoinInfo!.sourceTable,
sourceColumn: col.additionalJoinInfo!.sourceColumn,
joinAlias: col.additionalJoinInfo!.joinAlias,
referenceTable: col.additionalJoinInfo!.referenceTable,
}));
const hasEntityJoins = entityJoinColumns.length > 0;
let response;
if (hasEntityJoins) {
response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
page,
size: pageSize,
sortBy,
sortOrder,
search: filters,
enableEntityJoin: true,
additionalJoinColumns: entityJoinColumns,
});
} else {
response = await tableTypeApi.getTableData(tableConfig.selectedTable, {
page,
size: pageSize,
sortBy,
sortOrder,
search: filters,
});
}
setData(response.data || []);
setTotalPages(response.totalPages || 0);
setTotalItems(response.total || 0);
setError(null);
} catch (err: any) {
console.error("데이터 가져오기 실패:", err);
setData([]);
setTotalPages(0);
setTotalItems(0);
setError(err.message || "데이터를 불러오지 못했습니다.");
} finally {
setLoading(false);
}
}, [
tableConfig.selectedTable,
tableConfig.pagination?.currentPage,
tableConfig.columns,
currentPage,
localPageSize,
sortColumn,
sortDirection,
searchTerm,
searchValues,
isDesignMode,
]);
const fetchTableDataDebounced = useCallback(
(...args: Parameters<typeof fetchTableDataInternal>) => {
const key = `fetchData_${tableConfig.selectedTable}_${currentPage}_${sortColumn}_${sortDirection}`;
return debouncedApiCall(key, fetchTableDataInternal, 300)(...args);
},
[fetchTableDataInternal, tableConfig.selectedTable, currentPage, sortColumn, sortDirection],
);
// ========================================
// 이벤트 핸들러
// ========================================
const handlePageChange = (newPage: number) => {
if (newPage < 1 || newPage > totalPages) return;
setCurrentPage(newPage);
if (tableConfig.pagination) {
tableConfig.pagination.currentPage = newPage;
}
if (onConfigChange) {
onConfigChange({ ...tableConfig, pagination: { ...tableConfig.pagination, currentPage: newPage } });
}
};
const handleSort = (column: string) => {
if (sortColumn === column) {
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
} else {
setSortColumn(column);
setSortDirection("asc");
}
};
const handleSearchValueChange = (columnName: string, value: any) => {
setSearchValues((prev) => ({ ...prev, [columnName]: value }));
};
const handleAdvancedSearch = () => {
setCurrentPage(1);
fetchTableDataDebounced();
};
const handleClearAdvancedFilters = () => {
setSearchValues({});
setCurrentPage(1);
fetchTableDataDebounced();
};
const handleRefresh = () => {
fetchTableDataDebounced();
};
const getRowKey = (row: any, index: number) => {
return row.id || row.uuid || `row-${index}`;
};
const handleRowSelection = (rowKey: string, checked: boolean) => {
const newSelectedRows = new Set(selectedRows);
if (checked) {
newSelectedRows.add(rowKey);
} else {
newSelectedRows.delete(rowKey);
}
setSelectedRows(newSelectedRows);
const selectedRowsData = data.filter((row, index) => newSelectedRows.has(getRowKey(row, index)));
if (onSelectedRowsChange) {
onSelectedRowsChange(Array.from(newSelectedRows), selectedRowsData);
}
if (onFormDataChange) {
onFormDataChange({ selectedRows: Array.from(newSelectedRows), selectedRowsData });
}
const allRowsSelected = data.every((row, index) => newSelectedRows.has(getRowKey(row, index)));
setIsAllSelected(allRowsSelected && data.length > 0);
};
const handleSelectAll = (checked: boolean) => {
if (checked) {
const allKeys = data.map((row, index) => getRowKey(row, index));
const newSelectedRows = new Set(allKeys);
setSelectedRows(newSelectedRows);
setIsAllSelected(true);
if (onSelectedRowsChange) {
onSelectedRowsChange(Array.from(newSelectedRows), data);
}
if (onFormDataChange) {
onFormDataChange({ selectedRows: Array.from(newSelectedRows), selectedRowsData: data });
}
} else {
setSelectedRows(new Set());
setIsAllSelected(false);
if (onSelectedRowsChange) {
onSelectedRowsChange([], []);
}
if (onFormDataChange) {
onFormDataChange({ selectedRows: [], selectedRowsData: [] });
}
}
};
const handleRowClick = (row: any) => {
console.log("행 클릭:", row);
};
const handleRowDragStart = (e: React.DragEvent, row: any, index: number) => {
setIsDragging(true);
setDraggedRowIndex(index);
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("application/json", JSON.stringify(row));
};
const handleRowDragEnd = (e: React.DragEvent) => {
setIsDragging(false);
setDraggedRowIndex(null);
};
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.();
};
// ========================================
// 컬럼 관련
// ========================================
const visibleColumns = useMemo(() => {
let cols = (tableConfig.columns || []).filter((col) => col.visible !== false);
if (tableConfig.checkbox?.enabled) {
const checkboxCol: ColumnConfig = {
columnName: "__checkbox__",
displayName: "",
visible: true,
sortable: false,
searchable: false,
width: 50,
align: "center",
order: -1,
};
if (tableConfig.checkbox.position === "right") {
cols = [...cols, checkboxCol];
} else {
cols = [checkboxCol, ...cols];
}
}
return cols.sort((a, b) => (a.order || 0) - (b.order || 0));
}, [tableConfig.columns, tableConfig.checkbox]);
const getColumnWidth = (column: ColumnConfig) => {
if (column.columnName === "__checkbox__") return 50;
if (column.width) return column.width;
switch (column.format) {
case "date":
return 120;
case "number":
case "currency":
return 100;
case "boolean":
return 80;
default:
return 150;
}
};
const renderCheckboxHeader = () => {
if (!tableConfig.checkbox?.selectAll) return null;
return <Checkbox checked={isAllSelected} onCheckedChange={handleSelectAll} aria-label="전체 선택" />;
};
const renderCheckboxCell = (row: any, index: number) => {
const rowKey = getRowKey(row, index);
const isChecked = selectedRows.has(rowKey);
return (
<Checkbox
checked={isChecked}
onCheckedChange={(checked) => handleRowSelection(rowKey, checked as boolean)}
aria-label={`${index + 1} 선택`}
/>
);
};
const formatCellValue = useCallback(
(value: any, column: ColumnConfig) => {
if (value === null || value === undefined) return "-";
const meta = columnMeta[column.columnName];
if (meta?.webType && meta?.codeCategory) {
const convertedValue = optimizedConvertCode(value, meta.codeCategory);
if (convertedValue !== value) return convertedValue;
}
switch (column.format) {
case "date":
if (value) {
try {
const date = new Date(value);
return date.toLocaleDateString("ko-KR");
} catch {
return value;
}
}
return "-";
case "number":
return typeof value === "number" ? value.toLocaleString() : value;
case "currency":
return typeof value === "number" ? `${value.toLocaleString()}` : value;
case "boolean":
return value ? "예" : "아니오";
default:
return String(value);
}
},
[columnMeta, optimizedConvertCode],
);
// ========================================
// useEffect 훅
// ========================================
// 필터 설정 localStorage 키 생성
const filterSettingKey = useMemo(() => {
if (!tableConfig.selectedTable) return null;
return `tableList_filterSettings_${tableConfig.selectedTable}`;
}, [tableConfig.selectedTable]);
// 저장된 필터 설정 불러오기
useEffect(() => {
if (!filterSettingKey) return;
try {
const saved = localStorage.getItem(filterSettingKey);
if (saved) {
const savedFilters = JSON.parse(saved);
setVisibleFilterColumns(new Set(savedFilters));
} else {
// 초기값: 모든 필터 표시
const allFilters = (tableConfig.filter?.filters || []).map((f) => f.columnName);
setVisibleFilterColumns(new Set(allFilters));
}
} catch (error) {
console.error("필터 설정 불러오기 실패:", error);
// 기본값으로 모든 필터 표시
const allFilters = (tableConfig.filter?.filters || []).map((f) => f.columnName);
setVisibleFilterColumns(new Set(allFilters));
}
}, [filterSettingKey, tableConfig.filter?.filters]);
// 필터 설정 저장
const saveFilterSettings = useCallback(() => {
if (!filterSettingKey) return;
try {
localStorage.setItem(filterSettingKey, JSON.stringify(Array.from(visibleFilterColumns)));
setIsFilterSettingOpen(false);
} catch (error) {
console.error("필터 설정 저장 실패:", error);
}
}, [filterSettingKey, visibleFilterColumns]);
// 필터 토글
const toggleFilterVisibility = useCallback((columnName: string) => {
setVisibleFilterColumns((prev) => {
const newSet = new Set(prev);
if (newSet.has(columnName)) {
newSet.delete(columnName);
} else {
newSet.add(columnName);
}
return newSet;
});
}, []);
// 표시할 필터 목록
const activeFilters = useMemo(() => {
return (tableConfig.filter?.filters || []).filter((f) => visibleFilterColumns.has(f.columnName));
}, [tableConfig.filter?.filters, visibleFilterColumns]);
useEffect(() => {
fetchColumnLabels();
fetchTableLabel();
}, [tableConfig.selectedTable]);
useEffect(() => {
if (!isDesignMode && tableConfig.selectedTable) {
fetchTableDataDebounced();
}
}, [
tableConfig.selectedTable,
currentPage,
localPageSize,
sortColumn,
sortDirection,
searchTerm,
refreshKey,
isDesignMode,
]);
useEffect(() => {
if (tableConfig.refreshInterval && !isDesignMode) {
const interval = setInterval(() => {
fetchTableDataDebounced();
}, tableConfig.refreshInterval * 1000);
return () => clearInterval(interval);
}
}, [tableConfig.refreshInterval, isDesignMode]);
// ========================================
// 페이지네이션 JSX
// ========================================
const paginationJSX = useMemo(() => {
if (!tableConfig.pagination?.enabled || isDesignMode) return null;
return (
<div
style={{
width: "100%",
height: "60px",
display: "flex",
alignItems: "center",
justifyContent: "center",
position: "relative",
borderTop: "2px solid #e5e7eb",
backgroundColor: "#ffffff",
padding: "0 24px",
flexShrink: 0,
}}
>
{/* 중앙 페이지네이션 컨트롤 */}
<div
style={{
display: "flex",
alignItems: "center",
gap: "16px",
}}
>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(1)}
disabled={currentPage === 1 || loading}
>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1 || loading}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span style={{ fontSize: "14px", fontWeight: 500, color: "#374151", minWidth: "80px", textAlign: "center" }}>
{currentPage} / {totalPages || 1}
</span>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= totalPages || loading}
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(totalPages)}
disabled={currentPage >= totalPages || loading}
>
<ChevronsRight className="h-4 w-4" />
</Button>
<span style={{ fontSize: "12px", color: "#6b7280", marginLeft: "16px" }}>
{totalItems.toLocaleString()}
</span>
</div>
{/* 우측 새로고침 버튼 */}
<Button
variant="ghost"
size="sm"
onClick={handleRefresh}
disabled={loading}
style={{ position: "absolute", right: "24px" }}
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</Button>
</div>
);
}, [tableConfig.pagination, isDesignMode, currentPage, totalPages, totalItems, loading]);
// ========================================
// 렌더링
// ========================================
const domProps = {
onClick: handleClick,
onDragStart: isDesignMode ? onDragStart : undefined,
onDragEnd: isDesignMode ? onDragEnd : undefined,
draggable: isDesignMode,
className: cn(className, isDesignMode && "cursor-move"),
style: componentStyle,
};
// 카드 모드
if (tableConfig.displayMode === "card" && !isDesignMode) {
return (
<div {...domProps}>
<CardModeRenderer
data={data}
loading={loading}
error={error}
cardConfig={tableConfig.cardConfig}
onRowClick={handleRowClick}
/>
{paginationJSX}
</div>
);
}
// SingleTableWithSticky 모드
if (tableConfig.stickyHeader && !isDesignMode) {
return (
<div {...domProps}>
{tableConfig.showHeader && (
<div style={{ padding: "16px 24px", borderBottom: "1px solid #e5e7eb" }}>
<h2 style={{ fontSize: "18px", fontWeight: 600, color: "#111827" }}>{tableConfig.title || tableLabel}</h2>
</div>
)}
{tableConfig.filter?.enabled && (
<div style={{ padding: "16px 24px", borderBottom: "1px solid #e5e7eb" }}>
<div style={{ display: "flex", alignItems: "flex-start", gap: "16px" }}>
<div style={{ flex: 1 }}>
<AdvancedSearchFilters
filters={activeFilters}
searchValues={searchValues}
onSearchValueChange={handleSearchValueChange}
onSearch={handleAdvancedSearch}
onClear={handleClearAdvancedFilters}
/>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setIsFilterSettingOpen(true)}
style={{ flexShrink: 0, marginTop: "4px" }}
>
<Settings className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
)}
<div style={{ flex: 1, overflow: "hidden" }}>
<SingleTableWithSticky
data={data}
columns={visibleColumns}
loading={loading}
error={error}
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={handleSort}
columnLabels={columnLabels}
renderCheckboxHeader={renderCheckboxHeader}
renderCheckboxCell={renderCheckboxCell}
formatCellValue={(value: any, format?: string, columnName?: string) => {
const column = visibleColumns.find((c) => c.columnName === columnName);
return column ? formatCellValue(value, column) : String(value);
}}
getColumnWidth={getColumnWidth}
containerWidth={calculatedWidth}
/>
</div>
{paginationJSX}
</div>
);
}
// 일반 테이블 모드 (네이티브 HTML 테이블)
return (
<>
<div {...domProps}>
{/* 헤더 */}
{tableConfig.showHeader && (
<div style={{ padding: "16px 24px", borderBottom: "1px solid #e5e7eb", flexShrink: 0 }}>
<h2 style={{ fontSize: "18px", fontWeight: 600, color: "#111827" }}>{tableConfig.title || tableLabel}</h2>
</div>
)}
{/* 필터 */}
{tableConfig.filter?.enabled && (
<div style={{ padding: "16px 24px", borderBottom: "1px solid #e5e7eb", flexShrink: 0 }}>
<div style={{ display: "flex", alignItems: "flex-start", gap: "16px" }}>
<div style={{ flex: 1 }}>
<AdvancedSearchFilters
filters={activeFilters}
searchValues={searchValues}
onSearchValueChange={handleSearchValueChange}
onSearch={handleAdvancedSearch}
onClear={handleClearAdvancedFilters}
/>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setIsFilterSettingOpen(true)}
style={{ flexShrink: 0, marginTop: "4px" }}
>
<Settings className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
)}
{/* 테이블 컨테이너 */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
{/* 스크롤 영역 */}
<div
style={{
width: "100%",
height: "500px",
overflowY: "scroll",
overflowX: "auto",
border: "1px solid #e5e7eb",
backgroundColor: "white",
}}
>
{/* 테이블 */}
<table
style={{
width: "100%",
borderCollapse: "collapse",
tableLayout: "auto",
}}
>
{/* 헤더 (sticky) */}
<thead
style={{
position: "sticky",
top: 0,
zIndex: 10,
backgroundColor: "#f8fafc",
}}
>
<tr style={{ height: "48px", borderBottom: "1px solid #e5e7eb" }}>
{visibleColumns.map((column) => (
<th
key={column.columnName}
style={{
padding: "12px 24px",
textAlign: column.align || "left",
fontSize: "14px",
fontWeight: 600,
color: "#374151",
whiteSpace: "nowrap",
backgroundColor: "#f8fafc",
cursor: column.sortable ? "pointer" : "default",
}}
onClick={() => column.sortable && handleSort(column.columnName)}
>
{column.columnName === "__checkbox__" ? (
renderCheckboxHeader()
) : (
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<span>{columnLabels[column.columnName] || column.displayName}</span>
{column.sortable && sortColumn === column.columnName && (
<span>{sortDirection === "asc" ? "↑" : "↓"}</span>
)}
</div>
)}
</th>
))}
</tr>
</thead>
{/* 바디 (스크롤) */}
<tbody>
{loading ? (
<tr>
<td colSpan={visibleColumns.length} style={{ padding: "48px", textAlign: "center" }}>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "12px" }}>
<RefreshCw className="h-8 w-8 animate-spin text-gray-400" />
<div style={{ fontSize: "14px", fontWeight: 500, color: "#64748b" }}> ...</div>
</div>
</td>
</tr>
) : error ? (
<tr>
<td colSpan={visibleColumns.length} style={{ padding: "48px", textAlign: "center" }}>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "12px" }}>
<div style={{ fontSize: "14px", fontWeight: 500, color: "#ef4444" }}> </div>
<div style={{ fontSize: "12px", color: "#94a3b8" }}>{error}</div>
</div>
</td>
</tr>
) : data.length === 0 ? (
<tr>
<td colSpan={visibleColumns.length} style={{ padding: "48px", textAlign: "center" }}>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "12px" }}>
<TableIcon className="h-12 w-12 text-gray-300" />
<div style={{ fontSize: "14px", fontWeight: 500, color: "#64748b" }}> </div>
<div style={{ fontSize: "12px", color: "#94a3b8" }}>
</div>
</div>
</td>
</tr>
) : (
data.map((row, index) => (
<tr
key={index}
draggable={!isDesignMode}
onDragStart={(e) => handleRowDragStart(e, row, index)}
onDragEnd={handleRowDragEnd}
style={{
height: "48px",
borderBottom: "1px solid #f1f5f9",
cursor: "pointer",
transition: "background-color 0.2s",
backgroundColor: index % 2 === 1 ? "#f9fafb" : "white",
}}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "#fef3f2")}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundColor = index % 2 === 1 ? "#f9fafb" : "white")
}
onClick={() => handleRowClick(row)}
>
{visibleColumns.map((column) => {
const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName;
const cellValue = row[mappedColumnName];
return (
<td
key={column.columnName}
style={{
padding: "12px 24px",
fontSize: "14px",
color: "#374151",
textAlign: column.align || "left",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{column.columnName === "__checkbox__"
? renderCheckboxCell(row, index)
: formatCellValue(cellValue, column)}
</td>
);
})}
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* 페이지네이션 */}
{paginationJSX}
</div>
{/* 필터 설정 다이얼로그 */}
<Dialog open={isFilterSettingOpen} onOpenChange={setIsFilterSettingOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
. .
</DialogDescription>
</DialogHeader>
<div className="max-h-[60vh] space-y-3 overflow-y-auto sm:space-y-4">
{(tableConfig.filter?.filters || []).map((filter) => (
<div key={filter.columnName} className="flex items-center gap-3 rounded p-2 hover:bg-gray-50">
<Checkbox
id={`filter-${filter.columnName}`}
checked={visibleFilterColumns.has(filter.columnName)}
onCheckedChange={() => toggleFilterVisibility(filter.columnName)}
/>
<Label
htmlFor={`filter-${filter.columnName}`}
className="flex-1 cursor-pointer text-xs font-normal sm:text-sm"
>
{columnLabels[filter.columnName] || filter.label || filter.columnName}
</Label>
</div>
))}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setIsFilterSettingOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button onClick={saveFilterSettings} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
export const TableListWrapper: React.FC<TableListComponentProps> = (props) => {
return <TableListComponent {...props} />;
};