From 417d77729dcea4c22dbcfd2f963b7253cae65031 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Fri, 5 Dec 2025 16:44:58 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9D=BC=EB=8B=A8=20=EC=9B=94=EC=9A=94?= =?UTF-8?q?=EC=9D=BC=EC=97=90=20=EC=83=81=EC=9D=98=ED=95=B4=EC=95=BC?= =?UTF-8?q?=ED=95=B4=EC=84=9C=20=EC=97=AC=EA=B8=B0=EC=97=90=EB=8B=A4?= =?UTF-8?q?=EB=A7=8C=20=EC=BB=A4=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../table-list/TableListComponent.tsx | 3204 ++++++++++++++++- 1 file changed, 3059 insertions(+), 145 deletions(-) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 4f78ed23..ca2125e2 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -22,7 +22,18 @@ import { X, Layers, ChevronDown, + Filter, + Check, + Download, + FileSpreadsheet, + Copy, + ClipboardCopy, + Edit, + CheckSquare, + Trash2, } from "lucide-react"; +import * as XLSX from "xlsx"; +import { FileText, ChevronRightIcon } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; @@ -62,6 +73,7 @@ interface GroupedData { groupValues: Record; items: any[]; count: number; + summary?: Record; // ๐Ÿ†• ๊ทธ๋ฃน๋ณ„ ์†Œ๊ณ„ } // ======================================== @@ -125,6 +137,35 @@ const debouncedApiCall = (key: string, fn: (...args: T) => P }; }; +// ======================================== +// Filter Builder ์ธํ„ฐํŽ˜์ด์Šค +// ======================================== + +interface FilterCondition { + id: string; + column: string; + operator: + | "equals" + | "notEquals" + | "contains" + | "notContains" + | "startsWith" + | "endsWith" + | "greaterThan" + | "lessThan" + | "greaterOrEqual" + | "lessOrEqual" + | "isEmpty" + | "isNotEmpty"; + value: string; +} + +interface FilterGroup { + id: string; + logic: "AND" | "OR"; + conditions: FilterCondition[]; +} + // ======================================== // Props ์ธํ„ฐํŽ˜์ด์Šค // ======================================== @@ -328,28 +369,153 @@ export const TableListComponent: React.FC = ({ } }, [columnVisibility, tableConfig.selectedTable, currentUserId]); + // ๐Ÿ†• columnOrder๋ฅผ visibleColumns ์ด์ „์— ์ •์˜ (visibleColumns์—์„œ ์‚ฌ์šฉ) + const [columnOrder, setColumnOrder] = useState([]); + + // ๐Ÿ†• visibleColumns๋ฅผ ์ƒ๋‹จ์—์„œ ์ •์˜ (๋‹ค๋ฅธ useCallback/useMemo์—์„œ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•ด) + const visibleColumns = useMemo(() => { + let cols = (tableConfig.columns || []).filter((col) => col.visible !== false); + + // columnVisibility๊ฐ€ ์žˆ์œผ๋ฉด ๊ฐ€์‹œ์„ฑ ์ ์šฉ + if (columnVisibility.length > 0) { + cols = cols.filter((col) => { + const visibilityConfig = columnVisibility.find((cv) => cv.columnName === col.columnName); + return visibilityConfig ? visibilityConfig.visible : true; + }); + } + + // ์ฒดํฌ๋ฐ•์Šค ์ปฌ๋Ÿผ (๋‚˜์ค‘์— ์œ„์น˜ ๊ฒฐ์ •) + // ๊ธฐ๋ณธ๊ฐ’: enabled๊ฐ€ undefined๋ฉด true๋กœ ์ฒ˜๋ฆฌ + let checkboxCol: ColumnConfig | null = null; + if (tableConfig.checkbox?.enabled ?? true) { + checkboxCol = { + columnName: "__checkbox__", + displayName: "", + webType: "checkbox", + visible: true, + sortable: false, + filterable: false, + width: 40, + }; + } + + // columnOrder๊ฐ€ ์žˆ์œผ๋ฉด ํ•ด๋‹น ์ˆœ์„œ๋กœ ์ •๋ ฌ + if (columnOrder.length > 0) { + const orderMap = new Map(columnOrder.map((name, idx) => [name, idx])); + cols = [...cols].sort((a, b) => { + const aIdx = orderMap.get(a.columnName) ?? 9999; + const bIdx = orderMap.get(b.columnName) ?? 9999; + return aIdx - bIdx; + }); + } + + // ์ฒดํฌ๋ฐ•์Šค ์œ„์น˜ ๊ฒฐ์ • + if (checkboxCol) { + const checkboxPosition = tableConfig.checkbox?.position || "left"; + if (checkboxPosition === "left") { + return [checkboxCol, ...cols]; + } else { + return [...cols, checkboxCol]; + } + } + + return cols; + }, [tableConfig.columns, tableConfig.checkbox, columnVisibility, columnOrder]); + const [data, setData] = useState[]>([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - // ๐Ÿ†• ๋ถ„ํ•  ํŒจ๋„์—์„œ ์šฐ์ธก์— ์ด๋ฏธ ์ถ”๊ฐ€๋œ ํ•ญ๋ชฉ ํ•„ํ„ฐ๋ง (์ขŒ์ธก ํ…Œ์ด๋ธ”์—๋งŒ ์ ์šฉ) + // ๐Ÿ†• ์ปฌ๋Ÿผ ํ—ค๋” ํ•„ํ„ฐ ์ƒํƒœ (์ƒ๋‹จ์—์„œ ์„ ์–ธ) + const [headerFilters, setHeaderFilters] = useState>>({}); + const [openFilterColumn, setOpenFilterColumn] = useState(null); + + // ๐Ÿ†• Filter Builder (๊ณ ๊ธ‰ ํ•„ํ„ฐ) ๊ด€๋ จ ์ƒํƒœ - filteredData๋ณด๋‹ค ๋จผ์ € ์ •์˜ํ•ด์•ผ ํ•จ + const [filterGroups, setFilterGroups] = useState([]); + + // ๐Ÿ†• ๋ถ„ํ•  ํŒจ๋„์—์„œ ์šฐ์ธก์— ์ด๋ฏธ ์ถ”๊ฐ€๋œ ํ•ญ๋ชฉ ํ•„ํ„ฐ๋ง (์ขŒ์ธก ํ…Œ์ด๋ธ”์—๋งŒ ์ ์šฉ) + ํ—ค๋” ํ•„ํ„ฐ const filteredData = useMemo(() => { - // ๋ถ„ํ•  ํŒจ๋„ ์ขŒ์ธก์— ์žˆ๊ณ , ์šฐ์ธก์— ์ถ”๊ฐ€๋œ ํ•ญ๋ชฉ์ด ์žˆ๋Š” ๊ฒฝ์šฐ์—๋งŒ ํ•„ํ„ฐ๋ง + let result = data; + + // 1. ๋ถ„ํ•  ํŒจ๋„ ์ขŒ์ธก์— ์žˆ๊ณ , ์šฐ์ธก์— ์ถ”๊ฐ€๋œ ํ•ญ๋ชฉ์ด ์žˆ๋Š” ๊ฒฝ์šฐ ํ•„ํ„ฐ๋ง if (splitPanelPosition === "left" && splitPanelContext?.addedItemIds && splitPanelContext.addedItemIds.size > 0) { const addedIds = splitPanelContext.addedItemIds; - const filtered = data.filter((row) => { + result = result.filter((row) => { const rowId = String(row.id || row.po_item_id || row.item_id || ""); return !addedIds.has(rowId); }); - console.log("๐Ÿ” [TableList] ์šฐ์ธก ์ถ”๊ฐ€ ํ•ญ๋ชฉ ํ•„ํ„ฐ๋ง:", { - originalCount: data.length, - filteredCount: filtered.length, - addedIdsCount: addedIds.size, - }); - return filtered; } - return data; - }, [data, splitPanelPosition, splitPanelContext?.addedItemIds]); + + // 2. ํ—ค๋” ํ•„ํ„ฐ ์ ์šฉ (joinColumnMapping ์‚ฌ์šฉ ์•ˆ ํ•จ - ์ง์ ‘ ์ปฌ๋Ÿผ๋ช… ์‚ฌ์šฉ) + if (Object.keys(headerFilters).length > 0) { + result = result.filter((row) => { + return Object.entries(headerFilters).every(([columnName, values]) => { + if (values.size === 0) return true; + + // ์—ฌ๋Ÿฌ ๊ฐ€๋Šฅํ•œ ์ปฌ๋Ÿผ๋ช… ์‹œ๋„ + const cellValue = row[columnName] ?? row[columnName.toLowerCase()] ?? row[columnName.toUpperCase()]; + const cellStr = cellValue !== null && cellValue !== undefined ? String(cellValue) : ""; + + return values.has(cellStr); + }); + }); + } + + // 3. ๐Ÿ†• Filter Builder ์ ์šฉ + if (filterGroups.length > 0) { + result = result.filter((row) => { + return filterGroups.every((group) => { + const validConditions = group.conditions.filter( + (c) => c.column && (c.operator === "isEmpty" || c.operator === "isNotEmpty" || c.value) + ); + if (validConditions.length === 0) return true; + + const evaluateCondition = (value: any, condition: typeof group.conditions[0]): boolean => { + const strValue = value !== null && value !== undefined ? String(value).toLowerCase() : ""; + const condValue = condition.value.toLowerCase(); + + switch (condition.operator) { + case "equals": + return strValue === condValue; + case "notEquals": + return strValue !== condValue; + case "contains": + return strValue.includes(condValue); + case "notContains": + return !strValue.includes(condValue); + case "startsWith": + return strValue.startsWith(condValue); + case "endsWith": + return strValue.endsWith(condValue); + case "greaterThan": + return parseFloat(strValue) > parseFloat(condValue); + case "lessThan": + return parseFloat(strValue) < parseFloat(condValue); + case "greaterOrEqual": + return parseFloat(strValue) >= parseFloat(condValue); + case "lessOrEqual": + return parseFloat(strValue) <= parseFloat(condValue); + case "isEmpty": + return strValue === "" || value === null || value === undefined; + case "isNotEmpty": + return strValue !== "" && value !== null && value !== undefined; + default: + return true; + } + }; + + if (group.logic === "AND") { + return validConditions.every((cond) => evaluateCondition(row[cond.column], cond)); + } else { + return validConditions.some((cond) => evaluateCondition(row[cond.column], cond)); + } + }); + }); + } + + return result; + }, [data, splitPanelPosition, splitPanelContext?.addedItemIds, headerFilters, filterGroups]); + const [currentPage, setCurrentPage] = useState(1); const [totalPages, setTotalPages] = useState(0); const [totalItems, setTotalItems] = useState(0); @@ -373,7 +539,7 @@ export const TableListComponent: React.FC = ({ const [selectedRows, setSelectedRows] = useState>(new Set()); const [columnWidths, setColumnWidths] = useState>({}); const [refreshTrigger, setRefreshTrigger] = useState(0); - const [columnOrder, setColumnOrder] = useState([]); + // columnOrder๋Š” ์ƒ๋‹จ์—์„œ ์ •์˜๋จ (visibleColumns๋ณด๋‹ค ๋จผ์ € ํ•„์š”) const columnRefs = useRef>({}); const [isAllSelected, setIsAllSelected] = useState(false); const hasInitializedWidths = useRef(false); @@ -383,16 +549,117 @@ export const TableListComponent: React.FC = ({ const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false); const [visibleFilterColumns, setVisibleFilterColumns] = useState>(new Set()); + // ๐Ÿ†• ํ‚ค๋ณด๋“œ ๋„ค๋น„๊ฒŒ์ด์…˜ ๊ด€๋ จ ์ƒํƒœ + const [focusedCell, setFocusedCell] = useState<{ rowIndex: number; colIndex: number } | null>(null); + const tableContainerRef = useRef(null); + + // ๐Ÿ†• ์ธ๋ผ์ธ ์…€ ํŽธ์ง‘ ๊ด€๋ จ ์ƒํƒœ + const [editingCell, setEditingCell] = useState<{ + rowIndex: number; + colIndex: number; + columnName: string; + originalValue: any; + } | null>(null); + const [editingValue, setEditingValue] = useState(""); + const editInputRef = useRef(null); + + // ๐Ÿ†• ๋ฐฐ์น˜ ํŽธ์ง‘ ๊ด€๋ จ ์ƒํƒœ + const [editMode, setEditMode] = useState<"immediate" | "batch">("immediate"); // ํŽธ์ง‘ ๋ชจ๋“œ + const [pendingChanges, setPendingChanges] = useState>(new Map()); // key: `${rowIndex}-${columnName}` + const [localEditedData, setLocalEditedData] = useState>>({}); // ๋กœ์ปฌ ์ˆ˜์ • ๋ฐ์ดํ„ฐ + + // ๐Ÿ†• ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ๊ด€๋ จ ์ƒํƒœ + const [validationErrors, setValidationErrors] = useState>(new Map()); // key: `${rowIndex}-${columnName}` + + // ๐Ÿ†• ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ๊ทœ์น™ ํƒ€์ž… + type ValidationRule = { + required?: boolean; + min?: number; + max?: number; + minLength?: number; + maxLength?: number; + pattern?: RegExp; + customMessage?: string; + validate?: (value: any, row: any) => string | null; // ์ปค์Šคํ…€ ๊ฒ€์ฆ ํ•จ์ˆ˜ (์—๋Ÿฌ ๋ฉ”์‹œ์ง€ ๋˜๋Š” null) + }; + + // ๐Ÿ†• Cascading Lookups ๊ด€๋ จ ์ƒํƒœ + const [cascadingOptions, setCascadingOptions] = useState>({}); + const [loadingCascading, setLoadingCascading] = useState>({}); + + // ๐Ÿ†• Multi-Level Headers (Column Bands) ํƒ€์ž… + type ColumnBand = { + caption: string; + columns: string[]; // ํฌํ•จ๋  ์ปฌ๋Ÿผ๋ช… ๋ฐฐ์—ด + }; + // ๊ทธ๋ฃน ์„ค์ • ๊ด€๋ จ ์ƒํƒœ const [groupByColumns, setGroupByColumns] = useState([]); const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); + // ๐Ÿ†• Master-Detail ๊ด€๋ จ ์ƒํƒœ + const [expandedRows, setExpandedRows] = useState>(new Set()); // ํ™•์žฅ๋œ ํ–‰ ํ‚ค ๋ชฉ๋ก + const [detailData, setDetailData] = useState>({}); // ์ƒ์„ธ ๋ฐ์ดํ„ฐ ์บ์‹œ + + // ๐Ÿ†• Drag & Drop ์žฌ์ •๋ ฌ ๊ด€๋ จ ์ƒํƒœ + const [draggedRowIndex, setDraggedRowIndex] = useState(null); + const [dropTargetIndex, setDropTargetIndex] = useState(null); + const [isDragEnabled, setIsDragEnabled] = useState((tableConfig as any).enableRowDrag ?? false); + + // ๐Ÿ†• Virtual Scrolling ๊ด€๋ จ ์ƒํƒœ + const [isVirtualScrollEnabled] = useState((tableConfig as any).virtualScroll ?? false); + const [scrollTop, setScrollTop] = useState(0); + const ROW_HEIGHT = 40; // ๊ฐ ํ–‰์˜ ๋†’์ด (ํ”ฝ์…€) + const OVERSCAN = 5; // ๋ฒ„ํผ๋กœ ์ถ”๊ฐ€ ๋ Œ๋”๋งํ•  ํ–‰ ์ˆ˜ + const scrollContainerRef = useRef(null); + + // ๐Ÿ†• Column Reordering ๊ด€๋ จ ์ƒํƒœ + const [draggedColumnIndex, setDraggedColumnIndex] = useState(null); + const [dropTargetColumnIndex, setDropTargetColumnIndex] = useState(null); + const [isColumnDragEnabled] = useState((tableConfig as any).enableColumnDrag ?? true); + + // ๐Ÿ†• State Persistence: ํ†ตํ•ฉ ์ƒํƒœ ํ‚ค + const tableStateKey = useMemo(() => { + if (!tableConfig.selectedTable) return null; + return `tableState_${tableConfig.selectedTable}`; + }, [tableConfig.selectedTable]); + + // ๐Ÿ†• Real-Time Updates ๊ด€๋ จ ์ƒํƒœ + const [isRealTimeEnabled] = useState((tableConfig as any).realTimeUpdates ?? false); + const [wsConnectionStatus, setWsConnectionStatus] = useState<"connecting" | "connected" | "disconnected">("disconnected"); + const wsRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + + // ๐Ÿ†• Context Menu ๊ด€๋ จ ์ƒํƒœ + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + rowIndex: number; + colIndex: number; + row: any; + } | null>(null); + // ์‚ฌ์šฉ์ž ์˜ต์…˜ ๋ชจ๋‹ฌ ๊ด€๋ จ ์ƒํƒœ const [isTableOptionsOpen, setIsTableOptionsOpen] = useState(false); const [showGridLines, setShowGridLines] = useState(true); const [viewMode, setViewMode] = useState<"table" | "card" | "grouped-card">("table"); const [frozenColumns, setFrozenColumns] = useState([]); + // ๐Ÿ†• Search Panel (ํ†ตํ•ฉ ๊ฒ€์ƒ‰) ๊ด€๋ จ ์ƒํƒœ + const [globalSearchTerm, setGlobalSearchTerm] = useState(""); + const [isSearchPanelOpen, setIsSearchPanelOpen] = useState(false); + const [searchHighlights, setSearchHighlights] = useState>(new Set()); // "rowIndex-colIndex" ํ˜•์‹ + + // ๐Ÿ†• Filter Builder (๊ณ ๊ธ‰ ํ•„ํ„ฐ) ๊ด€๋ จ ์ƒํƒœ ์ถ”๊ฐ€ + const [isFilterBuilderOpen, setIsFilterBuilderOpen] = useState(false); + const [activeFilterCount, setActiveFilterCount] = useState(0); + // ๐Ÿ†• ์—ฐ๊ฒฐ๋œ ํ•„ํ„ฐ ์ฒ˜๋ฆฌ (์…€๋ ‰ํŠธ๋ฐ•์Šค ๋“ฑ ๋‹ค๋ฅธ ์ปดํฌ๋„ŒํŠธ ๊ฐ’์œผ๋กœ ํ•„ํ„ฐ๋ง) useEffect(() => { const linkedFilters = tableConfig.linkedFilters; @@ -796,9 +1063,10 @@ export const TableListComponent: React.FC = ({ // ์ „์—ญ ์ €์žฅ์†Œ์— ๋ฐ์ดํ„ฐ ์ €์žฅ if (tableConfig.selectedTable) { - // ์ปฌ๋Ÿผ ๋ผ๋ฒจ ๋งคํ•‘ ์ƒ์„ฑ + // ์ปฌ๋Ÿผ ๋ผ๋ฒจ ๋งคํ•‘ ์ƒ์„ฑ (tableConfig.columns ์‚ฌ์šฉ - visibleColumns๋Š” ์•„์ง ์ •์˜๋˜์ง€ ์•Š์Œ) + const cols = (tableConfig.columns || []).filter((col) => col.visible !== false); const labels: Record = {}; - visibleColumns.forEach((col) => { + cols.forEach((col) => { labels[col.columnName] = columnLabels[col.columnName] || col.columnName; }); @@ -811,7 +1079,7 @@ export const TableListComponent: React.FC = ({ { filterConditions: Object.keys(searchValues).length > 0 ? searchValues : undefined, searchTerm: searchTerm || undefined, - visibleColumns: visibleColumns.map((col) => col.columnName), + visibleColumns: cols.map((col) => col.columnName), columnLabels: labels, currentPage: currentPage, pageSize: localPageSize, @@ -1284,21 +1552,23 @@ export const TableListComponent: React.FC = ({ setError(null); // ๐ŸŽฏ Store์— ํ•„ํ„ฐ ์กฐ๊ฑด ์ €์žฅ (์—‘์…€ ๋‹ค์šด๋กœ๋“œ์šฉ) + // tableConfig.columns ์‚ฌ์šฉ (visibleColumns๋Š” ์ด ์‹œ์ ์—์„œ ์•„์ง ์ •์˜๋˜์ง€ ์•Š์„ ์ˆ˜ ์žˆ์Œ) + const cols = (tableConfig.columns || []).filter((col) => col.visible !== false); const labels: Record = {}; - visibleColumns.forEach((col) => { + cols.forEach((col) => { labels[col.columnName] = columnLabels[col.columnName] || col.columnName; }); tableDisplayStore.setTableData( tableConfig.selectedTable, response.data || [], - visibleColumns.map((col) => col.columnName), + cols.map((col) => col.columnName), sortBy, sortOrder, { filterConditions: filters, searchTerm: search, - visibleColumns: visibleColumns.map((col) => col.columnName), + visibleColumns: cols.map((col) => col.columnName), columnLabels: labels, currentPage: page, pageSize: pageSize, @@ -1418,9 +1688,11 @@ export const TableListComponent: React.FC = ({ }); // 2๋‹จ๊ณ„: ์ •๋ ฌ๋œ ๋ฐ์ดํ„ฐ๋ฅผ ์ปฌ๋Ÿผ ์ˆœ์„œ๋Œ€๋กœ ์žฌ์ •๋ ฌ + // tableConfig.columns ์‚ฌ์šฉ (visibleColumns๋Š” ์ด ์‹œ์ ์—์„œ ์•„์ง ์ •์˜๋˜์ง€ ์•Š์„ ์ˆ˜ ์žˆ์Œ) + const cols = (tableConfig.columns || []).filter((col) => col.visible !== false); const reorderedData = sortedData.map((row: any) => { const reordered: any = {}; - visibleColumns.forEach((col) => { + cols.forEach((col) => { if (col.columnName in row) { reordered[col.columnName] = row[col.columnName]; } @@ -1456,12 +1728,12 @@ export const TableListComponent: React.FC = ({ // ์ „์—ญ ์ €์žฅ์†Œ์— ์ •๋ ฌ๋œ ๋ฐ์ดํ„ฐ ์ €์žฅ if (tableConfig.selectedTable) { const cleanColumnOrder = ( - columnOrder.length > 0 ? columnOrder : visibleColumns.map((c) => c.columnName) + columnOrder.length > 0 ? columnOrder : cols.map((c) => c.columnName) ).filter((col) => col !== "__checkbox__"); // ์ปฌ๋Ÿผ ๋ผ๋ฒจ ์ •๋ณด๋„ ํ•จ๊ป˜ ์ €์žฅ const labels: Record = {}; - visibleColumns.forEach((col) => { + cols.forEach((col) => { labels[col.columnName] = columnLabels[col.columnName] || col.columnName; }); @@ -1474,7 +1746,7 @@ export const TableListComponent: React.FC = ({ { filterConditions: Object.keys(searchValues).length > 0 ? searchValues : undefined, searchTerm: searchTerm || undefined, - visibleColumns: visibleColumns.map((col) => col.columnName), + visibleColumns: cols.map((col) => col.columnName), columnLabels: labels, currentPage: currentPage, pageSize: localPageSize, @@ -1648,6 +1920,1495 @@ export const TableListComponent: React.FC = ({ console.log("ํ–‰ ํด๋ฆญ:", { row, index, isSelected: !isCurrentlySelected }); }; + // ๐Ÿ†• ์…€ ํด๋ฆญ ํ•ธ๋“ค๋Ÿฌ (ํฌ์ปค์Šค ์„ค์ •) + const handleCellClick = (rowIndex: number, colIndex: number, e: React.MouseEvent) => { + e.stopPropagation(); + setFocusedCell({ rowIndex, colIndex }); + // ํ…Œ์ด๋ธ” ์ปจํ…Œ์ด๋„ˆ์— ํฌ์ปค์Šค ์„ค์ • (ํ‚ค๋ณด๋“œ ์ด๋ฒคํŠธ ์ˆ˜์‹ ์šฉ) + tableContainerRef.current?.focus(); + }; + + // ๐Ÿ†• ์…€ ๋”๋ธ”ํด๋ฆญ ํ•ธ๋“ค๋Ÿฌ (ํŽธ์ง‘ ๋ชจ๋“œ ์ง„์ž…) - visibleColumns ์ •์˜ ํ›„ ์‚ฌ์šฉ + const handleCellDoubleClick = useCallback((rowIndex: number, colIndex: number, columnName: string, value: any) => { + // ์ฒดํฌ๋ฐ•์Šค ์ปฌ๋Ÿผ์€ ํŽธ์ง‘ ๋ถˆ๊ฐ€ + if (columnName === "__checkbox__") return; + + setEditingCell({ rowIndex, colIndex, columnName, originalValue: value }); + setEditingValue(value !== null && value !== undefined ? String(value) : ""); + setFocusedCell({ rowIndex, colIndex }); + }, []); + + // ๐Ÿ†• ํŽธ์ง‘ ๋ชจ๋“œ ์ง„์ž… placeholder (์‹ค์ œ ๊ตฌํ˜„์€ visibleColumns ์ •์˜ ํ›„) + const startEditingRef = useRef<() => void>(() => {}); + + // ๐Ÿ†• ๊ฐ ์ปฌ๋Ÿผ์˜ ๊ณ ์œ ๊ฐ’ ๋ชฉ๋ก ๊ณ„์‚ฐ + const columnUniqueValues = useMemo(() => { + const result: Record = {}; + + if (data.length === 0) return result; + + (tableConfig.columns || []).forEach((column: { columnName: string }) => { + if (column.columnName === "__checkbox__") return; + + const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; + const values = new Set(); + + data.forEach((row) => { + const val = row[mappedColumnName]; + if (val !== null && val !== undefined && val !== "") { + values.add(String(val)); + } + }); + + result[column.columnName] = Array.from(values).sort(); + }); + + return result; + }, [data, tableConfig.columns, joinColumnMapping]); + + // ๐Ÿ†• ํ—ค๋” ํ•„ํ„ฐ ํ† ๊ธ€ + const toggleHeaderFilter = useCallback((columnName: string, value: string) => { + setHeaderFilters((prev) => { + const current = prev[columnName] || new Set(); + const newSet = new Set(current); + + if (newSet.has(value)) { + newSet.delete(value); + } else { + newSet.add(value); + } + + return { ...prev, [columnName]: newSet }; + }); + }, []); + + // ๐Ÿ†• ํ—ค๋” ํ•„ํ„ฐ ์ดˆ๊ธฐํ™” + const clearHeaderFilter = useCallback((columnName: string) => { + setHeaderFilters((prev) => { + const newFilters = { ...prev }; + delete newFilters[columnName]; + return newFilters; + }); + setOpenFilterColumn(null); + }, []); + + // ๐Ÿ†• ๋ชจ๋“  ํ—ค๋” ํ•„ํ„ฐ ์ดˆ๊ธฐํ™” + const clearAllHeaderFilters = useCallback(() => { + setHeaderFilters({}); + setOpenFilterColumn(null); + }, []); + + // ๐Ÿ†• ๋ฐ์ดํ„ฐ ์š”์•ฝ (Total Summaries) ์„ค์ • + // ํ˜•์‹: { columnName: { type: 'sum' | 'avg' | 'count' | 'min' | 'max', label?: string } } + const summaryConfig = useMemo(() => { + const config: Record = {}; + + // tableConfig์—์„œ summary ์„ค์ • ์ฝ๊ธฐ + if (tableConfig.summaries) { + tableConfig.summaries.forEach((summary: { columnName: string; type: string; label?: string }) => { + config[summary.columnName] = { type: summary.type, label: summary.label }; + }); + } + + return config; + }, [tableConfig.summaries]); + + // ๐Ÿ†• ์š”์•ฝ ๋ฐ์ดํ„ฐ ๊ณ„์‚ฐ + const summaryData = useMemo(() => { + if (Object.keys(summaryConfig).length === 0 || data.length === 0) { + return null; + } + + const result: Record = {}; + + Object.entries(summaryConfig).forEach(([columnName, config]) => { + const values = data + .map((row) => { + const mappedColumnName = joinColumnMapping[columnName] || columnName; + const val = row[mappedColumnName]; + return typeof val === "number" ? val : parseFloat(val); + }) + .filter((v) => !isNaN(v)); + + let value: number | string = 0; + let label = config.label || ""; + + switch (config.type) { + case "sum": + value = values.reduce((acc, v) => acc + v, 0); + label = label || "ํ•ฉ๊ณ„"; + break; + case "avg": + value = values.length > 0 ? values.reduce((acc, v) => acc + v, 0) / values.length : 0; + label = label || "ํ‰๊ท "; + break; + case "count": + value = data.length; + label = label || "๊ฐœ์ˆ˜"; + break; + case "min": + value = values.length > 0 ? Math.min(...values) : 0; + label = label || "์ตœ์†Œ"; + break; + case "max": + value = values.length > 0 ? Math.max(...values) : 0; + label = label || "์ตœ๋Œ€"; + break; + default: + value = 0; + } + + result[columnName] = { value, label }; + }); + + return result; + }, [data, summaryConfig, joinColumnMapping]); + + // ๐Ÿ†• ํŽธ์ง‘ ์ทจ์†Œ + const cancelEditing = useCallback(() => { + setEditingCell(null); + setEditingValue(""); + tableContainerRef.current?.focus(); + }, []); + + // ๐Ÿ†• ํŽธ์ง‘ ์ €์žฅ (์ฆ‰์‹œ ์ €์žฅ ๋˜๋Š” ๋ฐฐ์น˜ ์ €์žฅ) + const saveEditing = useCallback(async () => { + if (!editingCell) return; + + const { rowIndex, columnName, originalValue } = editingCell; + const newValue = editingValue; + + // ๊ฐ’์ด ๋ณ€๊ฒฝ๋˜์ง€ ์•Š์•˜์œผ๋ฉด ๊ทธ๋ƒฅ ๋‹ซ๊ธฐ + if (String(originalValue ?? "") === newValue) { + setCellValidationError(rowIndex, columnName, null); // ์—๋Ÿฌ ์ดˆ๊ธฐํ™” + cancelEditing(); + return; + } + + // ํ˜„์žฌ ํ–‰ ๋ฐ์ดํ„ฐ ๊ฐ€์ ธ์˜ค๊ธฐ + const row = data[rowIndex]; + if (!row || !tableConfig.selectedTable) { + cancelEditing(); + return; + } + + // ๐Ÿ†• ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ์‹คํ–‰ + const validationError = validateValue(newValue === "" ? null : newValue, columnName, row); + if (validationError) { + setCellValidationError(rowIndex, columnName, validationError); + toast.error(validationError); + // ํŽธ์ง‘ ์ƒํƒœ ์œ ์ง€ (์—๋Ÿฌ ์ˆ˜์ • ๊ฐ€๋Šฅํ•˜๋„๋ก) + return; + } + // ์œ ํšจ์„ฑ ํ†ต๊ณผ ์‹œ ์—๋Ÿฌ ์ดˆ๊ธฐํ™” + setCellValidationError(rowIndex, columnName, null); + + // ๊ธฐ๋ณธ ํ‚ค ํ•„๋“œ ์ฐพ๊ธฐ (id ๋˜๋Š” ์ฒซ ๋ฒˆ์งธ ์ปฌ๋Ÿผ) + const primaryKeyField = tableConfig.primaryKey || "id"; + const primaryKeyValue = row[primaryKeyField]; + + if (primaryKeyValue === undefined || primaryKeyValue === null) { + console.error("๊ธฐ๋ณธ ํ‚ค ๊ฐ’์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค:", primaryKeyField); + cancelEditing(); + return; + } + + // ๐Ÿ†• ๋ฐฐ์น˜ ๋ชจ๋“œ: ๋ณ€๊ฒฝ์‚ฌํ•ญ์„ pending์— ์ €์žฅ + if (editMode === "batch") { + const changeKey = `${rowIndex}-${columnName}`; + setPendingChanges((prev) => { + const newMap = new Map(prev); + newMap.set(changeKey, { + rowIndex, + columnName, + originalValue, + newValue: newValue === "" ? null : newValue, + primaryKeyValue, + }); + return newMap; + }); + + // ๋กœ์ปฌ ์ˆ˜์ • ๋ฐ์ดํ„ฐ ์—…๋ฐ์ดํŠธ (ํ™”๋ฉด ํ‘œ์‹œ์šฉ) + setLocalEditedData((prev) => ({ + ...prev, + [rowIndex]: { + ...(prev[rowIndex] || {}), + [columnName]: newValue === "" ? null : newValue, + }, + })); + + console.log("๐Ÿ“ ๋ฐฐ์น˜ ํŽธ์ง‘ ์ถ”๊ฐ€:", { columnName, newValue, pendingCount: pendingChanges.size + 1 }); + cancelEditing(); + return; + } + + // ๐Ÿ†• ์ฆ‰์‹œ ๋ชจ๋“œ: ๋ฐ”๋กœ ์ €์žฅ + try { + const { apiClient } = await import("@/lib/api/client"); + + await apiClient.put(`/dynamic-form/update-field`, { + tableName: tableConfig.selectedTable, + keyField: primaryKeyField, + keyValue: primaryKeyValue, + updateField: columnName, + updateValue: newValue === "" ? null : newValue, + }); + + // ๋ฐ์ดํ„ฐ ์ƒˆ๋กœ๊ณ ์นจ ํŠธ๋ฆฌ๊ฑฐ + setRefreshTrigger((prev) => prev + 1); + + console.log("โœ… ์…€ ํŽธ์ง‘ ์ €์žฅ ์™„๋ฃŒ:", { columnName, newValue }); + } catch (error) { + console.error("โŒ ์…€ ํŽธ์ง‘ ์ €์žฅ ์‹คํŒจ:", error); + } + + cancelEditing(); + }, [editingCell, editingValue, data, tableConfig.selectedTable, tableConfig.primaryKey, cancelEditing, editMode, pendingChanges.size]); + + // ๐Ÿ†• ๋ฐฐ์น˜ ์ €์žฅ: ๋ชจ๋“  ๋ณ€๊ฒฝ์‚ฌํ•ญ ํ•œ๋ฒˆ์— ์ €์žฅ + const saveBatchChanges = useCallback(async () => { + if (pendingChanges.size === 0) { + toast.info("์ €์žฅํ•  ๋ณ€๊ฒฝ์‚ฌํ•ญ์ด ์—†์Šต๋‹ˆ๋‹ค."); + return; + } + + try { + const { apiClient } = await import("@/lib/api/client"); + const primaryKeyField = tableConfig.primaryKey || "id"; + + // ๋ชจ๋“  ๋ณ€๊ฒฝ์‚ฌํ•ญ ์ €์žฅ + const savePromises = Array.from(pendingChanges.values()).map((change) => + apiClient.put(`/dynamic-form/update-field`, { + tableName: tableConfig.selectedTable, + keyField: primaryKeyField, + keyValue: change.primaryKeyValue, + updateField: change.columnName, + updateValue: change.newValue, + }) + ); + + await Promise.all(savePromises); + + // ์ƒํƒœ ์ดˆ๊ธฐํ™” + setPendingChanges(new Map()); + setLocalEditedData({}); + setRefreshTrigger((prev) => prev + 1); + + toast.success(`${pendingChanges.size}๊ฐœ์˜ ๋ณ€๊ฒฝ์‚ฌํ•ญ์ด ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.`); + console.log("โœ… ๋ฐฐ์น˜ ์ €์žฅ ์™„๋ฃŒ:", pendingChanges.size, "๊ฐœ"); + } catch (error) { + console.error("โŒ ๋ฐฐ์น˜ ์ €์žฅ ์‹คํŒจ:", error); + toast.error("์ €์žฅ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."); + } + }, [pendingChanges, tableConfig.selectedTable, tableConfig.primaryKey]); + + // ๐Ÿ†• ๋ฐฐ์น˜ ์ทจ์†Œ: ๋ชจ๋“  ๋ณ€๊ฒฝ์‚ฌํ•ญ ๋กค๋ฐฑ + const cancelBatchChanges = useCallback(() => { + if (pendingChanges.size === 0) return; + + setPendingChanges(new Map()); + setLocalEditedData({}); + toast.info("๋ณ€๊ฒฝ์‚ฌํ•ญ์ด ์ทจ์†Œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + console.log("๐Ÿ”„ ๋ฐฐ์น˜ ํŽธ์ง‘ ์ทจ์†Œ"); + }, [pendingChanges.size]); + + // ๐Ÿ†• ํŠน์ • ์…€์ด ์ˆ˜์ •๋˜์—ˆ๋Š”์ง€ ํ™•์ธ + const isCellModified = useCallback((rowIndex: number, columnName: string) => { + return pendingChanges.has(`${rowIndex}-${columnName}`); + }, [pendingChanges]); + + // ๐Ÿ†• ์ˆ˜์ •๋œ ์…€ ๊ฐ’ ๊ฐ€์ ธ์˜ค๊ธฐ (๋กœ์ปฌ ์ˆ˜์ • ๋ฐ์ดํ„ฐ ์šฐ์„ ) + const getDisplayValue = useCallback((row: any, rowIndex: number, columnName: string) => { + const localValue = localEditedData[rowIndex]?.[columnName]; + if (localValue !== undefined) { + return localValue; + } + return row[columnName]; + }, [localEditedData]); + + // ๐Ÿ†• ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ํ•จ์ˆ˜ + const validateValue = useCallback(( + value: any, + columnName: string, + row: any + ): string | null => { + // tableConfig.validation์—์„œ ์ปฌ๋Ÿผ๋ณ„ ๊ทœ์น™ ๊ฐ€์ ธ์˜ค๊ธฐ + const rules = (tableConfig as any).validation?.[columnName] as ValidationRule | undefined; + if (!rules) return null; + + const strValue = value !== null && value !== undefined ? String(value) : ""; + const numValue = parseFloat(strValue); + + // ํ•„์ˆ˜ ๊ฒ€์‚ฌ + if (rules.required && (!strValue || strValue.trim() === "")) { + return rules.customMessage || "ํ•„์ˆ˜ ์ž…๋ ฅ ํ•ญ๋ชฉ์ž…๋‹ˆ๋‹ค."; + } + + // ๊ฐ’์ด ๋น„์–ด์žˆ์œผ๋ฉด ๋‹ค๋ฅธ ๊ฒ€์‚ฌ ์Šคํ‚ต (required๊ฐ€ ์•„๋‹Œ ๊ฒฝ์šฐ) + if (!strValue || strValue.trim() === "") return null; + + // ์ตœ์†Œ๊ฐ’ ๊ฒ€์‚ฌ + if (rules.min !== undefined && !isNaN(numValue) && numValue < rules.min) { + return rules.customMessage || `์ตœ์†Œ๊ฐ’์€ ${rules.min}์ž…๋‹ˆ๋‹ค.`; + } + + // ์ตœ๋Œ€๊ฐ’ ๊ฒ€์‚ฌ + if (rules.max !== undefined && !isNaN(numValue) && numValue > rules.max) { + return rules.customMessage || `์ตœ๋Œ€๊ฐ’์€ ${rules.max}์ž…๋‹ˆ๋‹ค.`; + } + + // ์ตœ์†Œ ๊ธธ์ด ๊ฒ€์‚ฌ + if (rules.minLength !== undefined && strValue.length < rules.minLength) { + return rules.customMessage || `์ตœ์†Œ ${rules.minLength}์ž ์ด์ƒ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.`; + } + + // ์ตœ๋Œ€ ๊ธธ์ด ๊ฒ€์‚ฌ + if (rules.maxLength !== undefined && strValue.length > rules.maxLength) { + return rules.customMessage || `์ตœ๋Œ€ ${rules.maxLength}์ž๊นŒ์ง€ ์ž…๋ ฅ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.`; + } + + // ํŒจํ„ด ๊ฒ€์‚ฌ + if (rules.pattern && !rules.pattern.test(strValue)) { + return rules.customMessage || "์ž…๋ ฅ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."; + } + + // ์ปค์Šคํ…€ ๊ฒ€์ฆ + if (rules.validate) { + const customError = rules.validate(value, row); + if (customError) return customError; + } + + return null; + }, [tableConfig]); + + // ๐Ÿ†• ์…€ ์œ ํšจ์„ฑ ์—๋Ÿฌ ์—ฌ๋ถ€ ํ™•์ธ + const getCellValidationError = useCallback((rowIndex: number, columnName: string): string | null => { + return validationErrors.get(`${rowIndex}-${columnName}`) || null; + }, [validationErrors]); + + // ๐Ÿ†• ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ์—๋Ÿฌ ์„ค์ • + const setCellValidationError = useCallback((rowIndex: number, columnName: string, error: string | null) => { + setValidationErrors((prev) => { + const newMap = new Map(prev); + const key = `${rowIndex}-${columnName}`; + if (error) { + newMap.set(key, error); + } else { + newMap.delete(key); + } + return newMap; + }); + }, []); + + // ๐Ÿ†• ๋ชจ๋“  ์œ ํšจ์„ฑ ์—๋Ÿฌ ์ดˆ๊ธฐํ™” + const clearAllValidationErrors = useCallback(() => { + setValidationErrors(new Map()); + }, []); + + // ๐Ÿ†• Excel ๋‚ด๋ณด๋‚ด๊ธฐ ํ•จ์ˆ˜ + const exportToExcel = useCallback((exportAll: boolean = true) => { + try { + // ๋‚ด๋ณด๋‚ผ ๋ฐ์ดํ„ฐ ์„ ํƒ (์„ ํƒ๋œ ํ–‰๋งŒ ๋˜๋Š” ์ „์ฒด) + let exportData: any[]; + if (exportAll) { + exportData = filteredData; + } else { + // ์„ ํƒ๋œ ํ–‰๋งŒ ๋‚ด๋ณด๋‚ด๊ธฐ + exportData = filteredData.filter((row, index) => { + const rowKey = getRowKey(row, index); + return selectedRows.has(rowKey); + }); + } + + if (exportData.length === 0) { + toast.error(exportAll ? "๋‚ด๋ณด๋‚ผ ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค." : "์„ ํƒ๋œ ํ–‰์ด ์—†์Šต๋‹ˆ๋‹ค."); + return; + } + + // ์ปฌ๋Ÿผ ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ (์ฒดํฌ๋ฐ•์Šค ์ œ์™ธ) + const exportColumns = visibleColumns.filter((col) => col.columnName !== "__checkbox__"); + + // ํ—ค๋” ํ–‰ ์ƒ์„ฑ + const headers = exportColumns.map((col) => columnLabels[col.columnName] || col.columnName); + + // ๋ฐ์ดํ„ฐ ํ–‰ ์ƒ์„ฑ + const rows = exportData.map((row) => { + return exportColumns.map((col) => { + const mappedColumnName = joinColumnMapping[col.columnName] || col.columnName; + const value = row[mappedColumnName]; + + // ์นดํ…Œ๊ณ ๋ฆฌ ๋งคํ•‘๋œ ๊ฐ’ ์ฒ˜๋ฆฌ + if (categoryMappings[col.columnName] && value !== null && value !== undefined) { + const mapping = categoryMappings[col.columnName][String(value)]; + if (mapping) { + return mapping.label; + } + } + + // null/undefined ์ฒ˜๋ฆฌ + if (value === null || value === undefined) { + return ""; + } + + return value; + }); + }); + + // ์›Œํฌ์‹œํŠธ ์ƒ์„ฑ + const wsData = [headers, ...rows]; + const ws = XLSX.utils.aoa_to_sheet(wsData); + + // ์ปฌ๋Ÿผ ๋„ˆ๋น„ ์ž๋™ ์กฐ์ • + const colWidths = exportColumns.map((col, idx) => { + const headerLength = headers[idx]?.length || 10; + const maxDataLength = Math.max( + ...rows.map((row) => String(row[idx] ?? "").length) + ); + return { wch: Math.min(Math.max(headerLength, maxDataLength) + 2, 50) }; + }); + ws["!cols"] = colWidths; + + // ์›Œํฌ๋ถ ์ƒ์„ฑ + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, tableLabel || "๋ฐ์ดํ„ฐ"); + + // ํŒŒ์ผ๋ช… ์ƒ์„ฑ + const fileName = `${tableLabel || tableConfig.selectedTable || "export"}_${new Date().toISOString().split("T")[0]}.xlsx`; + + // ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ + XLSX.writeFile(wb, fileName); + + toast.success(`${exportData.length}๊ฐœ ํ–‰์ด Excel๋กœ ๋‚ด๋ณด๋‚ด๊ธฐ ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.`); + console.log("โœ… Excel ๋‚ด๋ณด๋‚ด๊ธฐ ์™„๋ฃŒ:", fileName); + } catch (error) { + console.error("โŒ Excel ๋‚ด๋ณด๋‚ด๊ธฐ ์‹คํŒจ:", error); + toast.error("Excel ๋‚ด๋ณด๋‚ด๊ธฐ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."); + } + }, [filteredData, selectedRows, visibleColumns, columnLabels, joinColumnMapping, categoryMappings, tableLabel, tableConfig.selectedTable, getRowKey]); + + // ๐Ÿ†• ํ–‰ ํ™•์žฅ/์ถ•์†Œ ํ† ๊ธ€ + const toggleRowExpand = useCallback(async (rowKey: string, row: any) => { + setExpandedRows((prev) => { + const newSet = new Set(prev); + if (newSet.has(rowKey)) { + newSet.delete(rowKey); + } else { + newSet.add(rowKey); + // ์ƒ์„ธ ๋ฐ์ดํ„ฐ ๋กœ๋”ฉ (์•„์ง ์—†๋Š” ๊ฒฝ์šฐ) + if (!detailData[rowKey] && (tableConfig as any).masterDetail?.detailTable) { + loadDetailData(rowKey, row); + } + } + return newSet; + }); + }, [detailData, tableConfig]); + + // ๐Ÿ†• ์ƒ์„ธ ๋ฐ์ดํ„ฐ ๋กœ๋”ฉ + const loadDetailData = useCallback(async (rowKey: string, row: any) => { + const masterDetailConfig = (tableConfig as any).masterDetail; + if (!masterDetailConfig?.detailTable) return; + + try { + const { apiClient } = await import("@/lib/api/client"); + + // masterKey ๊ฐ’ ๊ฐ€์ ธ์˜ค๊ธฐ + const masterKeyField = masterDetailConfig.masterKey || "id"; + const masterKeyValue = row[masterKeyField]; + + // ์ƒ์„ธ ํ…Œ์ด๋ธ”์—์„œ ๋ฐ์ดํ„ฐ ์กฐํšŒ + const response = await apiClient.post(`/table-management/tables/${masterDetailConfig.detailTable}/data`, { + page: 1, + size: 100, + search: { + [masterDetailConfig.detailKey || masterKeyField]: masterKeyValue, + }, + autoFilter: true, + }); + + const details = response.data?.data?.data || []; + + setDetailData((prev) => ({ + ...prev, + [rowKey]: details, + })); + + console.log("โœ… ์ƒ์„ธ ๋ฐ์ดํ„ฐ ๋กœ๋”ฉ ์™„๋ฃŒ:", { rowKey, count: details.length }); + } catch (error) { + console.error("โŒ ์ƒ์„ธ ๋ฐ์ดํ„ฐ ๋กœ๋”ฉ ์‹คํŒจ:", error); + setDetailData((prev) => ({ + ...prev, + [rowKey]: [], + })); + } + }, [tableConfig]); + + // ๐Ÿ†• ๋ชจ๋“  ํ–‰ ํ™•์žฅ/์ถ•์†Œ + const expandAllRows = useCallback(() => { + if (expandedRows.size === filteredData.length) { + // ๋ชจ๋‘ ์ถ•์†Œ + setExpandedRows(new Set()); + } else { + // ๋ชจ๋‘ ํ™•์žฅ + const allKeys = new Set(filteredData.map((row, index) => getRowKey(row, index))); + setExpandedRows(allKeys); + } + }, [expandedRows.size, filteredData, getRowKey]); + + // ๐Ÿ†• Multi-Level Headers: Band ์ •๋ณด ๊ณ„์‚ฐ + const columnBandsInfo = useMemo(() => { + const bands = (tableConfig as any).columnBands as ColumnBand[] | undefined; + if (!bands || bands.length === 0) return null; + + // ๊ฐ band์˜ ์‹œ์ž‘ ์ธ๋ฑ์Šค์™€ colspan ๊ณ„์‚ฐ + const bandInfo = bands.map((band) => { + const visibleBandColumns = band.columns.filter((colName) => + visibleColumns.some((vc) => vc.columnName === colName) + ); + + const startIndex = visibleColumns.findIndex( + (vc) => visibleBandColumns.includes(vc.columnName) + ); + + return { + caption: band.caption, + columns: visibleBandColumns, + colSpan: visibleBandColumns.length, + startIndex, + }; + }).filter((b) => b.colSpan > 0); + + // Band์— ํฌํ•จ๋˜์ง€ ์•Š์€ ์ปฌ๋Ÿผ ์ฐพ๊ธฐ + const bandedColumns = new Set(bands.flatMap((b) => b.columns)); + const unbandedColumns = visibleColumns + .map((vc, idx) => ({ columnName: vc.columnName, index: idx })) + .filter((c) => !bandedColumns.has(c.columnName)); + + return { + bands: bandInfo, + unbandedColumns, + hasBands: bandInfo.length > 0, + }; + }, [tableConfig, visibleColumns]); + + // ๐Ÿ†• Cascading Lookups: ์—ฐ๊ณ„ ๋“œ๋กญ๋‹ค์šด ์˜ต์…˜ ๋กœ๋”ฉ + const loadCascadingOptions = useCallback(async ( + columnName: string, + parentColumnName: string, + parentValue: any + ) => { + const cascadingConfig = (tableConfig as any).cascadingLookups?.[columnName]; + if (!cascadingConfig) return; + + const cacheKey = `${columnName}_${parentValue}`; + + // ์ด๋ฏธ ๋กœ๋”ฉ ์ค‘์ด๋ฉด ์Šคํ‚ต + if (loadingCascading[cacheKey]) return; + + // ์ด๋ฏธ ์บ์‹œ๋œ ๋ฐ์ดํ„ฐ๊ฐ€ ์žˆ์œผ๋ฉด ์Šคํ‚ต + if (cascadingOptions[cacheKey]) return; + + setLoadingCascading((prev) => ({ ...prev, [cacheKey]: true })); + + try { + const { apiClient } = await import("@/lib/api/client"); + + // API์—์„œ ์—ฐ๊ณ„ ์˜ต์…˜ ๋กœ๋”ฉ + const response = await apiClient.post(`/table-management/tables/${cascadingConfig.sourceTable}/data`, { + page: 1, + size: 1000, + search: { + [cascadingConfig.parentKeyField || parentColumnName]: parentValue, + }, + autoFilter: true, + }); + + const items = response.data?.data?.data || []; + const options = items.map((item: any) => ({ + value: item[cascadingConfig.valueField || "id"], + label: item[cascadingConfig.labelField || "name"], + })); + + setCascadingOptions((prev) => ({ + ...prev, + [cacheKey]: options, + })); + + console.log("โœ… Cascading options ๋กœ๋”ฉ ์™„๋ฃŒ:", { columnName, parentValue, count: options.length }); + } catch (error) { + console.error("โŒ Cascading options ๋กœ๋”ฉ ์‹คํŒจ:", error); + setCascadingOptions((prev) => ({ + ...prev, + [cacheKey]: [], + })); + } finally { + setLoadingCascading((prev) => ({ ...prev, [cacheKey]: false })); + } + }, [tableConfig, cascadingOptions, loadingCascading]); + + // ๐Ÿ†• Cascading Lookups: ํŠน์ • ์ปฌ๋Ÿผ์˜ ์˜ต์…˜ ๊ฐ€์ ธ์˜ค๊ธฐ + const getCascadingOptions = useCallback((columnName: string, row: any): { value: string; label: string }[] => { + const cascadingConfig = (tableConfig as any).cascadingLookups?.[columnName]; + if (!cascadingConfig) return []; + + const parentValue = row[cascadingConfig.parentColumn]; + if (parentValue === undefined || parentValue === null) return []; + + const cacheKey = `${columnName}_${parentValue}`; + return cascadingOptions[cacheKey] || []; + }, [tableConfig, cascadingOptions]); + + // ๐Ÿ†• Virtual Scrolling: ๋ณด์ด๋Š” ํ–‰ ๋ฒ”์œ„ ๊ณ„์‚ฐ + const virtualScrollInfo = useMemo(() => { + if (!isVirtualScrollEnabled || filteredData.length === 0) { + return { + startIndex: 0, + endIndex: filteredData.length, + visibleData: filteredData, + topSpacerHeight: 0, + bottomSpacerHeight: 0, + totalHeight: filteredData.length * ROW_HEIGHT, + }; + } + + const containerHeight = scrollContainerRef.current?.clientHeight || 600; + const totalRows = filteredData.length; + const totalHeight = totalRows * ROW_HEIGHT; + + // ํ˜„์žฌ ๋ณด์ด๋Š” ํ–‰ ๋ฒ”์œ„ ๊ณ„์‚ฐ + const startIndex = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN); + const visibleRowCount = Math.ceil(containerHeight / ROW_HEIGHT) + OVERSCAN * 2; + const endIndex = Math.min(totalRows, startIndex + visibleRowCount); + + return { + startIndex, + endIndex, + visibleData: filteredData.slice(startIndex, endIndex), + topSpacerHeight: startIndex * ROW_HEIGHT, + bottomSpacerHeight: (totalRows - endIndex) * ROW_HEIGHT, + totalHeight, + }; + }, [isVirtualScrollEnabled, filteredData, scrollTop, ROW_HEIGHT, OVERSCAN]); + + // ๐Ÿ†• Virtual Scrolling: ์Šคํฌ๋กค ํ•ธ๋“ค๋Ÿฌ + const handleVirtualScroll = useCallback((e: React.UIEvent) => { + if (!isVirtualScrollEnabled) return; + setScrollTop(e.currentTarget.scrollTop); + }, [isVirtualScrollEnabled]); + + // ๐Ÿ†• State Persistence: ํ†ตํ•ฉ ์ƒํƒœ ์ €์žฅ + const saveTableState = useCallback(() => { + if (!tableStateKey) return; + + const state = { + columnWidths, + columnOrder, + sortColumn, + sortDirection, + groupByColumns, + frozenColumns, + showGridLines, + headerFilters: Object.fromEntries( + Object.entries(headerFilters).map(([key, set]) => [key, Array.from(set as Set)]) + ), + pageSize: localPageSize, + timestamp: Date.now(), + }; + + try { + localStorage.setItem(tableStateKey, JSON.stringify(state)); + console.log("โœ… ํ…Œ์ด๋ธ” ์ƒํƒœ ์ €์žฅ:", tableStateKey); + } catch (error) { + console.error("โŒ ํ…Œ์ด๋ธ” ์ƒํƒœ ์ €์žฅ ์‹คํŒจ:", error); + } + }, [tableStateKey, columnWidths, columnOrder, sortColumn, sortDirection, groupByColumns, frozenColumns, showGridLines, headerFilters, localPageSize]); + + // ๐Ÿ†• State Persistence: ํ†ตํ•ฉ ์ƒํƒœ ๋ณต์› + const loadTableState = useCallback(() => { + if (!tableStateKey) return; + + try { + const saved = localStorage.getItem(tableStateKey); + if (!saved) return; + + const state = JSON.parse(saved); + + if (state.columnWidths) setColumnWidths(state.columnWidths); + if (state.columnOrder) setColumnOrder(state.columnOrder); + if (state.sortColumn !== undefined) setSortColumn(state.sortColumn); + if (state.sortDirection) setSortDirection(state.sortDirection); + if (state.groupByColumns) setGroupByColumns(state.groupByColumns); + if (state.frozenColumns) setFrozenColumns(state.frozenColumns); + if (state.showGridLines !== undefined) setShowGridLines(state.showGridLines); + if (state.headerFilters) { + const filters: Record> = {}; + Object.entries(state.headerFilters).forEach(([key, values]) => { + filters[key] = new Set(values as string[]); + }); + setHeaderFilters(filters); + } + + console.log("โœ… ํ…Œ์ด๋ธ” ์ƒํƒœ ๋ณต์›:", tableStateKey); + } catch (error) { + console.error("โŒ ํ…Œ์ด๋ธ” ์ƒํƒœ ๋ณต์› ์‹คํŒจ:", error); + } + }, [tableStateKey]); + + // ๐Ÿ†• State Persistence: ์ƒํƒœ ์ดˆ๊ธฐํ™” + const resetTableState = useCallback(() => { + if (!tableStateKey) return; + + try { + localStorage.removeItem(tableStateKey); + setColumnWidths({}); + setColumnOrder([]); + setSortColumn(null); + setSortDirection("asc"); + setGroupByColumns([]); + setFrozenColumns([]); + setShowGridLines(true); + setHeaderFilters({}); + toast.success("ํ…Œ์ด๋ธ” ์„ค์ •์ด ์ดˆ๊ธฐํ™”๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + console.log("โœ… ํ…Œ์ด๋ธ” ์ƒํƒœ ์ดˆ๊ธฐํ™”:", tableStateKey); + } catch (error) { + console.error("โŒ ํ…Œ์ด๋ธ” ์ƒํƒœ ์ดˆ๊ธฐํ™” ์‹คํŒจ:", error); + } + }, [tableStateKey]); + + // ๐Ÿ†• State Persistence: ์ปดํฌ๋„ŒํŠธ ๋งˆ์šดํŠธ ์‹œ ์ƒํƒœ ๋ณต์› + useEffect(() => { + loadTableState(); + }, [tableStateKey]); // loadTableState๋Š” ์˜์กด์„ฑ์—์„œ ์ œ์™ธ (๋ฌดํ•œ ๋ฃจํ”„ ๋ฐฉ์ง€) + + // ๐Ÿ†• Real-Time Updates: WebSocket ์—ฐ๊ฒฐ + const connectWebSocket = useCallback(() => { + if (!isRealTimeEnabled || !tableConfig.selectedTable) return; + + const wsUrl = (tableConfig as any).wsUrl || + `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws/table/${tableConfig.selectedTable}`; + + try { + setWsConnectionStatus("connecting"); + wsRef.current = new WebSocket(wsUrl); + + wsRef.current.onopen = () => { + setWsConnectionStatus("connected"); + console.log("โœ… WebSocket ์—ฐ๊ฒฐ๋จ:", tableConfig.selectedTable); + }; + + wsRef.current.onmessage = (event) => { + try { + const message = JSON.parse(event.data); + console.log("๐Ÿ“จ WebSocket ๋ฉ”์‹œ์ง€ ์ˆ˜์‹ :", message); + + switch (message.type) { + case "insert": + // ์ƒˆ ๋ฐ์ดํ„ฐ ์ถ”๊ฐ€ + setRefreshTrigger((prev) => prev + 1); + toast.info("์ƒˆ ๋ฐ์ดํ„ฐ๊ฐ€ ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + break; + case "update": + // ๋ฐ์ดํ„ฐ ์—…๋ฐ์ดํŠธ + setRefreshTrigger((prev) => prev + 1); + toast.info("๋ฐ์ดํ„ฐ๊ฐ€ ์—…๋ฐ์ดํŠธ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + break; + case "delete": + // ๋ฐ์ดํ„ฐ ์‚ญ์ œ + setRefreshTrigger((prev) => prev + 1); + toast.info("๋ฐ์ดํ„ฐ๊ฐ€ ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + break; + case "refresh": + // ์ „์ฒด ์ƒˆ๋กœ๊ณ ์นจ + setRefreshTrigger((prev) => prev + 1); + break; + default: + console.log("์•Œ ์ˆ˜ ์—†๋Š” ๋ฉ”์‹œ์ง€ ํƒ€์ž…:", message.type); + } + } catch (error) { + console.error("WebSocket ๋ฉ”์‹œ์ง€ ํŒŒ์‹ฑ ์˜ค๋ฅ˜:", error); + } + }; + + wsRef.current.onclose = () => { + setWsConnectionStatus("disconnected"); + console.log("๐Ÿ”Œ WebSocket ์—ฐ๊ฒฐ ์ข…๋ฃŒ"); + + // ์ž๋™ ์žฌ์—ฐ๊ฒฐ (5์ดˆ ํ›„) + if (isRealTimeEnabled) { + reconnectTimeoutRef.current = setTimeout(() => { + console.log("๐Ÿ”„ WebSocket ์žฌ์—ฐ๊ฒฐ ์‹œ๋„..."); + connectWebSocket(); + }, 5000); + } + }; + + wsRef.current.onerror = (error) => { + console.error("โŒ WebSocket ์˜ค๋ฅ˜:", error); + setWsConnectionStatus("disconnected"); + }; + } catch (error) { + console.error("WebSocket ์—ฐ๊ฒฐ ์‹คํŒจ:", error); + setWsConnectionStatus("disconnected"); + } + }, [isRealTimeEnabled, tableConfig.selectedTable]); + + // ๐Ÿ†• Real-Time Updates: ์—ฐ๊ฒฐ ๊ด€๋ฆฌ + useEffect(() => { + if (isRealTimeEnabled) { + connectWebSocket(); + } + + return () => { + // ์ •๋ฆฌ + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + }; + }, [isRealTimeEnabled, tableConfig.selectedTable]); + + // ๐Ÿ†• State Persistence: ์ƒํƒœ ๋ณ€๊ฒฝ ์‹œ ์ž๋™ ์ €์žฅ (๋””๋ฐ”์šด์Šค) + useEffect(() => { + const timeoutId = setTimeout(() => { + saveTableState(); + }, 1000); // 1์ดˆ ํ›„ ์ €์žฅ (๋””๋ฐ”์šด์Šค) + + return () => clearTimeout(timeoutId); + }, [columnWidths, columnOrder, sortColumn, sortDirection, groupByColumns, frozenColumns, showGridLines, headerFilters]); + + // ๐Ÿ†• Clipboard: ์„ ํƒ๋œ ๋ฐ์ดํ„ฐ ๋ณต์‚ฌ + const handleCopy = useCallback(async () => { + try { + // ์„ ํƒ๋œ ํ–‰ ๋ฐ์ดํ„ฐ ๊ฐ€์ ธ์˜ค๊ธฐ + let copyData: any[]; + + if (selectedRows.size > 0) { + // ์„ ํƒ๋œ ํ–‰๋งŒ + copyData = filteredData.filter((row, index) => { + const rowKey = getRowKey(row, index); + return selectedRows.has(rowKey); + }); + } else if (focusedCell) { + // ํฌ์ปค์Šค๋œ ์…€๋งŒ + const row = filteredData[focusedCell.rowIndex]; + if (row) { + const column = visibleColumns[focusedCell.colIndex]; + const value = row[column?.columnName]; + await navigator.clipboard.writeText(String(value ?? "")); + toast.success("์…€ ๋ณต์‚ฌ๋จ"); + return; + } + return; + } else { + toast.info("๋ณต์‚ฌํ•  ๋ฐ์ดํ„ฐ๋ฅผ ์„ ํƒํ•ด์ฃผ์„ธ์š”."); + return; + } + + // TSV ํ˜•์‹์œผ๋กœ ๋ณ€ํ™˜ (ํƒญ์œผ๋กœ ๊ตฌ๋ถ„) + const exportColumns = visibleColumns.filter((c) => c.columnName !== "__checkbox__"); + const headers = exportColumns.map((c) => columnLabels[c.columnName] || c.columnName); + const rows = copyData.map((row) => + exportColumns.map((c) => { + const value = row[c.columnName]; + return value !== null && value !== undefined ? String(value).replace(/\t/g, " ").replace(/\n/g, " ") : ""; + }).join("\t") + ); + + const tsvContent = [headers.join("\t"), ...rows].join("\n"); + await navigator.clipboard.writeText(tsvContent); + + toast.success(`${copyData.length}ํ–‰ ๋ณต์‚ฌ๋จ`); + console.log("โœ… ํด๋ฆฝ๋ณด๋“œ ๋ณต์‚ฌ:", copyData.length, "ํ–‰"); + } catch (error) { + console.error("โŒ ํด๋ฆฝ๋ณด๋“œ ๋ณต์‚ฌ ์‹คํŒจ:", error); + toast.error("๋ณต์‚ฌ ์‹คํŒจ"); + } + }, [selectedRows, filteredData, focusedCell, visibleColumns, columnLabels, getRowKey]); + + // ๐Ÿ†• ์ „์ฒด ํ–‰ ์„ ํƒ + const handleSelectAllRows = useCallback(() => { + if (selectedRows.size === filteredData.length) { + // ์ „์ฒด ํ•ด์ œ + setSelectedRows(new Set()); + setIsAllSelected(false); + } else { + // ์ „์ฒด ์„ ํƒ + const allKeys = new Set(filteredData.map((row, index) => getRowKey(row, index))); + setSelectedRows(allKeys); + setIsAllSelected(true); + } + }, [selectedRows.size, filteredData, getRowKey]); + + // ๐Ÿ†• Context Menu: ์—ด๊ธฐ + const handleContextMenu = useCallback((e: React.MouseEvent, rowIndex: number, colIndex: number, row: any) => { + e.preventDefault(); + setContextMenu({ + x: e.clientX, + y: e.clientY, + rowIndex, + colIndex, + row, + }); + }, []); + + // ๐Ÿ†• Context Menu: ๋‹ซ๊ธฐ + const closeContextMenu = useCallback(() => { + setContextMenu(null); + }, []); + + // ๐Ÿ†• Context Menu: ์™ธ๋ถ€ ํด๋ฆญ ์‹œ ๋‹ซ๊ธฐ + useEffect(() => { + if (contextMenu) { + const handleClick = () => closeContextMenu(); + document.addEventListener("click", handleClick); + return () => document.removeEventListener("click", handleClick); + } + }, [contextMenu, closeContextMenu]); + + // ๐Ÿ†• Search Panel: ํ†ตํ•ฉ ๊ฒ€์ƒ‰ ์‹คํ–‰ + const executeGlobalSearch = useCallback((term: string) => { + if (!term.trim()) { + setSearchHighlights(new Set()); + return; + } + + const lowerTerm = term.toLowerCase(); + const highlights = new Set(); + + filteredData.forEach((row, rowIndex) => { + visibleColumns.forEach((col, colIndex) => { + const value = row[col.columnName]; + if (value !== null && value !== undefined) { + const strValue = String(value).toLowerCase(); + if (strValue.includes(lowerTerm)) { + highlights.add(`${rowIndex}-${colIndex}`); + } + } + }); + }); + + setSearchHighlights(highlights); + + // ์ฒซ ๋ฒˆ์งธ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๋กœ ํฌ์ปค์Šค ์ด๋™ + if (highlights.size > 0) { + const firstHighlight = Array.from(highlights)[0]; + const [rowIdx, colIdx] = firstHighlight.split("-").map(Number); + setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx }); + toast.success(`${highlights.size}๊ฐœ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ`); + } else { + toast.info("๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค"); + } + }, [filteredData, visibleColumns]); + + // ๐Ÿ†• Search Panel: ๋‹ค์Œ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๋กœ ์ด๋™ + const goToNextSearchResult = useCallback(() => { + if (searchHighlights.size === 0) return; + + const highlightArray = Array.from(searchHighlights).sort((a, b) => { + const [aRow, aCol] = a.split("-").map(Number); + const [bRow, bCol] = b.split("-").map(Number); + if (aRow !== bRow) return aRow - bRow; + return aCol - bCol; + }); + + if (!focusedCell) { + const [rowIdx, colIdx] = highlightArray[0].split("-").map(Number); + setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx }); + return; + } + + const currentKey = `${focusedCell.rowIndex}-${focusedCell.colIndex}`; + const currentIndex = highlightArray.indexOf(currentKey); + const nextIndex = (currentIndex + 1) % highlightArray.length; + const [rowIdx, colIdx] = highlightArray[nextIndex].split("-").map(Number); + setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx }); + }, [searchHighlights, focusedCell]); + + // ๐Ÿ†• Search Panel: ์ด์ „ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๋กœ ์ด๋™ + const goToPrevSearchResult = useCallback(() => { + if (searchHighlights.size === 0) return; + + const highlightArray = Array.from(searchHighlights).sort((a, b) => { + const [aRow, aCol] = a.split("-").map(Number); + const [bRow, bCol] = b.split("-").map(Number); + if (aRow !== bRow) return aRow - bRow; + return aCol - bCol; + }); + + if (!focusedCell) { + const lastIdx = highlightArray.length - 1; + const [rowIdx, colIdx] = highlightArray[lastIdx].split("-").map(Number); + setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx }); + return; + } + + const currentKey = `${focusedCell.rowIndex}-${focusedCell.colIndex}`; + const currentIndex = highlightArray.indexOf(currentKey); + const prevIndex = currentIndex <= 0 ? highlightArray.length - 1 : currentIndex - 1; + const [rowIdx, colIdx] = highlightArray[prevIndex].split("-").map(Number); + setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx }); + }, [searchHighlights, focusedCell]); + + // ๐Ÿ†• Search Panel: ๊ฒ€์ƒ‰ ์ดˆ๊ธฐํ™” + const clearGlobalSearch = useCallback(() => { + setGlobalSearchTerm(""); + setSearchHighlights(new Set()); + setIsSearchPanelOpen(false); + }, []); + + // ๐Ÿ†• Filter Builder: ์กฐ๊ฑด ์ถ”๊ฐ€ + const addFilterCondition = useCallback((groupId: string, defaultColumn?: string) => { + setFilterGroups((prev) => + prev.map((group) => + group.id === groupId + ? { + ...group, + conditions: [ + ...group.conditions, + { + id: `cond-${Date.now()}`, + column: defaultColumn || "", + operator: "contains" as const, + value: "", + }, + ], + } + : group + ) + ); + }, []); + + // ๐Ÿ†• Filter Builder: ์กฐ๊ฑด ์‚ญ์ œ + const removeFilterCondition = useCallback((groupId: string, conditionId: string) => { + setFilterGroups((prev) => + prev.map((group) => + group.id === groupId + ? { + ...group, + conditions: group.conditions.filter((c) => c.id !== conditionId), + } + : group + ) + ); + }, []); + + // ๐Ÿ†• Filter Builder: ์กฐ๊ฑด ์—…๋ฐ์ดํŠธ + const updateFilterCondition = useCallback( + (groupId: string, conditionId: string, field: keyof FilterCondition, value: string) => { + setFilterGroups((prev) => + prev.map((group) => + group.id === groupId + ? { + ...group, + conditions: group.conditions.map((c) => + c.id === conditionId ? { ...c, [field]: value } : c + ), + } + : group + ) + ); + }, + [] + ); + + // ๐Ÿ†• Filter Builder: ๊ทธ๋ฃน ์ถ”๊ฐ€ + const addFilterGroup = useCallback((defaultColumn?: string) => { + setFilterGroups((prev) => [ + ...prev, + { + id: `group-${Date.now()}`, + logic: "AND" as const, + conditions: [ + { + id: `cond-${Date.now()}`, + column: defaultColumn || "", + operator: "contains" as const, + value: "", + }, + ], + }, + ]); + }, []); + + // ๐Ÿ†• Filter Builder: ๊ทธ๋ฃน ์‚ญ์ œ + const removeFilterGroup = useCallback((groupId: string) => { + setFilterGroups((prev) => prev.filter((g) => g.id !== groupId)); + }, []); + + // ๐Ÿ†• Filter Builder: ๊ทธ๋ฃน ๋กœ์ง ๋ณ€๊ฒฝ + const updateGroupLogic = useCallback((groupId: string, logic: "AND" | "OR") => { + setFilterGroups((prev) => + prev.map((group) => (group.id === groupId ? { ...group, logic } : group)) + ); + }, []); + + // ๐Ÿ†• Filter Builder: ํ•„ํ„ฐ ์ ์šฉ + const applyFilterBuilder = useCallback(() => { + // ์œ ํšจํ•œ ์กฐ๊ฑด ๊ฐœ์ˆ˜ ๊ณ„์‚ฐ + let validConditions = 0; + filterGroups.forEach((group) => { + group.conditions.forEach((cond) => { + if (cond.column && (cond.operator === "isEmpty" || cond.operator === "isNotEmpty" || cond.value)) { + validConditions++; + } + }); + }); + setActiveFilterCount(validConditions); + setIsFilterBuilderOpen(false); + toast.success(`${validConditions}๊ฐœ ํ•„ํ„ฐ ์กฐ๊ฑด ์ ์šฉ๋จ`); + }, [filterGroups]); + + // ๐Ÿ†• Filter Builder: ํ•„ํ„ฐ ์ดˆ๊ธฐํ™” + const clearFilterBuilder = useCallback(() => { + setFilterGroups([]); + setActiveFilterCount(0); + toast.info("ํ•„ํ„ฐ ์ดˆ๊ธฐํ™”๋จ"); + }, []); + + // ๐Ÿ†• Filter Builder: ์กฐ๊ฑด ํ‰๊ฐ€ ํ•จ์ˆ˜ + const evaluateCondition = useCallback((value: any, condition: FilterCondition): boolean => { + const strValue = value !== null && value !== undefined ? String(value).toLowerCase() : ""; + const condValue = condition.value.toLowerCase(); + + switch (condition.operator) { + case "equals": + return strValue === condValue; + case "notEquals": + return strValue !== condValue; + case "contains": + return strValue.includes(condValue); + case "notContains": + return !strValue.includes(condValue); + case "startsWith": + return strValue.startsWith(condValue); + case "endsWith": + return strValue.endsWith(condValue); + case "greaterThan": + return parseFloat(strValue) > parseFloat(condValue); + case "lessThan": + return parseFloat(strValue) < parseFloat(condValue); + case "greaterOrEqual": + return parseFloat(strValue) >= parseFloat(condValue); + case "lessOrEqual": + return parseFloat(strValue) <= parseFloat(condValue); + case "isEmpty": + return strValue === "" || value === null || value === undefined; + case "isNotEmpty": + return strValue !== "" && value !== null && value !== undefined; + default: + return true; + } + }, []); + + // ๐Ÿ†• Filter Builder: ํ–‰์ด ํ•„ํ„ฐ ์กฐ๊ฑด์„ ๋งŒ์กฑํ•˜๋Š”์ง€ ํ™•์ธ + const rowPassesFilterBuilder = useCallback( + (row: any): boolean => { + if (filterGroups.length === 0) return true; + + // ๋ชจ๋“  ๊ทธ๋ฃน์ด AND๋กœ ์—ฐ๊ฒฐ๋จ (๊ทธ๋ฃน ๊ฐ„) + return filterGroups.every((group) => { + const validConditions = group.conditions.filter( + (c) => c.column && (c.operator === "isEmpty" || c.operator === "isNotEmpty" || c.value) + ); + if (validConditions.length === 0) return true; + + if (group.logic === "AND") { + return validConditions.every((cond) => evaluateCondition(row[cond.column], cond)); + } else { + return validConditions.some((cond) => evaluateCondition(row[cond.column], cond)); + } + }); + }, + [filterGroups, evaluateCondition] + ); + + // ๐Ÿ†• ์ปฌ๋Ÿผ ๋“œ๋ž˜๊ทธ ์‹œ์ž‘ + const handleColumnDragStart = useCallback((e: React.DragEvent, index: number) => { + if (!isColumnDragEnabled) return; + + setDraggedColumnIndex(index); + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", `col-${index}`); + }, [isColumnDragEnabled]); + + // ๐Ÿ†• ์ปฌ๋Ÿผ ๋“œ๋ž˜๊ทธ ์˜ค๋ฒ„ + const handleColumnDragOver = useCallback((e: React.DragEvent, index: number) => { + if (!isColumnDragEnabled || draggedColumnIndex === null) return; + + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + + if (index !== draggedColumnIndex) { + setDropTargetColumnIndex(index); + } + }, [isColumnDragEnabled, draggedColumnIndex]); + + // ๐Ÿ†• ์ปฌ๋Ÿผ ๋“œ๋ž˜๊ทธ ์ข…๋ฃŒ + const handleColumnDragEnd = useCallback(() => { + setDraggedColumnIndex(null); + setDropTargetColumnIndex(null); + }, []); + + // ๐Ÿ†• ์ปฌ๋Ÿผ ๋“œ๋กญ + const handleColumnDrop = useCallback((e: React.DragEvent, targetIndex: number) => { + e.preventDefault(); + + if (!isColumnDragEnabled || draggedColumnIndex === null || draggedColumnIndex === targetIndex) { + handleColumnDragEnd(); + return; + } + + // ์ปฌ๋Ÿผ ์ˆœ์„œ ๋ณ€๊ฒฝ + const newOrder = [...(columnOrder.length > 0 ? columnOrder : visibleColumns.map((c) => c.columnName))]; + const [movedColumn] = newOrder.splice(draggedColumnIndex, 1); + newOrder.splice(targetIndex, 0, movedColumn); + + setColumnOrder(newOrder); + toast.info("์ปฌ๋Ÿผ ์ˆœ์„œ๊ฐ€ ๋ณ€๊ฒฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + console.log("โœ… ์ปฌ๋Ÿผ ์ˆœ์„œ ๋ณ€๊ฒฝ:", { from: draggedColumnIndex, to: targetIndex }); + + handleColumnDragEnd(); + }, [isColumnDragEnabled, draggedColumnIndex, columnOrder, visibleColumns, handleColumnDragEnd]); + + // ๐Ÿ†• ํ–‰ ๋“œ๋ž˜๊ทธ ์‹œ์ž‘ + const handleRowDragStart = useCallback((e: React.DragEvent, index: number) => { + if (!isDragEnabled) return; + + setDraggedRowIndex(index); + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", String(index)); + + // ๋“œ๋ž˜๊ทธ ์ด๋ฏธ์ง€ ์„ค์ • (๋ฐ˜ํˆฌ๋ช…) + const dragImage = e.currentTarget.cloneNode(true) as HTMLElement; + dragImage.style.opacity = "0.5"; + dragImage.style.position = "absolute"; + dragImage.style.top = "-1000px"; + document.body.appendChild(dragImage); + e.dataTransfer.setDragImage(dragImage, 0, 0); + setTimeout(() => document.body.removeChild(dragImage), 0); + }, [isDragEnabled]); + + // ๐Ÿ†• ํ–‰ ๋“œ๋ž˜๊ทธ ์˜ค๋ฒ„ + const handleRowDragOver = useCallback((e: React.DragEvent, index: number) => { + if (!isDragEnabled || draggedRowIndex === null) return; + + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + + if (index !== draggedRowIndex) { + setDropTargetIndex(index); + } + }, [isDragEnabled, draggedRowIndex]); + + // ๐Ÿ†• ํ–‰ ๋“œ๋ž˜๊ทธ ์ข…๋ฃŒ + const handleRowDragEnd = useCallback(() => { + setDraggedRowIndex(null); + setDropTargetIndex(null); + }, []); + + // ๐Ÿ†• ํ–‰ ๋“œ๋กญ + const handleRowDrop = useCallback(async (e: React.DragEvent, targetIndex: number) => { + e.preventDefault(); + + if (!isDragEnabled || draggedRowIndex === null || draggedRowIndex === targetIndex) { + handleRowDragEnd(); + return; + } + + try { + // ๋กœ์ปฌ ๋ฐ์ดํ„ฐ ์žฌ์ •๋ ฌ + const newData = [...filteredData]; + const [movedRow] = newData.splice(draggedRowIndex, 1); + newData.splice(targetIndex, 0, movedRow); + + // ์„œ๋ฒ„์— ์ˆœ์„œ ์ €์žฅ (order_index ํ•„๋“œ๊ฐ€ ์žˆ๋Š” ๊ฒฝ์šฐ) + const orderField = (tableConfig as any).orderField || "order_index"; + const hasOrderField = newData[0] && orderField in newData[0]; + + if (hasOrderField && tableConfig.selectedTable) { + const { apiClient } = await import("@/lib/api/client"); + const primaryKeyField = tableConfig.primaryKey || "id"; + + // ์˜ํ–ฅ๋ฐ›๋Š” ํ–‰๋“ค์˜ ์ˆœ์„œ ์—…๋ฐ์ดํŠธ + const updates = newData.map((row, idx) => ({ + tableName: tableConfig.selectedTable, + keyField: primaryKeyField, + keyValue: row[primaryKeyField], + updateField: orderField, + updateValue: idx + 1, + })); + + // ๋ฐฐ์น˜ ์—…๋ฐ์ดํŠธ + await Promise.all( + updates.map((update) => + apiClient.put(`/dynamic-form/update-field`, update) + ) + ); + + toast.success("์ˆœ์„œ๊ฐ€ ๋ณ€๊ฒฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + setRefreshTrigger((prev) => prev + 1); + } else { + // ๋กœ์ปฌ์—์„œ๋งŒ ์ˆœ์„œ ๋ณ€๊ฒฝ (์ €์žฅ ์•ˆํ•จ) + toast.info("์ˆœ์„œ๊ฐ€ ๋ณ€๊ฒฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. (๋กœ์ปฌ๋งŒ)"); + } + + console.log("โœ… ํ–‰ ์ˆœ์„œ ๋ณ€๊ฒฝ:", { from: draggedRowIndex, to: targetIndex }); + } catch (error) { + console.error("โŒ ํ–‰ ์ˆœ์„œ ๋ณ€๊ฒฝ ์‹คํŒจ:", error); + toast.error("์ˆœ์„œ ๋ณ€๊ฒฝ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."); + } + + handleRowDragEnd(); + }, [isDragEnabled, draggedRowIndex, filteredData, tableConfig, handleRowDragEnd]); + + // ๐Ÿ†• PDF ๋‚ด๋ณด๋‚ด๊ธฐ (์ธ์‡„์šฉ HTML ์ƒ์„ฑ) + const exportToPdf = useCallback((exportAll: boolean = true) => { + try { + // ๋‚ด๋ณด๋‚ผ ๋ฐ์ดํ„ฐ ์„ ํƒ + let exportData: any[]; + if (exportAll) { + exportData = filteredData; + } else { + exportData = filteredData.filter((row, index) => { + const rowKey = getRowKey(row, index); + return selectedRows.has(rowKey); + }); + } + + if (exportData.length === 0) { + toast.error(exportAll ? "๋‚ด๋ณด๋‚ผ ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค." : "์„ ํƒ๋œ ํ–‰์ด ์—†์Šต๋‹ˆ๋‹ค."); + return; + } + + // ์ปฌ๋Ÿผ ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ (์ฒดํฌ๋ฐ•์Šค ์ œ์™ธ) + const exportColumns = visibleColumns.filter((col) => col.columnName !== "__checkbox__"); + + // ์ธ์‡„์šฉ HTML ์ƒ์„ฑ + const printContent = ` + + + + + ${tableLabel || tableConfig.selectedTable || "๋ฐ์ดํ„ฐ"} + + + +

${tableLabel || tableConfig.selectedTable || "๋ฐ์ดํ„ฐ ๋ชฉ๋ก"}

+
+ ์ถœ๋ ฅ์ผ: ${new Date().toLocaleDateString("ko-KR")} | + ์ด ${exportData.length}๊ฑด +
+ + + + ${exportColumns.map((col) => ``).join("")} + + + + ${exportData.map((row) => ` + + ${exportColumns.map((col) => { + const mappedColumnName = joinColumnMapping[col.columnName] || col.columnName; + let value = row[mappedColumnName]; + + // ์นดํ…Œ๊ณ ๋ฆฌ ๋งคํ•‘ + if (categoryMappings[col.columnName] && value !== null && value !== undefined) { + const mapping = categoryMappings[col.columnName][String(value)]; + if (mapping) value = mapping.label; + } + + const meta = columnMeta[col.columnName]; + const inputType = meta?.inputType || (col as any).inputType; + const isNumeric = inputType === "number" || inputType === "decimal"; + + return ``; + }).join("")} + + `).join("")} + +
${columnLabels[col.columnName] || col.columnName}
${value ?? ""}
+ + + `; + + // ์ƒˆ ์ฐฝ์—์„œ ์ธ์‡„ + const printWindow = window.open("", "_blank"); + if (printWindow) { + printWindow.document.write(printContent); + printWindow.document.close(); + printWindow.onload = () => { + printWindow.print(); + }; + toast.success("์ธ์‡„ ์ฐฝ์ด ์—ด๋ ธ์Šต๋‹ˆ๋‹ค."); + } else { + toast.error("ํŒ์—…์ด ์ฐจ๋‹จ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ํŒ์—…์„ ํ—ˆ์šฉํ•ด์ฃผ์„ธ์š”."); + } + } catch (error) { + console.error("โŒ PDF ๋‚ด๋ณด๋‚ด๊ธฐ ์‹คํŒจ:", error); + toast.error("PDF ๋‚ด๋ณด๋‚ด๊ธฐ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."); + } + }, [filteredData, selectedRows, visibleColumns, columnLabels, joinColumnMapping, categoryMappings, columnMeta, tableLabel, tableConfig.selectedTable, getRowKey]); + + // ๐Ÿ†• ํŽธ์ง‘ ์ค‘ ํ‚ค๋ณด๋“œ ํ•ธ๋“ค๋Ÿฌ (๊ฐ„๋‹จ ๋ฒ„์ „ - Tab ์ด๋™์€ visibleColumns ์ •์˜ ํ›„ ์ฒ˜๋ฆฌ) + const handleEditKeyDown = useCallback((e: React.KeyboardEvent) => { + switch (e.key) { + case "Enter": + e.preventDefault(); + saveEditing(); + break; + case "Escape": + e.preventDefault(); + cancelEditing(); + break; + case "Tab": + e.preventDefault(); + saveEditing(); + // Tab ์ด๋™์€ ํŽธ์ง‘ ์ €์žฅ ํ›„ ํ…Œ์ด๋ธ” ํ‚ค๋ณด๋“œ ํ•ธ๋“ค๋Ÿฌ์—์„œ ์ฒ˜๋ฆฌ + break; + } + }, [saveEditing, cancelEditing]); + + // ๐Ÿ†• ํŽธ์ง‘ ์ž…๋ ฅ ํ•„๋“œ๊ฐ€ ๋‚˜ํƒ€๋‚˜๋ฉด ์ž๋™ ํฌ์ปค์Šค + useEffect(() => { + if (editingCell && editInputRef.current) { + editInputRef.current.focus(); + // select()๋Š” input ์š”์†Œ์—์„œ๋งŒ ์‚ฌ์šฉ ๊ฐ€๋Šฅ (select ์š”์†Œ์—์„œ๋Š” ์‚ฌ์šฉ ๋ถˆ๊ฐ€) + if (typeof editInputRef.current.select === "function") { + editInputRef.current.select(); + } + } + }, [editingCell]); + + // ๐Ÿ†• ํฌ์ปค์Šค๋œ ์…€๋กœ ์Šคํฌ๋กค + useEffect(() => { + if (focusedCell && tableContainerRef.current) { + const focusedCellElement = tableContainerRef.current.querySelector( + `[data-row="${focusedCell.rowIndex}"][data-col="${focusedCell.colIndex}"]` + ) as HTMLElement; + + if (focusedCellElement) { + focusedCellElement.scrollIntoView({ block: "nearest", inline: "nearest" }); + } + } + }, [focusedCell]); + // ์ปฌ๋Ÿผ ๋“œ๋ž˜๊ทธ์•ค๋“œ๋กญ ๊ธฐ๋Šฅ ์ œ๊ฑฐ๋จ (ํ…Œ์ด๋ธ” ์˜ต์…˜ ๋ชจ๋‹ฌ์—์„œ ์ปฌ๋Ÿผ ์ˆœ์„œ ๋ณ€๊ฒฝ ๊ฐ€๋Šฅ) const handleClick = (e: React.MouseEvent) => { @@ -1656,62 +3417,9 @@ export const TableListComponent: React.FC = ({ }; // ======================================== - // ์ปฌ๋Ÿผ ๊ด€๋ จ + // ์ปฌ๋Ÿผ ๊ด€๋ จ (visibleColumns๋Š” ์ƒ๋‹จ์—์„œ ์ •์˜๋จ) // ======================================== - const visibleColumns = useMemo(() => { - let cols = (tableConfig.columns || []).filter((col) => col.visible !== false); - - // columnVisibility๊ฐ€ ์žˆ์œผ๋ฉด ๊ฐ€์‹œ์„ฑ ์ ์šฉ - if (columnVisibility.length > 0) { - cols = cols.filter((col) => { - const visibilityConfig = columnVisibility.find((cv) => cv.columnName === col.columnName); - return visibilityConfig ? visibilityConfig.visible : true; - }); - } - - // ์ฒดํฌ๋ฐ•์Šค ์ปฌ๋Ÿผ (๋‚˜์ค‘์— ์œ„์น˜ ๊ฒฐ์ •) - // ๊ธฐ๋ณธ๊ฐ’: enabled๊ฐ€ undefined๋ฉด true๋กœ ์ฒ˜๋ฆฌ - let checkboxCol: ColumnConfig | null = null; - if (tableConfig.checkbox?.enabled ?? true) { - checkboxCol = { - columnName: "__checkbox__", - displayName: "", - visible: true, - sortable: false, - searchable: false, - width: 50, - align: "center", - order: -1, - }; - } - - // columnOrder ์ƒํƒœ๊ฐ€ ์žˆ์œผ๋ฉด ๊ทธ ์ˆœ์„œ๋Œ€๋กœ ์ •๋ ฌ (์ฒดํฌ๋ฐ•์Šค ์ œ์™ธ) - if (columnOrder.length > 0) { - const orderedCols = columnOrder - .map((colName) => cols.find((c) => c.columnName === colName)) - .filter(Boolean) as ColumnConfig[]; - - // columnOrder์— ์—†๋Š” ์ƒˆ๋กœ์šด ์ปฌ๋Ÿผ๋“ค ์ถ”๊ฐ€ - const remainingCols = cols.filter((c) => !columnOrder.includes(c.columnName)); - - cols = [...orderedCols, ...remainingCols]; - } else { - cols = cols.sort((a, b) => (a.order || 0) - (b.order || 0)); - } - - // ์ฒดํฌ๋ฐ•์Šค๋ฅผ ๋งจ ์•ž ๋˜๋Š” ๋งจ ๋’ค์— ์ถ”๊ฐ€ - if (checkboxCol) { - if (tableConfig.checkbox?.position === "right") { - cols = [...cols, checkboxCol]; - } else { - cols = [checkboxCol, ...cols]; - } - } - - return cols; - }, [tableConfig.columns, tableConfig.checkbox, columnOrder, columnVisibility]); - // ๐Ÿ†• visibleColumns๊ฐ€ ๋ณ€๊ฒฝ๋  ๋•Œ๋งˆ๋‹ค ํ˜„์žฌ ์ปฌ๋Ÿผ ์ˆœ์„œ๋ฅผ ๋ถ€๋ชจ์—๊ฒŒ ์ „๋‹ฌ const lastColumnOrderRef = useRef(""); @@ -1782,6 +3490,231 @@ export const TableListComponent: React.FC = ({ ); }, [visibleColumns.length, visibleColumns.map((c) => c.columnName).join(",")]); // ์˜์กด์„ฑ ๋‹จ์ˆœํ™” + // ๐Ÿ†• ํ‚ค๋ณด๋“œ ๋„ค๋น„๊ฒŒ์ด์…˜ ํ•ธ๋“ค๋Ÿฌ (visibleColumns ์ •์˜ ํ›„์— ๋ฐฐ์น˜) + const handleTableKeyDown = useCallback((e: React.KeyboardEvent) => { + // ํŽธ์ง‘ ์ค‘์ผ ๋•Œ๋Š” ํ…Œ์ด๋ธ” ํ‚ค๋ณด๋“œ ํ•ธ๋“ค๋Ÿฌ ๋ฌด์‹œ (ํŽธ์ง‘ ์ž…๋ ฅ์—์„œ ์ฒ˜๋ฆฌ) + if (editingCell) return; + + if (!focusedCell || data.length === 0) return; + + const { rowIndex, colIndex } = focusedCell; + const maxRowIndex = data.length - 1; + const maxColIndex = visibleColumns.length - 1; + + switch (e.key) { + case "ArrowUp": + e.preventDefault(); + if (rowIndex > 0) { + setFocusedCell({ rowIndex: rowIndex - 1, colIndex }); + } + break; + case "ArrowDown": + e.preventDefault(); + if (rowIndex < maxRowIndex) { + setFocusedCell({ rowIndex: rowIndex + 1, colIndex }); + } + break; + case "ArrowLeft": + e.preventDefault(); + if (colIndex > 0) { + setFocusedCell({ rowIndex, colIndex: colIndex - 1 }); + } + break; + case "ArrowRight": + e.preventDefault(); + if (colIndex < maxColIndex) { + setFocusedCell({ rowIndex, colIndex: colIndex + 1 }); + } + break; + case "Enter": + e.preventDefault(); + // ํ˜„์žฌ ํ–‰ ์„ ํƒ/ํ•ด์ œ + const enterRow = data[rowIndex]; + if (enterRow) { + const rowKey = getRowKey(enterRow, rowIndex); + const isCurrentlySelected = selectedRows.has(rowKey); + handleRowSelection(rowKey, !isCurrentlySelected); + } + break; + case " ": // Space + e.preventDefault(); + // ์ฒดํฌ๋ฐ•์Šค ํ† ๊ธ€ + const spaceRow = data[rowIndex]; + if (spaceRow) { + const currentRowKey = getRowKey(spaceRow, rowIndex); + const isChecked = selectedRows.has(currentRowKey); + handleRowSelection(currentRowKey, !isChecked); + } + break; + case "F2": + // ๐Ÿ†• F2: ํŽธ์ง‘ ๋ชจ๋“œ ์ง„์ž… + e.preventDefault(); + { + const col = visibleColumns[colIndex]; + if (col && col.columnName !== "__checkbox__") { + const row = data[rowIndex]; + const mappedCol = joinColumnMapping[col.columnName] || col.columnName; + const val = row?.[mappedCol]; + setEditingCell({ + rowIndex, + colIndex, + columnName: col.columnName, + originalValue: val + }); + setEditingValue(val !== null && val !== undefined ? String(val) : ""); + } + } + break; + case "b": + case "B": + // ๐Ÿ†• Ctrl+B: ๋ฐฐ์น˜ ํŽธ์ง‘ ๋ชจ๋“œ ํ† ๊ธ€ + if (e.ctrlKey) { + e.preventDefault(); + setEditMode((prev) => { + const newMode = prev === "immediate" ? "batch" : "immediate"; + if (newMode === "immediate" && pendingChanges.size > 0) { + // ์ฆ‰์‹œ ๋ชจ๋“œ๋กœ ์ „ํ™˜ ์‹œ ์ €์žฅ๋˜์ง€ ์•Š์€ ๋ณ€๊ฒฝ์‚ฌํ•ญ ๊ฒฝ๊ณ  + const confirmDiscard = window.confirm( + `์ €์žฅ๋˜์ง€ ์•Š์€ ${pendingChanges.size}๊ฐœ์˜ ๋ณ€๊ฒฝ์‚ฌํ•ญ์ด ์žˆ์Šต๋‹ˆ๋‹ค. ์ทจ์†Œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?` + ); + if (confirmDiscard) { + setPendingChanges(new Map()); + setLocalEditedData({}); + toast.info("๋ฐฐ์น˜ ํŽธ์ง‘ ๋ชจ๋“œ ์ข…๋ฃŒ"); + return "immediate"; + } + return "batch"; + } + toast.info(newMode === "batch" ? "๋ฐฐ์น˜ ํŽธ์ง‘ ๋ชจ๋“œ ์‹œ์ž‘ (Ctrl+B๋กœ ์ข…๋ฃŒ)" : "์ฆ‰์‹œ ์ €์žฅ ๋ชจ๋“œ"); + return newMode; + }); + } + break; + case "s": + case "S": + // ๐Ÿ†• Ctrl+S: ๋ฐฐ์น˜ ์ €์žฅ + if (e.ctrlKey && editMode === "batch") { + e.preventDefault(); + saveBatchChanges(); + } + break; + case "c": + case "C": + // ๐Ÿ†• Ctrl+C: ์„ ํƒ๋œ ํ–‰/์…€ ๋ณต์‚ฌ + if (e.ctrlKey) { + e.preventDefault(); + handleCopy(); + } + break; + case "v": + case "V": + // ๐Ÿ†• Ctrl+V: ๋ถ™์—ฌ๋„ฃ๊ธฐ (ํŽธ์ง‘ ์ค‘์ธ ๊ฒฝ์šฐ๋งŒ) + if (e.ctrlKey && editingCell) { + // ๊ธฐ๋ณธ ๋™์ž‘ ํ—ˆ์šฉ (input์—์„œ ์ฒ˜๋ฆฌ) + } + break; + case "a": + case "A": + // ๐Ÿ†• Ctrl+A: ์ „์ฒด ์„ ํƒ + if (e.ctrlKey) { + e.preventDefault(); + handleSelectAllRows(); + } + break; + case "f": + case "F": + // ๐Ÿ†• Ctrl+F: ํ†ตํ•ฉ ๊ฒ€์ƒ‰ ํŒจ๋„ ์—ด๊ธฐ + if (e.ctrlKey) { + e.preventDefault(); + setIsSearchPanelOpen(true); + } + break; + case "F3": + // ๐Ÿ†• F3: ๋‹ค์Œ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ / Shift+F3: ์ด์ „ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ + e.preventDefault(); + if (e.shiftKey) { + goToPrevSearchResult(); + } else { + goToNextSearchResult(); + } + break; + case "Home": + e.preventDefault(); + if (e.ctrlKey) { + // Ctrl+Home: ์ฒซ ๋ฒˆ์งธ ์…€๋กœ + setFocusedCell({ rowIndex: 0, colIndex: 0 }); + } else { + // Home: ํ˜„์žฌ ํ–‰์˜ ์ฒซ ๋ฒˆ์งธ ์…€๋กœ + setFocusedCell({ rowIndex, colIndex: 0 }); + } + break; + case "End": + e.preventDefault(); + if (e.ctrlKey) { + // Ctrl+End: ๋งˆ์ง€๋ง‰ ์…€๋กœ + setFocusedCell({ rowIndex: maxRowIndex, colIndex: maxColIndex }); + } else { + // End: ํ˜„์žฌ ํ–‰์˜ ๋งˆ์ง€๋ง‰ ์…€๋กœ + setFocusedCell({ rowIndex, colIndex: maxColIndex }); + } + break; + case "PageUp": + e.preventDefault(); + // 10ํ–‰ ์œ„๋กœ + setFocusedCell({ rowIndex: Math.max(0, rowIndex - 10), colIndex }); + break; + case "PageDown": + e.preventDefault(); + // 10ํ–‰ ์•„๋ž˜๋กœ + setFocusedCell({ rowIndex: Math.min(maxRowIndex, rowIndex + 10), colIndex }); + break; + case "Escape": + e.preventDefault(); + // ํฌ์ปค์Šค ํ•ด์ œ + setFocusedCell(null); + break; + case "Tab": + e.preventDefault(); + if (e.shiftKey) { + // Shift+Tab: ์ด์ „ ์…€ + if (colIndex > 0) { + setFocusedCell({ rowIndex, colIndex: colIndex - 1 }); + } else if (rowIndex > 0) { + setFocusedCell({ rowIndex: rowIndex - 1, colIndex: maxColIndex }); + } + } else { + // Tab: ๋‹ค์Œ ์…€ + if (colIndex < maxColIndex) { + setFocusedCell({ rowIndex, colIndex: colIndex + 1 }); + } else if (rowIndex < maxRowIndex) { + setFocusedCell({ rowIndex: rowIndex + 1, colIndex: 0 }); + } + } + break; + default: + // ๐Ÿ†• ์ง์ ‘ ํƒ€์ดํ•‘์œผ๋กœ ํŽธ์ง‘ ๋ชจ๋“œ ์ง„์ž… (์˜๋ฌธ์ž, ์ˆซ์ž, ํ•œ๊ธ€ ๋“ฑ) + if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) { + const column = visibleColumns[colIndex]; + if (column && column.columnName !== "__checkbox__") { + e.preventDefault(); + // ํŽธ์ง‘ ์‹œ์ž‘ (ํ˜„์žฌ ํ‚ค๋ฅผ ์ดˆ๊ธฐ๊ฐ’์œผ๋กœ) + const row = data[rowIndex]; + const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; + const value = row?.[mappedColumnName]; + + setEditingCell({ + rowIndex, + colIndex, + columnName: column.columnName, + originalValue: value + }); + setEditingValue(e.key); // ์ž…๋ ฅํ•œ ํ‚ค๋กœ ์‹œ์ž‘ + } + } + break; + } + }, [editingCell, focusedCell, data, visibleColumns, joinColumnMapping, selectedRows, getRowKey, handleRowSelection]); + const getColumnWidth = (column: ColumnConfig) => { if (column.columnName === "__checkbox__") return 50; if (column.width) return column.width; @@ -2264,14 +4197,42 @@ export const TableListComponent: React.FC = ({ groupValues[col] = items[0]?.[col]; }); + // ๐Ÿ†• ๊ทธ๋ฃน๋ณ„ ์†Œ๊ณ„ ๊ณ„์‚ฐ + const groupSummary: Record = {}; + + // ์ˆซ์žํ˜• ์ปฌ๋Ÿผ์— ๋Œ€ํ•ด ์†Œ๊ณ„ ๊ณ„์‚ฐ + (tableConfig.columns || []).forEach((col: { columnName: string }) => { + if (col.columnName === "__checkbox__") return; + + const colMeta = columnMeta?.[col.columnName]; + const inputType = colMeta?.inputType; + const isNumeric = inputType === "number" || inputType === "decimal"; + + if (isNumeric) { + const values = items + .map((item) => parseFloat(item[col.columnName])) + .filter((v) => !isNaN(v)); + + if (values.length > 0) { + const sum = values.reduce((a, b) => a + b, 0); + groupSummary[col.columnName] = { + sum, + avg: sum / values.length, + count: values.length, + }; + } + } + }); + return { groupKey, groupValues, items, count: items.length, + summary: groupSummary, // ๐Ÿ†• ๊ทธ๋ฃน๋ณ„ ์†Œ๊ณ„ }; }); - }, [data, groupByColumns, columnLabels, columnMeta]); + }, [data, groupByColumns, columnLabels, columnMeta, tableConfig.columns]); // ์ €์žฅ๋œ ๊ทธ๋ฃน ์„ค์ • ๋ถˆ๋Ÿฌ์˜ค๊ธฐ useEffect(() => { @@ -2485,19 +4446,81 @@ export const TableListComponent: React.FC = ({ - {/* ์šฐ์ธก ์ƒˆ๋กœ๊ณ ์นจ ๋ฒ„ํŠผ */} - + {/* ์šฐ์ธก ๋ฒ„ํŠผ ๊ทธ๋ฃน */} +
+ {/* ๐Ÿ†• ๋‚ด๋ณด๋‚ด๊ธฐ ๋ฒ„ํŠผ (Excel/PDF) */} + + + + + +
+
Excel
+ + +
+
PDF/์ธ์‡„
+ + +
+ + + + {/* ์ƒˆ๋กœ๊ณ ์นจ ๋ฒ„ํŠผ */} + +
); - }, [tableConfig.pagination, isDesignMode, currentPage, totalPages, totalItems, loading]); + }, [tableConfig.pagination, isDesignMode, currentPage, totalPages, totalItems, loading, selectedRows.size, exportToExcel, exportToPdf]); // ======================================== // ๋ Œ๋”๋ง @@ -2597,6 +4620,236 @@ export const TableListComponent: React.FC = ({
{/* ํ•„ํ„ฐ ํ—ค๋”๋Š” TableSearchWidget์œผ๋กœ ์ด๋™ */} + {/* ๐Ÿ†• DevExpress ์Šคํƒ€์ผ ๊ธฐ๋Šฅ ํˆด๋ฐ” */} +
+ {/* ํŽธ์ง‘ ๋ชจ๋“œ ํ† ๊ธ€ */} +
+ +
+ + {/* ๋‚ด๋ณด๋‚ด๊ธฐ ๋ฒ„ํŠผ๋“ค */} +
+ + +
+ + {/* ๋ณต์‚ฌ ๋ฒ„ํŠผ */} +
+ +
+ + {/* ์„ ํƒ ์ •๋ณด */} + {selectedRows.size > 0 && ( +
+ + {selectedRows.size}๊ฐœ ์„ ํƒ๋จ + + +
+ )} + + {/* ๐Ÿ†• ํ†ตํ•ฉ ๊ฒ€์ƒ‰ ํŒจ๋„ */} +
+ {isSearchPanelOpen ? ( +
+ setGlobalSearchTerm(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + executeGlobalSearch(globalSearchTerm); + } else if (e.key === "Escape") { + clearGlobalSearch(); + } else if (e.key === "F3" || (e.key === "g" && (e.ctrlKey || e.metaKey))) { + e.preventDefault(); + if (e.shiftKey) { + goToPrevSearchResult(); + } else { + goToNextSearchResult(); + } + } + }} + placeholder="๊ฒ€์ƒ‰์–ด ์ž…๋ ฅ... (Enter)" + className="border-input bg-background h-7 w-32 rounded border px-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary sm:w-48" + autoFocus + /> + {searchHighlights.size > 0 && ( + + {searchHighlights.size}๊ฐœ + + )} + + + +
+ ) : ( + + )} +
+ + {/* ๐Ÿ†• Filter Builder (๊ณ ๊ธ‰ ํ•„ํ„ฐ) ๋ฒ„ํŠผ */} +
+ + {activeFilterCount > 0 && ( + + )} +
+ + {/* ์ƒˆ๋กœ๊ณ ์นจ */} +
+ +
+
+ + {/* ๐Ÿ†• ๋ฐฐ์น˜ ํŽธ์ง‘ ํˆด๋ฐ” */} + {(editMode === "batch" || pendingChanges.size > 0) && ( +
+
+ + ๋ฐฐ์น˜ ํŽธ์ง‘ ๋ชจ๋“œ + + {pendingChanges.size > 0 && ( + + {pendingChanges.size}๊ฐœ ๋ณ€๊ฒฝ์‚ฌํ•ญ + + )} +
+
+ + +
+
+ )} + {/* ๊ทธ๋ฃน ํ‘œ์‹œ ๋ฐฐ์ง€ */} {groupByColumns.length > 0 && (
@@ -2623,17 +4876,23 @@ export const TableListComponent: React.FC = ({
)} - {/* ํ…Œ์ด๋ธ” ์ปจํ…Œ์ด๋„ˆ */} + {/* ํ…Œ์ด๋ธ” ์ปจํ…Œ์ด๋„ˆ - ํ‚ค๋ณด๋“œ ๋„ค๋น„๊ฒŒ์ด์…˜ ์ง€์› */}
{/* ์Šคํฌ๋กค ์˜์—ญ */}
= ({ height: "100%", overflow: "auto", }} + onScroll={handleVirtualScroll} > {/* ํ…Œ์ด๋ธ” */} = ({ backgroundColor: "hsl(var(--background))", }} > + {/* ๐Ÿ†• Multi-Level Headers (Column Bands) */} + {columnBandsInfo?.hasBands && ( + + {visibleColumns.map((column, colIdx) => { + // ์ด ์ปฌ๋Ÿผ์ด ์†ํ•œ band ์ฐพ๊ธฐ + const band = columnBandsInfo.bands.find( + (b) => b.columns.includes(column.columnName) && b.startIndex === colIdx + ); + + // band์˜ ์ฒซ ๋ฒˆ์งธ ์ปฌ๋Ÿผ์ธ ๊ฒฝ์šฐ์—๋งŒ ๋ Œ๋”๋ง + if (band) { + return ( + + ); + } + + // band์— ์†ํ•˜์ง€ ์•Š์€ ์ปฌ๋Ÿผ (๊ฐœ๋ณ„ ํ‘œ์‹œ) + const isInAnyBand = columnBandsInfo.bands.some( + (b) => b.columns.includes(column.columnName) + ); + if (!isInAnyBand) { + return ( + + ); + } + + // band์˜ ์ค‘๊ฐ„ ์ปฌ๋Ÿผ์€ ๋ Œ๋”๋งํ•˜์ง€ ์•Š์Œ + return null; + })} + + )} = ({ } } + // ๐Ÿ†• Column Reordering ์ƒํƒœ + const isColumnDragging = draggedColumnIndex === columnIndex; + const isColumnDropTarget = dropTargetColumnIndex === columnIndex; + return ( ))} + {/* ๐Ÿ†• ๊ทธ๋ฃน๋ณ„ ์†Œ๊ณ„ ํ–‰ */} + {!isCollapsed && group.summary && Object.keys(group.summary).length > 0 && ( + + {visibleColumns.map((column, colIndex) => { + const summary = group.summary?.[column.columnName]; + const meta = columnMeta[column.columnName]; + const inputType = meta?.inputType || (column as any).inputType; + const isNumeric = inputType === "number" || inputType === "decimal"; + + if (colIndex === 0 && column.columnName === "__checkbox__") { + return ( + + ); + } + + if (colIndex === 0 && column.columnName !== "__checkbox__") { + return ( + + ); + } + + if (summary) { + return ( + + ); + } + + return + )} ); }) ) : ( - // ์ผ๋ฐ˜ ๋ Œ๋”๋ง (๊ทธ๋ฃน ์—†์Œ) - filteredData.map((row, index) => ( - handleRowClick(row, index, e)} - > - {visibleColumns.map((column, colIndex) => { - const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; - const cellValue = row[mappedColumnName]; + // ์ผ๋ฐ˜ ๋ Œ๋”๋ง (๊ทธ๋ฃน ์—†์Œ) - ํ‚ค๋ณด๋“œ ๋„ค๋น„๊ฒŒ์ด์…˜ ์ง€์› + <> + {/* ๐Ÿ†• Virtual Scrolling: Top Spacer */} + {isVirtualScrollEnabled && virtualScrollInfo.topSpacerHeight > 0 && ( + + + )} + {/* ๋ฐ์ดํ„ฐ ํ–‰ ๋ Œ๋”๋ง */} + {(isVirtualScrollEnabled ? virtualScrollInfo.visibleData : filteredData).map((row, idx) => { + // Virtual Scrolling์—์„œ๋Š” ์‹ค์ œ ์ธ๋ฑ์Šค ๊ณ„์‚ฐ + const index = isVirtualScrollEnabled ? virtualScrollInfo.startIndex + idx : idx; + const rowKey = getRowKey(row, index); + const isRowSelected = selectedRows.has(rowKey); + const isRowFocused = focusedCell?.rowIndex === index; + + // ๐Ÿ†• Drag & Drop ์ƒํƒœ + const isDragging = draggedRowIndex === index; + const isDropTarget = dropTargetIndex === index; + + return ( + handleRowClick(row, index, e)} + role="row" + aria-selected={isRowSelected} + // ๐Ÿ†• Drag & Drop ์ด๋ฒคํŠธ + draggable={isDragEnabled} + onDragStart={(e) => handleRowDragStart(e, index)} + onDragOver={(e) => handleRowDragOver(e, index)} + onDragEnd={handleRowDragEnd} + onDrop={(e) => handleRowDrop(e, index)} + > + {visibleColumns.map((column, colIndex) => { + const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; + // ๐Ÿ†• ๋ฐฐ์น˜ ํŽธ์ง‘: ๋กœ์ปฌ ์ˆ˜์ • ๋ฐ์ดํ„ฐ ์šฐ์„  ํ‘œ์‹œ + const cellValue = editMode === "batch" + ? getDisplayValue(row, index, mappedColumnName) + : row[mappedColumnName]; - const meta = columnMeta[column.columnName]; - const inputType = meta?.inputType || column.inputType; - const isNumeric = inputType === "number" || inputType === "decimal"; + const meta = columnMeta[column.columnName]; + const inputType = meta?.inputType || column.inputType; + const isNumeric = inputType === "number" || inputType === "decimal"; - const isFrozen = frozenColumns.includes(column.columnName); - const frozenIndex = frozenColumns.indexOf(column.columnName); + const isFrozen = frozenColumns.includes(column.columnName); + const frozenIndex = frozenColumns.indexOf(column.columnName); + + // ์…€ ํฌ์ปค์Šค ์ƒํƒœ + const isCellFocused = focusedCell?.rowIndex === index && focusedCell?.colIndex === colIndex; + + // ๐Ÿ†• ๋ฐฐ์น˜ ํŽธ์ง‘: ์ˆ˜์ •๋œ ์…€ ์—ฌ๋ถ€ + const isModified = isCellModified(index, mappedColumnName); + + // ๐Ÿ†• ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ์—๋Ÿฌ + const cellValidationError = getCellValidationError(index, mappedColumnName); + + // ๐Ÿ†• ๊ฒ€์ƒ‰ ํ•˜์ด๋ผ์ดํŠธ ์—ฌ๋ถ€ + const isSearchHighlighted = searchHighlights.has(`${index}-${colIndex}`); - // ํ‹€๊ณ ์ •๋œ ์ปฌ๋Ÿผ์˜ left ์œ„์น˜ ๊ณ„์‚ฐ - let leftPosition = 0; - if (isFrozen && frozenIndex > 0) { - for (let i = 0; i < frozenIndex; i++) { - const frozenCol = frozenColumns[i]; - const frozenColWidth = columnWidths[frozenCol] || 150; - leftPosition += frozenColWidth; + // ํ‹€๊ณ ์ •๋œ ์ปฌ๋Ÿผ์˜ left ์œ„์น˜ ๊ณ„์‚ฐ + let leftPosition = 0; + if (isFrozen && frozenIndex > 0) { + for (let i = 0; i < frozenIndex; i++) { + const frozenCol = frozenColumns[i]; + const frozenColWidth = columnWidths[frozenCol] || 150; + leftPosition += frozenColWidth; + } } - } - return ( - - ); - })} - - )) + return ( + + ); + })} + + ); + })} + {/* ๐Ÿ†• Virtual Scrolling: Bottom Spacer */} + {isVirtualScrollEnabled && virtualScrollInfo.bottomSpacerHeight > 0 && ( + + + )} + )} + + {/* ๐Ÿ†• ๋ฐ์ดํ„ฐ ์š”์•ฝ (Total Summaries) */} + {summaryData && Object.keys(summaryData).length > 0 && ( + + + {visibleColumns.map((column, colIndex) => { + const summary = summaryData[column.columnName]; + const columnWidth = columnWidths[column.columnName]; + const isFrozen = frozenColumns.includes(column.columnName); + const frozenIndex = frozenColumns.indexOf(column.columnName); + + // ํ‹€๊ณ ์ •๋œ ์ปฌ๋Ÿผ์˜ left ์œ„์น˜ ๊ณ„์‚ฐ + let leftPosition = 0; + if (isFrozen && frozenIndex > 0) { + for (let i = 0; i < frozenIndex; i++) { + const frozenCol = frozenColumns[i]; + const frozenColWidth = columnWidths[frozenCol] || 150; + leftPosition += frozenColWidth; + } + } + + const meta = columnMeta[column.columnName]; + const inputType = meta?.inputType || (column as any).inputType; + const isNumeric = inputType === "number" || inputType === "decimal"; + + return ( + + ); + })} + + + )}
+ {band.caption} + + {columnLabels[column.columnName] || column.columnName} +
= ({ column.columnName !== "__checkbox__" && "hover:bg-muted/70 cursor-pointer transition-colors", isFrozen && "sticky z-60 shadow-[2px_0_4px_rgba(0,0,0,0.1)]", + // ๐Ÿ†• Column Reordering ์Šคํƒ€์ผ + isColumnDragEnabled && column.columnName !== "__checkbox__" && "cursor-grab active:cursor-grabbing", + isColumnDragging && "opacity-50 bg-primary/20", + isColumnDropTarget && "border-l-4 border-l-primary", )} style={{ textAlign: column.columnName === "__checkbox__" ? "center" : "center", @@ -2708,6 +5022,12 @@ export const TableListComponent: React.FC = ({ backgroundColor: "hsl(var(--muted))", ...(isFrozen && { left: `${leftPosition}px` }), }} + // ๐Ÿ†• Column Reordering ์ด๋ฒคํŠธ + draggable={isColumnDragEnabled && column.columnName !== "__checkbox__"} + onDragStart={(e) => handleColumnDragStart(e, columnIndex)} + onDragOver={(e) => handleColumnDragOver(e, columnIndex)} + onDragEnd={handleColumnDragEnd} + onDrop={(e) => handleColumnDrop(e, columnIndex)} onClick={() => { if (isResizing.current) return; if (column.sortable !== false && column.columnName !== "__checkbox__") { @@ -2718,11 +5038,81 @@ export const TableListComponent: React.FC = ({ {column.columnName === "__checkbox__" ? ( renderCheckboxHeader() ) : ( -
+
{columnLabels[column.columnName] || column.displayName} {column.sortable !== false && sortColumn === column.columnName && ( {sortDirection === "asc" ? "โ†‘" : "โ†“"} )} + {/* ๐Ÿ†• ํ—ค๋” ํ•„ํ„ฐ ๋ฒ„ํŠผ */} + {tableConfig.headerFilter !== false && columnUniqueValues[column.columnName]?.length > 0 && ( + setOpenFilterColumn(open ? column.columnName : null)} + > + + + + e.stopPropagation()} + > +
+
+ ํ•„ํ„ฐ: {columnLabels[column.columnName] || column.displayName} + {headerFilters[column.columnName]?.size > 0 && ( + + )} +
+
+ {columnUniqueValues[column.columnName]?.slice(0, 50).map((val) => { + const isSelected = headerFilters[column.columnName]?.has(val); + return ( +
toggleHeaderFilter(column.columnName, val)} + > +
+ {isSelected && } +
+ {val || "(๋นˆ ๊ฐ’)"} +
+ ); + })} + {(columnUniqueValues[column.columnName]?.length || 0) > 50 && ( +
+ ...์™ธ {(columnUniqueValues[column.columnName]?.length || 0) - 50}๊ฐœ +
+ )} +
+
+
+
+ )}
)} {/* ๋ฆฌ์‚ฌ์ด์ฆˆ ํ•ธ๋“ค (์ฒดํฌ๋ฐ•์Šค ์ œ์™ธ) */} @@ -2926,71 +5316,317 @@ export const TableListComponent: React.FC = ({ })}
+ ์†Œ๊ณ„ + + ์†Œ๊ณ„ ({group.count}๊ฑด) + + {summary.sum.toLocaleString()} + ; + })} +
+
- {column.columnName === "__checkbox__" - ? renderCheckboxCell(row, index) - : formatCellValue(cellValue, column, row)} -
handleCellClick(index, colIndex, e)} + onDoubleClick={() => handleCellDoubleClick(index, colIndex, column.columnName, cellValue)} + onContextMenu={(e) => handleContextMenu(e, index, colIndex, row)} + role="gridcell" + tabIndex={isCellFocused ? 0 : -1} + > + {/* ๐Ÿ†• ์ธ๋ผ์ธ ํŽธ์ง‘ ๋ชจ๋“œ */} + {editingCell?.rowIndex === index && editingCell?.colIndex === colIndex ? ( + // ๐Ÿ†• Cascading Lookups: ๋“œ๋กญ๋‹ค์šด ๋˜๋Š” ์ผ๋ฐ˜ ์ž…๋ ฅ + (() => { + const cascadingConfig = (tableConfig as any).cascadingLookups?.[column.columnName]; + const options = cascadingConfig ? getCascadingOptions(column.columnName, row) : []; + + // ๋ถ€๋ชจ ๊ฐ’์ด ๋ณ€๊ฒฝ๋˜๋ฉด ์˜ต์…˜ ๋กœ๋”ฉ + if (cascadingConfig && options.length === 0) { + const parentValue = row[cascadingConfig.parentColumn]; + if (parentValue !== undefined && parentValue !== null) { + loadCascadingOptions(column.columnName, cascadingConfig.parentColumn, parentValue); + } + } + + // ์นดํ…Œ๊ณ ๋ฆฌ/์ฝ”๋“œ ํƒ€์ž…์ด๊ฑฐ๋‚˜ Cascading Lookup์ธ ๊ฒฝ์šฐ ๋“œ๋กญ๋‹ค์šด + const colMeta = columnMeta[column.columnName]; + const isCategoryType = colMeta?.inputType === "category" || colMeta?.inputType === "code"; + const hasCategoryOptions = categoryMappings[column.columnName] && Object.keys(categoryMappings[column.columnName]).length > 0; + + if (cascadingConfig || (isCategoryType && hasCategoryOptions)) { + const selectOptions = cascadingConfig + ? options + : Object.entries(categoryMappings[column.columnName] || {}).map(([value, info]) => ({ + value, + label: info.label, + })); + + return ( + + ); + } + + // ์ผ๋ฐ˜ ์ž…๋ ฅ ํ•„๋“œ + return ( + setEditingValue(e.target.value)} + onKeyDown={handleEditKeyDown} + onBlur={saveEditing} + className="w-full h-full px-2 py-1 sm:px-4 sm:py-1.5 text-xs sm:text-sm border-2 border-primary bg-background focus:outline-none" + style={{ + textAlign: isNumeric ? "right" : column.align || "left", + }} + /> + ); + })() + ) : column.columnName === "__checkbox__" ? ( + renderCheckboxCell(row, index) + ) : ( + formatCellValue(cellValue, column, row) + )} +
+
+ {summary ? ( +
+ {summary.label} + + {typeof summary.value === "number" + ? summary.value.toLocaleString("ko-KR", { + maximumFractionDigits: 2, + }) + : summary.value} + +
+ ) : colIndex === 0 ? ( + ์š”์•ฝ + ) : null} +
@@ -3079,6 +5715,284 @@ export const TableListComponent: React.FC = ({ + {/* ๐Ÿ†• Context Menu (์šฐํด๋ฆญ ๋ฉ”๋‰ด) */} + {contextMenu && ( +
e.stopPropagation()} + > +
+ {/* ์…€ ๋ณต์‚ฌ */} + + + {/* ํ–‰ ๋ณต์‚ฌ */} + + +
+ + {/* ์…€ ํŽธ์ง‘ */} + + + {/* ํ–‰ ์„ ํƒ/ํ•ด์ œ */} + + +
+ + {/* ํ–‰ ์‚ญ์ œ */} + +
+
+ )} + + {/* ๐Ÿ†• Filter Builder ๋ชจ๋‹ฌ */} + + + + ๊ณ ๊ธ‰ ํ•„ํ„ฐ + + ์—ฌ๋Ÿฌ ์กฐ๊ฑด์„ ์กฐํ•ฉํ•˜์—ฌ ๋ฐ์ดํ„ฐ๋ฅผ ํ•„ํ„ฐ๋งํ•ฉ๋‹ˆ๋‹ค. + + + +
+ {filterGroups.length === 0 ? ( +
+ ํ•„ํ„ฐ ์กฐ๊ฑด์ด ์—†์Šต๋‹ˆ๋‹ค. ์•„๋ž˜ ๋ฒ„ํŠผ์„ ํด๋ฆญํ•˜์—ฌ ์กฐ๊ฑด์„ ์ถ”๊ฐ€ํ•˜์„ธ์š”. +
+ ) : ( + filterGroups.map((group, groupIndex) => ( +
+
+
+ ์กฐ๊ฑด ๊ทธ๋ฃน {groupIndex + 1} + +
+ +
+ +
+ {group.conditions.map((condition) => ( +
+ {/* ์ปฌ๋Ÿผ ์„ ํƒ */} + + + {/* ์—ฐ์‚ฐ์ž ์„ ํƒ */} + + + {/* ๊ฐ’ ์ž…๋ ฅ (isEmpty/isNotEmpty๊ฐ€ ์•„๋‹Œ ๊ฒฝ์šฐ๋งŒ) */} + {condition.operator !== "isEmpty" && condition.operator !== "isNotEmpty" && ( + updateFilterCondition(group.id, condition.id, "value", e.target.value)} + placeholder="๊ฐ’ ์ž…๋ ฅ" + className="border-input bg-background h-8 flex-1 rounded border px-2 text-xs" + /> + )} + + {/* ์กฐ๊ฑด ์‚ญ์ œ */} + +
+ ))} +
+ + {/* ์กฐ๊ฑด ์ถ”๊ฐ€ ๋ฒ„ํŠผ */} + +
+ )) + )} + + {/* ๊ทธ๋ฃน ์ถ”๊ฐ€ ๋ฒ„ํŠผ */} + +
+ + + + + + +
+
+ {/* ํ…Œ์ด๋ธ” ์˜ต์…˜ ๋ชจ๋‹ฌ */}