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

1667 lines
61 KiB
TypeScript
Raw Normal View History

2025-09-15 11:43:59 +09:00
"use client";
2025-09-29 17:29:58 +09:00
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
2025-09-16 16:53:03 +09:00
import { TableListConfig, ColumnConfig } from "./types";
import { WebType } from "@/types/common";
2025-09-15 11:43:59 +09:00
import { tableTypeApi } from "@/lib/api/screen";
2025-09-16 15:13:00 +09:00
import { entityJoinApi } from "@/lib/api/entityJoin";
import { codeCache } from "@/lib/caching/codeCache";
2025-10-23 16:50:41 +09:00
import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization";
import { Button } from "@/components/ui/button";
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
RefreshCw,
ArrowUp,
ArrowDown,
TableIcon,
Settings,
X,
Layers,
ChevronDown,
2025-10-23 16:50:41 +09:00
} from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
2025-10-23 16:50:41 +09:00
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";
// ========================================
// 인터페이스
// ========================================
// 그룹화된 데이터 인터페이스
interface GroupedData {
groupKey: string;
groupValues: Record<string, any>;
items: any[];
count: number;
}
2025-10-23 16:50:41 +09:00
// ========================================
// 캐시 및 유틸리티
// ========================================
2025-09-29 17:24:06 +09:00
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);
}
2025-09-29 17:29:58 +09:00
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);
});
};
};
2025-10-23 16:50:41 +09:00
// ========================================
// Props 인터페이스
// ========================================
2025-09-15 11:43:59 +09:00
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;
2025-09-18 18:49:30 +09:00
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void;
onConfigChange?: (config: any) => void;
2025-09-18 18:49:30 +09:00
refreshKey?: number;
2025-09-15 11:43:59 +09:00
}
2025-10-23 16:50:41 +09:00
// ========================================
// 메인 컴포넌트
// ========================================
2025-09-15 11:43:59 +09:00
export const TableListComponent: React.FC<TableListComponentProps> = ({
component,
isDesignMode = false,
isSelected = false,
onClick,
onDragStart,
onDragEnd,
config,
className,
style,
onFormDataChange,
componentConfig,
2025-09-18 18:49:30 +09:00
onSelectedRowsChange,
onConfigChange,
2025-09-18 18:49:30 +09:00
refreshKey,
2025-10-23 16:50:41 +09:00
tableName,
2025-09-15 11:43:59 +09:00
}) => {
2025-10-23 16:50:41 +09:00
// ========================================
// 설정 및 스타일
// ========================================
2025-09-15 11:43:59 +09:00
const tableConfig = {
...config,
...component.config,
...componentConfig,
} as TableListConfig;
2025-10-23 16:50:41 +09:00
// selectedTable 안전하게 추출 (문자열인지 확인)
let finalSelectedTable =
componentConfig?.selectedTable || component.config?.selectedTable || config?.selectedTable || tableName;
console.log("🔍 TableListComponent 초기화:", {
componentConfigSelectedTable: componentConfig?.selectedTable,
2025-10-23 16:50:41 +09:00
componentConfigSelectedTableType: typeof componentConfig?.selectedTable,
componentConfigSelectedTable2: component.config?.selectedTable,
2025-10-23 16:50:41 +09:00
componentConfigSelectedTable2Type: typeof component.config?.selectedTable,
configSelectedTable: config?.selectedTable,
2025-10-23 16:50:41 +09:00
configSelectedTableType: typeof config?.selectedTable,
screenTableName: tableName,
2025-10-23 16:50:41 +09:00
screenTableNameType: typeof tableName,
finalSelectedTable,
finalSelectedTableType: typeof finalSelectedTable,
});
2025-10-23 16:50:41 +09:00
// 객체인 경우 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";
2025-09-29 17:24:06 +09:00
const buttonTextColor = component.config?.buttonTextColor || "#ffffff";
2025-10-23 16:50:41 +09:00
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%";
}
2025-09-24 10:33:54 +09:00
2025-10-23 16:50:41 +09:00
const componentStyle: React.CSSProperties = {
width: calculatedWidth,
height: isDesignMode ? "auto" : "100%",
minHeight: isDesignMode ? "300px" : "100%",
display: "flex",
flexDirection: "column",
backgroundColor: "hsl(var(--background))",
2025-10-23 16:50:41 +09:00
overflow: "hidden",
...style,
};
// ========================================
2025-09-15 11:43:59 +09:00
// 상태 관리
2025-10-23 16:50:41 +09:00
// ========================================
2025-09-18 15:14:14 +09:00
const [data, setData] = useState<Record<string, any>[]>([]);
2025-09-15 11:43:59 +09:00
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("");
2025-09-15 11:43:59 +09:00
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>("");
2025-10-23 16:50:41 +09:00
const [localPageSize, setLocalPageSize] = useState<number>(tableConfig.pagination?.pageSize || 20);
const [displayColumns, setDisplayColumns] = useState<ColumnConfig[]>([]);
2025-09-24 10:33:54 +09:00
const [joinColumnMapping, setJoinColumnMapping] = useState<Record<string, string>>({});
2025-10-23 16:50:41 +09:00
const [columnMeta, setColumnMeta] = useState<Record<string, { webType?: string; codeCategory?: string }>>({});
2025-09-23 14:26:18 +09:00
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
const [isDragging, setIsDragging] = useState(false);
2025-10-23 16:50:41 +09:00
const [draggedRowIndex, setDraggedRowIndex] = useState<number | null>(null);
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
const columnRefs = useRef<Record<string, HTMLTableCellElement | null>>({});
2025-10-23 16:50:41 +09:00
const [isAllSelected, setIsAllSelected] = useState(false);
const hasInitializedWidths = useRef(false);
const isResizing = useRef(false);
2025-10-23 16:50:41 +09:00
// 필터 설정 관련 상태
const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false);
const [visibleFilterColumns, setVisibleFilterColumns] = useState<Set<string>>(new Set());
2025-09-18 18:49:30 +09:00
// 그룹 설정 관련 상태
const [isGroupSettingOpen, setIsGroupSettingOpen] = useState(false);
const [groupByColumns, setGroupByColumns] = useState<string[]>([]);
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
2025-09-17 13:49:00 +09:00
const { optimizedConvertCode } = useEntityJoinOptimization(columnMeta, {
2025-09-16 16:53:03 +09:00
enableBatchLoading: true,
preloadCommonCodes: true,
maxBatchSize: 5,
});
2025-09-15 11:43:59 +09:00
2025-10-23 16:50:41 +09:00
// ========================================
// 컬럼 라벨 가져오기
// ========================================
2025-09-15 11:43:59 +09:00
const fetchColumnLabels = async () => {
if (!tableConfig.selectedTable) return;
2025-10-23 16:50:41 +09:00
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,
};
});
2025-10-23 16:50:41 +09:00
setColumnLabels(labels);
setColumnMeta(meta);
2025-09-29 17:24:06 +09:00
return;
}
2025-10-23 16:50:41 +09:00
const columns = await tableTypeApi.getColumns(tableConfig.selectedTable);
// 컬럼 입력 타입 정보 가져오기
const inputTypes = await tableTypeApi.getColumnInputTypes(tableConfig.selectedTable);
const inputTypeMap: Record<string, string> = {};
inputTypes.forEach((col: any) => {
inputTypeMap[col.columnName] = col.inputType;
});
2025-09-29 17:24:06 +09:00
2025-10-23 16:50:41 +09:00
tableColumnCache.set(cacheKey, {
columns,
inputTypes,
2025-10-23 16:50:41 +09:00
timestamp: Date.now(),
});
2025-09-29 17:24:06 +09:00
2025-10-23 16:50:41 +09:00
const labels: Record<string, string> = {};
const meta: Record<string, { webType?: string; codeCategory?: string; inputType?: string }> = {};
2025-09-29 17:24:06 +09:00
2025-10-23 16:50:41 +09:00
columns.forEach((col: any) => {
labels[col.columnName] = col.displayName || col.comment || col.columnName;
meta[col.columnName] = {
webType: col.webType,
codeCategory: col.codeCategory,
inputType: inputTypeMap[col.columnName],
2025-10-23 16:50:41 +09:00
};
});
2025-09-29 17:24:06 +09:00
2025-10-23 16:50:41 +09:00
setColumnLabels(labels);
setColumnMeta(meta);
} catch (error) {
console.error("컬럼 라벨 가져오기 실패:", error);
}
2025-09-15 11:43:59 +09:00
};
2025-10-23 16:50:41 +09:00
// ========================================
// 테이블 라벨 가져오기
// ========================================
2025-09-15 11:43:59 +09:00
const fetchTableLabel = async () => {
if (!tableConfig.selectedTable) return;
2025-10-23 16:50:41 +09:00
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);
2025-09-29 17:24:06 +09:00
return;
2025-09-15 11:43:59 +09:00
}
2025-09-29 17:24:06 +09:00
2025-10-23 16:50:41 +09:00
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);
2025-09-15 11:43:59 +09:00
}
};
2025-10-23 16:50:41 +09:00
// ========================================
// 데이터 가져오기
// ========================================
const fetchTableDataInternal = useCallback(async () => {
2025-10-23 16:50:41 +09:00
if (!tableConfig.selectedTable || isDesignMode) {
2025-09-15 11:43:59 +09:00
setData([]);
2025-10-23 16:50:41 +09:00
setTotalPages(0);
setTotalItems(0);
2025-09-15 11:43:59 +09:00
return;
}
2025-10-23 16:50:41 +09:00
// 테이블명 확인 로그
console.log("🔍 fetchTableDataInternal - selectedTable:", tableConfig.selectedTable);
console.log("🔍 selectedTable 타입:", typeof tableConfig.selectedTable);
console.log("🔍 전체 tableConfig:", tableConfig);
2025-09-15 11:43:59 +09:00
setLoading(true);
setError(null);
try {
2025-10-23 16:50:41 +09:00
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,
});
2025-09-15 11:43:59 +09:00
}
2025-10-23 16:50:41 +09:00
setData(response.data || []);
setTotalPages(response.totalPages || 0);
setTotalItems(response.total || 0);
setError(null);
} catch (err: any) {
console.error("데이터 가져오기 실패:", err);
2025-09-15 11:43:59 +09:00
setData([]);
2025-10-23 16:50:41 +09:00
setTotalPages(0);
setTotalItems(0);
setError(err.message || "데이터를 불러오지 못했습니다.");
2025-09-15 11:43:59 +09:00
} finally {
setLoading(false);
}
}, [
tableConfig.selectedTable,
2025-10-23 16:50:41 +09:00
tableConfig.pagination?.currentPage,
tableConfig.columns,
currentPage,
localPageSize,
sortColumn,
sortDirection,
2025-10-23 16:50:41 +09:00
searchTerm,
searchValues,
2025-10-23 16:50:41 +09:00
isDesignMode,
]);
const fetchTableDataDebounced = useCallback(
2025-10-23 16:50:41 +09:00
(...args: Parameters<typeof fetchTableDataInternal>) => {
const key = `fetchData_${tableConfig.selectedTable}_${currentPage}_${sortColumn}_${sortDirection}`;
return debouncedApiCall(key, fetchTableDataInternal, 300)(...args);
},
[fetchTableDataInternal, tableConfig.selectedTable, currentPage, sortColumn, sortDirection],
);
2025-09-15 11:43:59 +09:00
2025-10-23 16:50:41 +09:00
// ========================================
// 이벤트 핸들러
// ========================================
2025-09-15 11:43:59 +09:00
const handlePageChange = (newPage: number) => {
2025-10-23 16:50:41 +09:00
if (newPage < 1 || newPage > totalPages) return;
2025-09-15 11:43:59 +09:00
setCurrentPage(newPage);
2025-10-23 16:50:41 +09:00
if (tableConfig.pagination) {
tableConfig.pagination.currentPage = newPage;
}
if (onConfigChange) {
onConfigChange({ ...tableConfig, pagination: { ...tableConfig.pagination, currentPage: newPage } });
}
2025-09-15 11:43:59 +09:00
};
const handleSort = (column: string) => {
if (sortColumn === column) {
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
} else {
setSortColumn(column);
setSortDirection("asc");
}
};
2025-09-23 14:26:18 +09:00
const handleSearchValueChange = (columnName: string, value: any) => {
2025-10-23 16:50:41 +09:00
setSearchValues((prev) => ({ ...prev, [columnName]: value }));
2025-09-23 14:26:18 +09:00
};
const handleAdvancedSearch = () => {
setCurrentPage(1);
2025-09-29 17:29:58 +09:00
fetchTableDataDebounced();
2025-09-23 14:26:18 +09:00
};
const handleClearAdvancedFilters = () => {
setSearchValues({});
setCurrentPage(1);
2025-09-29 17:29:58 +09:00
fetchTableDataDebounced();
2025-09-15 11:43:59 +09:00
};
const handleRefresh = () => {
2025-09-29 17:29:58 +09:00
fetchTableDataDebounced();
2025-09-15 11:43:59 +09:00
};
2025-09-18 18:49:30 +09:00
const getRowKey = (row: any, index: number) => {
2025-10-23 16:50:41 +09:00
return row.id || row.uuid || `row-${index}`;
2025-09-18 18:49:30 +09:00
};
const handleRowSelection = (rowKey: string, checked: boolean) => {
const newSelectedRows = new Set(selectedRows);
if (checked) {
newSelectedRows.add(rowKey);
} else {
newSelectedRows.delete(rowKey);
}
setSelectedRows(newSelectedRows);
2025-10-23 16:50:41 +09:00
const selectedRowsData = data.filter((row, index) => newSelectedRows.has(getRowKey(row, index)));
if (onSelectedRowsChange) {
onSelectedRowsChange(Array.from(newSelectedRows), selectedRowsData);
2025-09-18 18:49:30 +09:00
}
2025-10-23 16:50:41 +09:00
if (onFormDataChange) {
onFormDataChange({ selectedRows: Array.from(newSelectedRows), selectedRowsData });
}
const allRowsSelected = data.every((row, index) => newSelectedRows.has(getRowKey(row, index)));
setIsAllSelected(allRowsSelected && data.length > 0);
2025-09-18 18:49:30 +09:00
};
const handleSelectAll = (checked: boolean) => {
if (checked) {
const allKeys = data.map((row, index) => getRowKey(row, index));
2025-10-23 16:50:41 +09:00
const newSelectedRows = new Set(allKeys);
setSelectedRows(newSelectedRows);
2025-09-18 18:49:30 +09:00
setIsAllSelected(true);
2025-10-23 16:50:41 +09:00
if (onSelectedRowsChange) {
onSelectedRowsChange(Array.from(newSelectedRows), data);
}
if (onFormDataChange) {
onFormDataChange({ selectedRows: Array.from(newSelectedRows), selectedRowsData: data });
2025-09-18 18:49:30 +09:00
}
} else {
setSelectedRows(new Set());
setIsAllSelected(false);
2025-10-23 16:50:41 +09:00
if (onSelectedRowsChange) {
onSelectedRowsChange([], []);
2025-09-18 18:49:30 +09:00
}
2025-09-15 11:43:59 +09:00
if (onFormDataChange) {
2025-10-23 16:50:41 +09:00
onFormDataChange({ selectedRows: [], selectedRowsData: [] });
2025-09-15 11:43:59 +09:00
}
}
2025-10-23 16:50:41 +09:00
};
2025-09-18 18:49:30 +09:00
2025-10-23 16:50:41 +09:00
const handleRowClick = (row: any) => {
console.log("행 클릭:", row);
};
2025-10-23 16:50:41 +09:00
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));
};
2025-10-23 16:50:41 +09:00
const handleRowDragEnd = (e: React.DragEvent) => {
setIsDragging(false);
setDraggedRowIndex(null);
};
2025-10-23 16:50:41 +09:00
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.();
};
2025-09-29 17:24:06 +09:00
2025-10-23 16:50:41 +09:00
// ========================================
// 컬럼 관련
// ========================================
2025-09-15 11:43:59 +09:00
const visibleColumns = useMemo(() => {
2025-10-23 16:50:41 +09:00
let cols = (tableConfig.columns || []).filter((col) => col.visible !== false);
2025-09-18 18:49:30 +09:00
2025-10-23 16:50:41 +09:00
if (tableConfig.checkbox?.enabled) {
const checkboxCol: ColumnConfig = {
2025-09-18 18:49:30 +09:00
columnName: "__checkbox__",
displayName: "",
visible: true,
sortable: false,
searchable: false,
width: 50,
align: "center",
2025-10-23 16:50:41 +09:00
order: -1,
2025-09-18 18:49:30 +09:00
};
2025-10-23 16:50:41 +09:00
if (tableConfig.checkbox.position === "right") {
cols = [...cols, checkboxCol];
2025-09-18 18:49:30 +09:00
} else {
2025-10-23 16:50:41 +09:00
cols = [checkboxCol, ...cols];
2025-09-18 18:49:30 +09:00
}
2025-09-16 15:13:00 +09:00
}
2025-09-18 18:49:30 +09:00
2025-10-23 16:50:41 +09:00
return cols.sort((a, b) => (a.order || 0) - (b.order || 0));
}, [tableConfig.columns, tableConfig.checkbox]);
2025-09-18 15:14:14 +09:00
const getColumnWidth = (column: ColumnConfig) => {
2025-10-23 16:50:41 +09:00
if (column.columnName === "__checkbox__") return 50;
2025-09-18 15:14:14 +09:00
if (column.width) return column.width;
2025-10-23 16:50:41 +09:00
switch (column.format) {
case "date":
return 120;
case "number":
case "currency":
return 100;
case "boolean":
return 80;
default:
return 150;
2025-09-18 18:49:30 +09:00
}
2025-09-18 15:14:14 +09:00
};
2025-09-18 18:49:30 +09:00
const renderCheckboxHeader = () => {
2025-10-23 16:50:41 +09:00
if (!tableConfig.checkbox?.selectAll) return null;
2025-09-18 18:49:30 +09:00
2025-10-23 16:50:41 +09:00
return <Checkbox checked={isAllSelected} onCheckedChange={handleSelectAll} aria-label="전체 선택" />;
2025-09-18 18:49:30 +09:00
};
const renderCheckboxCell = (row: any, index: number) => {
const rowKey = getRowKey(row, index);
2025-10-23 16:50:41 +09:00
const isChecked = selectedRows.has(rowKey);
2025-09-18 18:49:30 +09:00
return (
<Checkbox
2025-10-23 16:50:41 +09:00
checked={isChecked}
2025-09-18 18:49:30 +09:00
onCheckedChange={(checked) => handleRowSelection(rowKey, checked as boolean)}
aria-label={`${index + 1} 선택`}
/>
);
};
2025-10-23 16:50:41 +09:00
const formatCellValue = useCallback(
2025-10-28 18:41:45 +09:00
(value: any, column: ColumnConfig, rowData?: Record<string, any>) => {
2025-10-23 16:50:41 +09:00
if (value === null || value === undefined) return "-";
2025-10-28 18:41:45 +09:00
// 🎯 엔티티 컬럼 표시 설정이 있는 경우
if (column.entityDisplayConfig && rowData) {
// displayColumns 또는 selectedColumns 둘 다 체크
const displayColumns = column.entityDisplayConfig.displayColumns || column.entityDisplayConfig.selectedColumns;
const separator = column.entityDisplayConfig.separator;
if (displayColumns && displayColumns.length > 0) {
// 선택된 컬럼들의 값을 구분자로 조합
const values = displayColumns
.map((colName) => {
const cellValue = rowData[colName];
if (cellValue === null || cellValue === undefined) return "";
return String(cellValue);
})
.filter((v) => v !== ""); // 빈 값 제외
return values.join(separator || " - ");
}
}
2025-10-23 16:50:41 +09:00
const meta = columnMeta[column.columnName];
if (meta?.webType === "code" && meta?.codeCategory) {
2025-10-23 16:50:41 +09:00
const convertedValue = optimizedConvertCode(value, meta.codeCategory);
if (convertedValue !== value) return convertedValue;
2025-09-16 16:53:03 +09:00
}
// inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선)
const inputType = meta?.inputType || column.inputType;
if (inputType === "number" || inputType === "decimal") {
if (value !== null && value !== undefined && value !== "") {
const numValue = typeof value === "string" ? parseFloat(value) : value;
if (!isNaN(numValue)) {
return numValue.toLocaleString("ko-KR");
}
}
return String(value);
}
2025-10-23 16:50:41 +09:00
switch (column.format) {
case "number":
if (value !== null && value !== undefined && value !== "") {
const numValue = typeof value === "string" ? parseFloat(value) : value;
if (!isNaN(numValue)) {
return numValue.toLocaleString("ko-KR");
}
}
return String(value);
2025-10-23 16:50:41 +09:00
case "date":
if (value) {
try {
const date = new Date(value);
return date.toLocaleDateString("ko-KR");
} catch {
return value;
}
}
return "-";
2025-09-16 16:53:03 +09:00
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);
}
2025-10-23 16:50:41 +09:00
},
[columnMeta, optimizedConvertCode],
);
2025-09-15 11:43:59 +09:00
2025-10-23 16:50:41 +09:00
// ========================================
// useEffect 훅
// ========================================
2025-09-15 11:43:59 +09:00
2025-10-23 16:50:41 +09:00
// 필터 설정 localStorage 키 생성
const filterSettingKey = useMemo(() => {
if (!tableConfig.selectedTable) return null;
return `tableList_filterSettings_${tableConfig.selectedTable}`;
}, [tableConfig.selectedTable]);
// 그룹 설정 localStorage 키 생성
const groupSettingKey = useMemo(() => {
if (!tableConfig.selectedTable) return null;
return `tableList_groupSettings_${tableConfig.selectedTable}`;
}, [tableConfig.selectedTable]);
2025-10-23 16:50:41 +09:00
// 저장된 필터 설정 불러오기
useEffect(() => {
if (!filterSettingKey || visibleColumns.length === 0) return;
2025-10-23 16:50:41 +09:00
try {
const saved = localStorage.getItem(filterSettingKey);
if (saved) {
const savedFilters = JSON.parse(saved);
setVisibleFilterColumns(new Set(savedFilters));
} else {
// 초기값: 빈 Set (아무것도 선택 안 함)
setVisibleFilterColumns(new Set());
2025-10-23 16:50:41 +09:00
}
} catch (error) {
console.error("필터 설정 불러오기 실패:", error);
setVisibleFilterColumns(new Set());
2025-09-15 11:43:59 +09:00
}
}, [filterSettingKey, visibleColumns]);
2025-09-15 11:43:59 +09:00
2025-10-23 16:50:41 +09:00
// 필터 설정 저장
const saveFilterSettings = useCallback(() => {
if (!filterSettingKey) return;
2025-09-29 17:24:06 +09:00
2025-10-23 16:50:41 +09:00
try {
localStorage.setItem(filterSettingKey, JSON.stringify(Array.from(visibleFilterColumns)));
setIsFilterSettingOpen(false);
toast.success("검색 필터 설정이 저장되었습니다");
// 검색 값 초기화
setSearchValues({});
2025-10-23 16:50:41 +09:00
} catch (error) {
console.error("필터 설정 저장 실패:", error);
toast.error("설정 저장에 실패했습니다");
2025-10-23 16:50:41 +09:00
}
}, [filterSettingKey, visibleFilterColumns]);
// 필터 컬럼 토글
2025-10-23 16:50:41 +09:00
const toggleFilterVisibility = useCallback((columnName: string) => {
setVisibleFilterColumns((prev) => {
const newSet = new Set(prev);
if (newSet.has(columnName)) {
newSet.delete(columnName);
} else {
newSet.add(columnName);
}
2025-10-23 16:50:41 +09:00
return newSet;
});
}, []);
// 전체 선택/해제
const toggleAllFilters = useCallback(() => {
const filterableColumns = visibleColumns.filter((col) => col.columnName !== "__checkbox__");
const columnNames = filterableColumns.map((col) => col.columnName);
if (visibleFilterColumns.size === columnNames.length) {
// 전체 해제
setVisibleFilterColumns(new Set());
} else {
// 전체 선택
setVisibleFilterColumns(new Set(columnNames));
}
}, [visibleFilterColumns, visibleColumns]);
// 표시할 필터 목록 (선택된 컬럼만)
2025-10-23 16:50:41 +09:00
const activeFilters = useMemo(() => {
return visibleColumns
.filter((col) => col.columnName !== "__checkbox__" && visibleFilterColumns.has(col.columnName))
.map((col) => ({
columnName: col.columnName,
label: columnLabels[col.columnName] || col.displayName || col.columnName,
type: col.format || "text",
}));
}, [visibleColumns, visibleFilterColumns, columnLabels]);
// 그룹 설정 저장
const saveGroupSettings = useCallback(() => {
if (!groupSettingKey) return;
try {
localStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns));
setIsGroupSettingOpen(false);
toast.success("그룹 설정이 저장되었습니다");
} catch (error) {
console.error("그룹 설정 저장 실패:", error);
toast.error("설정 저장에 실패했습니다");
}
}, [groupSettingKey, groupByColumns]);
// 그룹 컬럼 토글
const toggleGroupColumn = useCallback((columnName: string) => {
setGroupByColumns((prev) => {
if (prev.includes(columnName)) {
return prev.filter((col) => col !== columnName);
} else {
return [...prev, columnName];
}
});
}, []);
// 그룹 펼치기/접기 토글
const toggleGroupCollapse = useCallback((groupKey: string) => {
setCollapsedGroups((prev) => {
const newSet = new Set(prev);
if (newSet.has(groupKey)) {
newSet.delete(groupKey);
} else {
newSet.add(groupKey);
}
return newSet;
});
}, []);
// 그룹 해제
const clearGrouping = useCallback(() => {
setGroupByColumns([]);
setCollapsedGroups(new Set());
if (groupSettingKey) {
localStorage.removeItem(groupSettingKey);
}
toast.success("그룹이 해제되었습니다");
}, [groupSettingKey]);
// 데이터 그룹화
const groupedData = useMemo((): GroupedData[] => {
if (groupByColumns.length === 0 || data.length === 0) return [];
const grouped = new Map<string, any[]>();
data.forEach((item) => {
// 그룹 키 생성: "통화:KRW > 단위:EA"
const keyParts = groupByColumns.map((col) => {
const value = item[col];
const label = columnLabels[col] || col;
return `${label}:${value !== null && value !== undefined ? value : "-"}`;
});
const groupKey = keyParts.join(" > ");
if (!grouped.has(groupKey)) {
grouped.set(groupKey, []);
}
grouped.get(groupKey)!.push(item);
});
return Array.from(grouped.entries()).map(([groupKey, items]) => {
const groupValues: Record<string, any> = {};
groupByColumns.forEach((col) => {
groupValues[col] = items[0]?.[col];
});
return {
groupKey,
groupValues,
items,
count: items.length,
};
});
}, [data, groupByColumns, columnLabels]);
// 저장된 그룹 설정 불러오기
useEffect(() => {
if (!groupSettingKey || visibleColumns.length === 0) return;
try {
const saved = localStorage.getItem(groupSettingKey);
if (saved) {
const savedGroups = JSON.parse(saved);
setGroupByColumns(savedGroups);
}
} catch (error) {
console.error("그룹 설정 불러오기 실패:", error);
}
}, [groupSettingKey, visibleColumns]);
2025-10-23 16:50:41 +09:00
useEffect(() => {
fetchColumnLabels();
fetchTableLabel();
}, [tableConfig.selectedTable]);
2025-09-15 11:43:59 +09:00
2025-10-23 16:50:41 +09:00
useEffect(() => {
if (!isDesignMode && tableConfig.selectedTable) {
fetchTableDataDebounced();
}
}, [
tableConfig.selectedTable,
currentPage,
localPageSize,
sortColumn,
sortDirection,
searchTerm,
refreshKey,
isDesignMode,
]);
2025-09-15 11:43:59 +09:00
2025-10-23 16:50:41 +09:00
useEffect(() => {
if (tableConfig.refreshInterval && !isDesignMode) {
const interval = setInterval(() => {
fetchTableDataDebounced();
}, tableConfig.refreshInterval * 1000);
2025-09-15 11:43:59 +09:00
2025-10-23 16:50:41 +09:00
return () => clearInterval(interval);
}
}, [tableConfig.refreshInterval, isDesignMode]);
2025-09-18 18:49:30 +09:00
// 초기 컬럼 너비 측정 (한 번만)
useEffect(() => {
if (!hasInitializedWidths.current && visibleColumns.length > 0) {
// 약간의 지연을 두고 DOM이 완전히 렌더링된 후 측정
const timer = setTimeout(() => {
const newWidths: Record<string, number> = {};
let hasAnyWidth = false;
visibleColumns.forEach((column) => {
// 체크박스 컬럼은 제외 (고정 48px)
if (column.columnName === "__checkbox__") return;
const thElement = columnRefs.current[column.columnName];
if (thElement) {
const measuredWidth = thElement.offsetWidth;
if (measuredWidth > 0) {
newWidths[column.columnName] = measuredWidth;
hasAnyWidth = true;
}
}
});
if (hasAnyWidth) {
setColumnWidths(newWidths);
hasInitializedWidths.current = true;
}
}, 100);
return () => clearTimeout(timer);
}
}, [visibleColumns]);
2025-10-23 16:50:41 +09:00
// ========================================
// 페이지네이션 JSX
// ========================================
const paginationJSX = useMemo(() => {
if (!tableConfig.pagination?.enabled || isDesignMode) return null;
2025-09-23 14:26:18 +09:00
2025-10-23 16:50:41 +09:00
return (
2025-09-29 17:24:06 +09:00
<div
className="w-full h-14 flex items-center justify-center relative border-t-2 border-border bg-background px-4 flex-shrink-0 sm:h-[60px] sm:px-6"
>
2025-10-23 16:50:41 +09:00
{/* 중앙 페이지네이션 컨트롤 */}
<div
className="flex items-center gap-2 sm:gap-4"
2025-10-23 16:50:41 +09:00
>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(1)}
disabled={currentPage === 1 || loading}
className="h-8 w-8 p-0 sm:h-9 sm:w-auto sm:px-3"
2025-10-23 16:50:41 +09:00
>
<ChevronsLeft className="h-3 w-3 sm:h-4 sm:w-4" />
2025-10-23 16:50:41 +09:00
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1 || loading}
className="h-8 w-8 p-0 sm:h-9 sm:w-auto sm:px-3"
2025-10-23 16:50:41 +09:00
>
<ChevronLeft className="h-3 w-3 sm:h-4 sm:w-4" />
2025-10-23 16:50:41 +09:00
</Button>
<span className="text-xs font-medium text-foreground min-w-[60px] text-center sm:text-sm sm:min-w-[80px]">
2025-10-23 16:50:41 +09:00
{currentPage} / {totalPages || 1}
</span>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= totalPages || loading}
className="h-8 w-8 p-0 sm:h-9 sm:w-auto sm:px-3"
2025-10-23 16:50:41 +09:00
>
<ChevronRight className="h-3 w-3 sm:h-4 sm:w-4" />
2025-10-23 16:50:41 +09:00
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(totalPages)}
disabled={currentPage >= totalPages || loading}
className="h-8 w-8 p-0 sm:h-9 sm:w-auto sm:px-3"
2025-10-23 16:50:41 +09:00
>
<ChevronsRight className="h-3 w-3 sm:h-4 sm:w-4" />
2025-10-23 16:50:41 +09:00
</Button>
<span className="text-[10px] text-muted-foreground ml-2 sm:text-xs sm:ml-4">
2025-10-23 16:50:41 +09:00
{totalItems.toLocaleString()}
</span>
</div>
{/* 우측 새로고침 버튼 */}
<Button
variant="ghost"
size="sm"
onClick={handleRefresh}
disabled={loading}
className="absolute right-2 h-8 w-8 p-0 sm:right-6 sm:h-9 sm:w-auto sm:px-3"
2025-10-23 16:50:41 +09:00
>
<RefreshCw className={cn("h-3 w-3", loading && "animate-spin")} />
2025-10-23 16:50:41 +09:00
</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.filter?.enabled && (
<div className="px-4 py-3 border-b border-border sm:px-6 sm:py-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
<div className="flex-1">
2025-10-23 16:50:41 +09:00
<AdvancedSearchFilters
filters={activeFilters}
searchValues={searchValues}
onSearchValueChange={handleSearchValueChange}
onSearch={handleAdvancedSearch}
onClear={handleClearAdvancedFilters}
/>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setIsFilterSettingOpen(true)}
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
>
<Settings className="mr-2 h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setIsGroupSettingOpen(true)}
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
>
<Layers className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
</div>
)}
{/* 그룹 표시 배지 */}
{groupByColumns.length > 0 && (
<div className="px-4 py-2 border-b border-border bg-muted/30 sm:px-6">
<div className="flex items-center gap-2 text-xs sm:text-sm">
<span className="text-muted-foreground">:</span>
<div className="flex flex-wrap items-center gap-2">
{groupByColumns.map((col, idx) => (
<span key={col} className="flex items-center">
{idx > 0 && <span className="text-muted-foreground mx-1"></span>}
<span className="bg-primary/10 text-primary rounded px-2 py-1 text-xs font-medium">
{columnLabels[col] || col}
</span>
</span>
))}
</div>
<button
onClick={clearGrouping}
className="hover:bg-destructive/10 text-destructive ml-auto rounded p-1"
title="그룹 해제"
2025-10-23 16:50:41 +09:00
>
<X className="h-4 w-4" />
</button>
2025-09-15 11:43:59 +09:00
</div>
</div>
2025-10-23 16:50:41 +09:00
)}
<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}
2025-10-28 18:41:45 +09:00
formatCellValue={(value: any, format?: string, columnName?: string, rowData?: Record<string, any>) => {
2025-10-23 16:50:41 +09:00
const column = visibleColumns.find((c) => c.columnName === columnName);
2025-10-28 18:41:45 +09:00
return column ? formatCellValue(value, column, rowData) : String(value);
2025-10-23 16:50:41 +09:00
}}
getColumnWidth={getColumnWidth}
containerWidth={calculatedWidth}
/>
</div>
{paginationJSX}
</div>
);
}
// 일반 테이블 모드 (네이티브 HTML 테이블)
return (
<>
<div {...domProps}>
{/* 필터 */}
{tableConfig.filter?.enabled && (
<div className="px-4 py-3 border-b border-border flex-shrink-0 sm:px-6 sm:py-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
<div className="flex-1">
2025-10-23 16:50:41 +09:00
<AdvancedSearchFilters
filters={activeFilters}
searchValues={searchValues}
onSearchValueChange={handleSearchValueChange}
onSearch={handleAdvancedSearch}
onClear={handleClearAdvancedFilters}
/>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setIsFilterSettingOpen(true)}
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
>
<Settings className="mr-2 h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setIsGroupSettingOpen(true)}
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
>
<Layers className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
</div>
)}
{/* 그룹 표시 배지 */}
{groupByColumns.length > 0 && (
<div className="px-4 py-2 border-b border-border bg-muted/30 sm:px-6">
<div className="flex items-center gap-2 text-xs sm:text-sm">
<span className="text-muted-foreground">:</span>
<div className="flex flex-wrap items-center gap-2">
{groupByColumns.map((col, idx) => (
<span key={col} className="flex items-center">
{idx > 0 && <span className="text-muted-foreground mx-1"></span>}
<span className="bg-primary/10 text-primary rounded px-2 py-1 text-xs font-medium">
{columnLabels[col] || col}
</span>
</span>
))}
</div>
<button
onClick={clearGrouping}
className="hover:bg-destructive/10 text-destructive ml-auto rounded p-1"
title="그룹 해제"
2025-10-23 16:50:41 +09:00
>
<X className="h-4 w-4" />
</button>
2025-10-23 16:50:41 +09:00
</div>
</div>
2025-10-23 16:50:41 +09:00
)}
{/* 테이블 컨테이너 */}
<div className="flex-1 flex flex-col overflow-hidden w-full max-w-full">
2025-10-23 16:50:41 +09:00
{/* 스크롤 영역 */}
<div
className="w-full max-w-full h-[400px] overflow-y-scroll overflow-x-auto bg-background sm:h-[500px]"
2025-10-23 16:50:41 +09:00
>
{/* 테이블 */}
<table
className="w-full max-w-full table-mobile-fixed"
2025-09-29 17:24:06 +09:00
style={{
2025-10-23 16:50:41 +09:00
borderCollapse: "collapse",
width: "100%",
tableLayout: "fixed",
}}
>
2025-10-23 16:50:41 +09:00
{/* 헤더 (sticky) */}
<thead
className="sticky top-0 z-10"
2025-09-29 17:24:06 +09:00
>
<tr className="h-10 border-b-2 border-primary/20 bg-gradient-to-b from-muted/50 to-muted sm:h-12">
{visibleColumns.map((column, columnIndex) => {
const columnWidth = columnWidths[column.columnName];
return (
<th
key={column.columnName}
ref={(el) => (columnRefs.current[column.columnName] = el)}
className={cn(
"relative h-10 text-xs font-bold text-foreground/90 overflow-hidden text-ellipsis whitespace-nowrap select-none sm:h-12 sm:text-sm",
column.columnName === "__checkbox__" ? "px-0 py-2" : "px-2 py-2 sm:px-6 sm:py-3",
column.sortable && "cursor-pointer hover:bg-muted/70 transition-colors"
)}
style={{
textAlign: column.columnName === "__checkbox__" ? "center" : "center",
width: column.columnName === "__checkbox__" ? '48px' : (columnWidth ? `${columnWidth}px` : undefined),
minWidth: column.columnName === "__checkbox__" ? '48px' : undefined,
maxWidth: column.columnName === "__checkbox__" ? '48px' : undefined,
userSelect: 'none'
}}
onClick={() => {
if (isResizing.current) return;
if (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>
)}
{/* 리사이즈 핸들 (체크박스 제외) */}
{columnIndex < visibleColumns.length - 1 && column.columnName !== "__checkbox__" && (
<div
className="absolute right-0 top-0 h-full w-2 cursor-col-resize hover:bg-blue-500 z-20"
style={{ marginRight: '-4px', paddingLeft: '4px', paddingRight: '4px' }}
onClick={(e) => e.stopPropagation()} // 정렬 클릭 방지
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
const thElement = columnRefs.current[column.columnName];
if (!thElement) return;
isResizing.current = true;
const startX = e.clientX;
const startWidth = columnWidth || thElement.offsetWidth;
// 드래그 중 텍스트 선택 방지
document.body.style.userSelect = 'none';
document.body.style.cursor = 'col-resize';
const handleMouseMove = (moveEvent: MouseEvent) => {
moveEvent.preventDefault();
const diff = moveEvent.clientX - startX;
const newWidth = Math.max(80, startWidth + diff);
// 직접 DOM 스타일 변경 (리렌더링 없음)
if (thElement) {
thElement.style.width = `${newWidth}px`;
}
};
const handleMouseUp = () => {
// 최종 너비를 state에 저장
if (thElement) {
const finalWidth = Math.max(80, thElement.offsetWidth);
setColumnWidths(prev => ({ ...prev, [column.columnName]: finalWidth }));
}
// 텍스트 선택 복원
document.body.style.userSelect = '';
document.body.style.cursor = '';
// 약간의 지연 후 리사이즈 플래그 해제 (클릭 이벤트가 먼저 처리되지 않도록)
setTimeout(() => {
isResizing.current = false;
}, 100);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}}
/>
)}
</th>
);
})}
2025-10-23 16:50:41 +09:00
</tr>
</thead>
{/* 바디 (스크롤) */}
<tbody>
{loading ? (
<tr>
<td colSpan={visibleColumns.length} className="p-12 text-center">
<div className="flex flex-col items-center gap-3">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
<div className="text-sm font-medium text-muted-foreground"> ...</div>
2025-10-23 16:50:41 +09:00
</div>
</td>
</tr>
) : error ? (
<tr>
<td colSpan={visibleColumns.length} className="p-12 text-center">
<div className="flex flex-col items-center gap-3">
<div className="text-sm font-medium text-destructive"> </div>
<div className="text-xs text-muted-foreground">{error}</div>
2025-10-23 16:50:41 +09:00
</div>
</td>
</tr>
) : data.length === 0 ? (
<tr>
<td colSpan={visibleColumns.length} className="p-12 text-center">
<div className="flex flex-col items-center gap-3">
<TableIcon className="h-12 w-12 text-muted-foreground/50" />
<div className="text-sm font-medium text-muted-foreground"> </div>
<div className="text-xs text-muted-foreground">
2025-10-23 16:50:41 +09:00
2025-09-29 17:24:06 +09:00
</div>
</div>
2025-10-23 16:50:41 +09:00
</td>
</tr>
) : groupByColumns.length > 0 && groupedData.length > 0 ? (
// 그룹화된 렌더링
groupedData.map((group) => {
const isCollapsed = collapsedGroups.has(group.groupKey);
return (
<React.Fragment key={group.groupKey}>
{/* 그룹 헤더 */}
<tr>
<td
colSpan={visibleColumns.length}
className="bg-muted/50 border-b border-border sticky top-[48px] z-[5]"
style={{ top: "48px" }}
>
<div
className="flex items-center gap-3 p-3 cursor-pointer hover:bg-muted"
onClick={() => toggleGroupCollapse(group.groupKey)}
>
{isCollapsed ? (
<ChevronRight className="h-4 w-4 flex-shrink-0" />
) : (
<ChevronDown className="h-4 w-4 flex-shrink-0" />
)}
<span className="font-medium text-sm flex-1">{group.groupKey}</span>
<span className="text-muted-foreground text-xs">({group.count})</span>
</div>
</td>
</tr>
{/* 그룹 데이터 */}
{!isCollapsed &&
group.items.map((row, index) => (
<tr
key={index}
draggable={!isDesignMode}
onDragStart={(e) => handleRowDragStart(e, row, index)}
onDragEnd={handleRowDragEnd}
className={cn(
"h-14 border-b transition-colors bg-background hover:bg-muted/50 cursor-pointer sm:h-16"
)}
onClick={() => handleRowClick(row)}
>
{visibleColumns.map((column) => {
const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName;
const cellValue = row[mappedColumnName];
const meta = columnMeta[column.columnName];
const inputType = meta?.inputType || column.inputType;
const isNumeric = inputType === "number" || inputType === "decimal";
return (
<td
key={column.columnName}
className={cn(
"h-14 text-xs text-foreground overflow-hidden text-ellipsis whitespace-nowrap sm:h-16 sm:text-sm",
column.columnName === "__checkbox__" ? "px-0 py-2" : "px-2 py-2 sm:px-6 sm:py-3"
)}
style={{
textAlign: column.columnName === "__checkbox__" ? "center" : (isNumeric ? "right" : (column.align || "left")),
width: column.columnName === "__checkbox__" ? '48px' : `${100 / visibleColumns.length}%`,
minWidth: column.columnName === "__checkbox__" ? '48px' : undefined,
maxWidth: column.columnName === "__checkbox__" ? '48px' : undefined,
}}
>
{column.columnName === "__checkbox__"
? renderCheckboxCell(row, index)
: formatCellValue(cellValue, column, row)}
</td>
);
})}
</tr>
))}
</React.Fragment>
);
})
2025-09-29 17:24:06 +09:00
) : (
// 일반 렌더링 (그룹 없음)
2025-09-29 17:24:06 +09:00
data.map((row, index) => (
2025-10-23 16:50:41 +09:00
<tr
2025-09-29 17:24:06 +09:00
key={index}
draggable={!isDesignMode}
onDragStart={(e) => handleRowDragStart(e, row, index)}
onDragEnd={handleRowDragEnd}
className={cn(
"h-14 border-b transition-colors bg-background hover:bg-muted/50 cursor-pointer sm:h-16"
)}
2025-09-29 17:24:06 +09:00
onClick={() => handleRowClick(row)}
>
2025-10-23 16:50:41 +09:00
{visibleColumns.map((column) => {
const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName;
const cellValue = row[mappedColumnName];
const meta = columnMeta[column.columnName];
const inputType = meta?.inputType || column.inputType;
const isNumeric = inputType === "number" || inputType === "decimal";
2025-10-23 16:50:41 +09:00
return (
<td
key={column.columnName}
className={cn(
"h-14 text-xs text-foreground overflow-hidden text-ellipsis whitespace-nowrap sm:h-16 sm:text-sm",
column.columnName === "__checkbox__" ? "px-0 py-2" : "px-2 py-2 sm:px-6 sm:py-3"
)}
2025-10-23 16:50:41 +09:00
style={{
textAlign: column.columnName === "__checkbox__" ? "center" : (isNumeric ? "right" : (column.align || "left")),
width: column.columnName === "__checkbox__" ? '48px' : `${100 / visibleColumns.length}%`,
minWidth: column.columnName === "__checkbox__" ? '48px' : undefined,
maxWidth: column.columnName === "__checkbox__" ? '48px' : undefined,
2025-10-23 16:50:41 +09:00
}}
>
{column.columnName === "__checkbox__"
? renderCheckboxCell(row, index)
2025-10-28 18:41:45 +09:00
: formatCellValue(cellValue, column, row)}
2025-10-23 16:50:41 +09:00
</td>
);
})}
</tr>
2025-09-29 17:24:06 +09:00
))
)}
2025-10-23 16:50:41 +09:00
</tbody>
</table>
</div>
2025-10-23 16:50:41 +09:00
</div>
2025-09-15 11:43:59 +09:00
2025-10-23 16:50:41 +09:00
{/* 페이지네이션 */}
{paginationJSX}
</div>
2025-09-15 11:43:59 +09:00
2025-10-23 16:50:41 +09:00
{/* 필터 설정 다이얼로그 */}
<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">
. .
2025-10-23 16:50:41 +09:00
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
{/* 전체 선택/해제 */}
<div className="bg-muted/50 flex items-center gap-3 rounded border p-3">
<Checkbox
id="select-all-filters"
checked={
visibleFilterColumns.size ===
visibleColumns.filter((col) => col.columnName !== "__checkbox__").length &&
visibleColumns.filter((col) => col.columnName !== "__checkbox__").length > 0
}
onCheckedChange={toggleAllFilters}
/>
<Label htmlFor="select-all-filters" className="flex-1 cursor-pointer text-xs font-semibold sm:text-sm">
/
</Label>
<span className="text-muted-foreground text-xs">
{visibleFilterColumns.size} / {visibleColumns.filter((col) => col.columnName !== "__checkbox__").length}
</span>
</div>
{/* 컬럼 목록 */}
<div className="max-h-[50vh] space-y-2 overflow-y-auto rounded border p-2">
{visibleColumns
.filter((col) => col.columnName !== "__checkbox__")
.map((col) => (
<div key={col.columnName} className="hover:bg-muted/50 flex items-center gap-3 rounded p-2">
<Checkbox
id={`filter-${col.columnName}`}
checked={visibleFilterColumns.has(col.columnName)}
onCheckedChange={() => toggleFilterVisibility(col.columnName)}
/>
<Label
htmlFor={`filter-${col.columnName}`}
className="flex-1 cursor-pointer text-xs font-normal sm:text-sm"
>
{columnLabels[col.columnName] || col.displayName || col.columnName}
</Label>
</div>
))}
</div>
{/* 선택된 컬럼 개수 안내 */}
<div className="text-muted-foreground bg-muted/30 rounded p-3 text-center text-xs">
{visibleFilterColumns.size === 0 ? (
<span> 1 </span>
) : (
<span>
<span className="text-primary font-semibold">{visibleFilterColumns.size}</span>
</span>
)}
</div>
2025-09-15 11:43:59 +09:00
</div>
2025-10-23 16:50:41 +09:00
<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>
{/* 그룹 설정 다이얼로그 */}
<Dialog open={isGroupSettingOpen} onOpenChange={setIsGroupSettingOpen}>
<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="space-y-3 sm:space-y-4">
{/* 컬럼 목록 */}
<div className="max-h-[50vh] space-y-2 overflow-y-auto rounded border p-2">
{visibleColumns
.filter((col) => col.columnName !== "__checkbox__")
.map((col) => (
<div key={col.columnName} className="hover:bg-muted/50 flex items-center gap-3 rounded p-2">
<Checkbox
id={`group-${col.columnName}`}
checked={groupByColumns.includes(col.columnName)}
onCheckedChange={() => toggleGroupColumn(col.columnName)}
/>
<Label
htmlFor={`group-${col.columnName}`}
className="flex-1 cursor-pointer text-xs font-normal sm:text-sm"
>
{columnLabels[col.columnName] || col.displayName || col.columnName}
</Label>
</div>
))}
</div>
{/* 선택된 그룹 안내 */}
<div className="text-muted-foreground bg-muted/30 rounded p-3 text-xs">
{groupByColumns.length === 0 ? (
<span> </span>
) : (
<span>
:{" "}
<span className="text-primary font-semibold">
{groupByColumns.map((col) => columnLabels[col] || col).join(" → ")}
</span>
</span>
)}
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setIsGroupSettingOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button onClick={saveGroupSettings} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
2025-10-23 16:50:41 +09:00
</>
2025-09-15 11:43:59 +09:00
);
};
export const TableListWrapper: React.FC<TableListComponentProps> = (props) => {
return <TableListComponent {...props} />;
};