From 417d77729dcea4c22dbcfd2f963b7253cae65031 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Fri, 5 Dec 2025 16:44:58 +0900 Subject: [PATCH 1/4] =?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" + /> + )} + + {/* ์กฐ๊ฑด ์‚ญ์ œ */} + +
+ ))} +
+ + {/* ์กฐ๊ฑด ์ถ”๊ฐ€ ๋ฒ„ํŠผ */} + +
+ )) + )} + + {/* ๊ทธ๋ฃน ์ถ”๊ฐ€ ๋ฒ„ํŠผ */} + +
+ + + + + + +
+
+ {/* ํ…Œ์ด๋ธ” ์˜ต์…˜ ๋ชจ๋‹ฌ */} Date: Fri, 5 Dec 2025 17:42:35 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=EC=9E=90=EB=AC=BC=EC=87=A0=20=EB=88=84?= =?UTF-8?q?=EB=A5=B4=EB=A9=B4=20=EC=BB=AC=EB=9F=BC=20=EA=B0=92=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EC=95=88=EB=90=A9=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../table-list/TableListComponent.tsx | 86 ++++++++++++++----- .../table-list/TableListConfigPanel.tsx | 25 +++++- .../registry/components/table-list/types.ts | 1 + 3 files changed, 88 insertions(+), 24 deletions(-) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index ca2125e2..e326ece6 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -31,6 +31,7 @@ import { Edit, CheckSquare, Trash2, + Lock, } from "lucide-react"; import * as XLSX from "xlsx"; import { FileText, ChevronRightIcon } from "lucide-react"; @@ -391,11 +392,13 @@ export const TableListComponent: React.FC = ({ checkboxCol = { columnName: "__checkbox__", displayName: "", - webType: "checkbox", visible: true, sortable: false, - filterable: false, + searchable: false, width: 40, + align: "center" as const, + order: -1, + editable: false, // ์ฒดํฌ๋ฐ•์Šค๋Š” ํŽธ์ง‘ ๋ถˆ๊ฐ€ }; } @@ -1933,10 +1936,17 @@ export const TableListComponent: React.FC = ({ // ์ฒดํฌ๋ฐ•์Šค ์ปฌ๋Ÿผ์€ ํŽธ์ง‘ ๋ถˆ๊ฐ€ if (columnName === "__checkbox__") return; + // ๐Ÿ†• ํŽธ์ง‘ ๋ถˆ๊ฐ€ ์ปฌ๋Ÿผ ์ฒดํฌ + const column = visibleColumns.find((col) => col.columnName === columnName); + if (column?.editable === false) { + toast.warning(`'${column.displayName || columnName}' ์ปฌ๋Ÿผ์€ ํŽธ์ง‘ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.`); + return; + } + setEditingCell({ rowIndex, colIndex, columnName, originalValue: value }); setEditingValue(value !== null && value !== undefined ? String(value) : ""); setFocusedCell({ rowIndex, colIndex }); - }, []); + }, [visibleColumns]); // ๐Ÿ†• ํŽธ์ง‘ ๋ชจ๋“œ ์ง„์ž… placeholder (์‹ค์ œ ๊ตฌํ˜„์€ visibleColumns ์ •์˜ ํ›„) const startEditingRef = useRef<() => void>(() => {}); @@ -3552,6 +3562,11 @@ export const TableListComponent: React.FC = ({ { const col = visibleColumns[colIndex]; if (col && col.columnName !== "__checkbox__") { + // ๐Ÿ†• ํŽธ์ง‘ ๋ถˆ๊ฐ€ ์ปฌ๋Ÿผ ์ฒดํฌ + if (col.editable === false) { + toast.warning(`'${col.displayName || col.columnName}' ์ปฌ๋Ÿผ์€ ํŽธ์ง‘ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.`); + break; + } const row = data[rowIndex]; const mappedCol = joinColumnMapping[col.columnName] || col.columnName; const val = row?.[mappedCol]; @@ -3696,6 +3711,11 @@ export const TableListComponent: React.FC = ({ if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) { const column = visibleColumns[colIndex]; if (column && column.columnName !== "__checkbox__") { + // ๐Ÿ†• ํŽธ์ง‘ ๋ถˆ๊ฐ€ ์ปฌ๋Ÿผ ์ฒดํฌ + if (column.editable === false) { + toast.warning(`'${column.displayName || column.columnName}' ์ปฌ๋Ÿผ์€ ํŽธ์ง‘ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.`); + break; + } e.preventDefault(); // ํŽธ์ง‘ ์‹œ์ž‘ (ํ˜„์žฌ ํ‚ค๋ฅผ ์ดˆ๊ธฐ๊ฐ’์œผ๋กœ) const row = data[rowIndex]; @@ -5039,6 +5059,12 @@ export const TableListComponent: React.FC = ({ renderCheckboxHeader() ) : (
+ {/* ๐Ÿ†• ํŽธ์ง‘ ๋ถˆ๊ฐ€ ์ปฌ๋Ÿผ ํ‘œ์‹œ */} + {column.editable === false && ( + + + + )} {columnLabels[column.columnName] || column.displayName} {column.sortable !== false && sortColumn === column.columnName && ( {sortDirection === "asc" ? "โ†‘" : "โ†“"} @@ -5458,6 +5484,8 @@ export const TableListComponent: React.FC = ({ cellValidationError && "bg-red-50 dark:bg-red-950/40 ring-2 ring-red-500 ring-inset", // ๐Ÿ†• ๊ฒ€์ƒ‰ ํ•˜์ด๋ผ์ดํŠธ ์Šคํƒ€์ผ (๋…ธ๋ž€ ๋ฐฐ๊ฒฝ) isSearchHighlighted && !isCellFocused && "bg-yellow-200 dark:bg-yellow-700/50", + // ๐Ÿ†• ํŽธ์ง‘ ๋ถˆ๊ฐ€ ์ปฌ๋Ÿผ ์Šคํƒ€์ผ (์—ฐํ•œ ํšŒ์ƒ‰ ๋ฐฐ๊ฒฝ) + column.editable === false && "bg-gray-50 dark:bg-gray-900/30", )} // ๐Ÿ†• ์œ ํšจ์„ฑ ์—๋Ÿฌ ํˆดํŒ title={cellValidationError || undefined} @@ -5762,25 +5790,39 @@ export const TableListComponent: React.FC = ({
{/* ์…€ ํŽธ์ง‘ */} - + {(() => { + const col = visibleColumns[contextMenu.colIndex]; + const isEditable = col?.editable !== false && col?.columnName !== "__checkbox__"; + return ( + + ); + })()} {/* ํ–‰ ์„ ํƒ/ํ•ด์ œ */}
- {/* ํ•„ํ„ฐ ์ฒดํฌ๋ฐ•์Šค + ์ˆœ์„œ ๋ณ€๊ฒฝ + ์‚ญ์ œ ๋ฒ„ํŠผ */} + {/* ํŽธ์ง‘ ๊ฐ€๋Šฅ ์—ฌ๋ถ€ + ํ•„ํ„ฐ ์ฒดํฌ๋ฐ•์Šค */}
+ {/* ๐Ÿ†• ํŽธ์ง‘ ๊ฐ€๋Šฅ ์—ฌ๋ถ€ ํ† ๊ธ€ */} + + + {/* ํ•„ํ„ฐ ์ฒดํฌ๋ฐ•์Šค */} f.columnName === column.columnName) || false} onCheckedChange={(checked) => { @@ -1173,6 +1193,7 @@ export const TableListConfigPanel: React.FC = ({ } }} className="h-3 w-3" + title="ํ•„ํ„ฐ์— ์ถ”๊ฐ€" />
diff --git a/frontend/lib/registry/components/table-list/types.ts b/frontend/lib/registry/components/table-list/types.ts index 2475f58f..a619baa0 100644 --- a/frontend/lib/registry/components/table-list/types.ts +++ b/frontend/lib/registry/components/table-list/types.ts @@ -77,6 +77,7 @@ export interface ColumnConfig { // ์ƒˆ๋กœ์šด ๊ธฐ๋Šฅ๋“ค hidden?: boolean; // ์ˆจ๊น€ ๊ธฐ๋Šฅ (ํŽธ์ง‘๊ธฐ์—์„œ๋Š” ์—ฐํ•˜๊ฒŒ, ์‹ค์ œ ํ™”๋ฉด์—์„œ๋Š” ์ˆจ๊น€) autoGeneration?: AutoGenerationConfig; // ์ž๋™์ƒ์„ฑ ์„ค์ • + editable?: boolean; // ๐Ÿ†• ํŽธ์ง‘ ๊ฐ€๋Šฅ ์—ฌ๋ถ€ (๊ธฐ๋ณธ๊ฐ’: true, false๋ฉด ์ธ๋ผ์ธ ํŽธ์ง‘ ๋ถˆ๊ฐ€) // ๐ŸŽฏ ์ถ”๊ฐ€ ์กฐ์ธ ์ปฌ๋Ÿผ ์ •๋ณด (์กฐ์ธ ํƒญ์—์„œ ์ถ”๊ฐ€ํ•œ ์ปฌ๋Ÿผ๋“ค) additionalJoinInfo?: { From 46ef858c1d7386797c541acda9fa3bc90c45cad3 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Fri, 5 Dec 2025 18:29:32 +0900 Subject: [PATCH 3/4] =?UTF-8?q?=EC=A7=80=EB=8F=84=20=EC=9C=84=EC=A0=AF=20R?= =?UTF-8?q?EST=20API=20Request=20Body=20=EC=A0=84=EB=8B=AC=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/DashboardController.ts | 24 ++-- .../dashboard/widgets/MapTestWidgetV2.tsx | 130 ++++++++++-------- 2 files changed, 85 insertions(+), 69 deletions(-) diff --git a/backend-node/src/controllers/DashboardController.ts b/backend-node/src/controllers/DashboardController.ts index e324c332..d0b22db4 100644 --- a/backend-node/src/controllers/DashboardController.ts +++ b/backend-node/src/controllers/DashboardController.ts @@ -709,9 +709,9 @@ export class DashboardController { } // ๊ธฐ์ƒ์ฒญ API ๋“ฑ EUC-KR ์ธ์ฝ”๋”ฉ์„ ์‚ฌ์šฉํ•˜๋Š” ๊ฒฝ์šฐ arraybuffer๋กœ ๋ฐ›์•„์„œ ๋””์ฝ”๋”ฉ - const isKmaApi = urlObj.hostname.includes('kma.go.kr'); + const isKmaApi = urlObj.hostname.includes("kma.go.kr"); if (isKmaApi) { - requestConfig.responseType = 'arraybuffer'; + requestConfig.responseType = "arraybuffer"; } const response = await axios(requestConfig); @@ -727,18 +727,22 @@ export class DashboardController { // ๊ธฐ์ƒ์ฒญ API ์ธ์ฝ”๋”ฉ ์ฒ˜๋ฆฌ (UTF-8 ์šฐ์„ , ์‹คํŒจ ์‹œ EUC-KR) if (isKmaApi && Buffer.isBuffer(data)) { - const iconv = require('iconv-lite'); + const iconv = require("iconv-lite"); const buffer = Buffer.from(data); - const utf8Text = buffer.toString('utf-8'); - + const utf8Text = buffer.toString("utf-8"); + // UTF-8๋กœ ์ •์ƒ ๋””์ฝ”๋”ฉ๋˜์—ˆ๋Š”์ง€ ํ™•์ธ - if (utf8Text.includes('ํŠน๋ณด') || utf8Text.includes('๊ฒฝ๋ณด') || utf8Text.includes('์ฃผ์˜๋ณด') || - (utf8Text.includes('#START7777') && !utf8Text.includes('๏ฟฝ'))) { - data = { text: utf8Text, contentType, encoding: 'utf-8' }; + if ( + utf8Text.includes("ํŠน๋ณด") || + utf8Text.includes("๊ฒฝ๋ณด") || + utf8Text.includes("์ฃผ์˜๋ณด") || + (utf8Text.includes("#START7777") && !utf8Text.includes("๏ฟฝ")) + ) { + data = { text: utf8Text, contentType, encoding: "utf-8" }; } else { // EUC-KR๋กœ ๋””์ฝ”๋”ฉ - const eucKrText = iconv.decode(buffer, 'EUC-KR'); - data = { text: eucKrText, contentType, encoding: 'euc-kr' }; + const eucKrText = iconv.decode(buffer, "EUC-KR"); + data = { text: eucKrText, contentType, encoding: "euc-kr" }; } } // ํ…์ŠคํŠธ ์‘๋‹ต์ธ ๊ฒฝ์šฐ ํฌ๋งทํŒ… diff --git a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx index 02cafe2b..94c3a217 100644 --- a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx +++ b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx @@ -94,12 +94,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const [error, setError] = useState(null); const [geoJsonData, setGeoJsonData] = useState(null); const [lastRefreshTime, setLastRefreshTime] = useState(null); - + // ์ด๋™๊ฒฝ๋กœ ์ƒํƒœ const [routePoints, setRoutePoints] = useState([]); const [selectedUserId, setSelectedUserId] = useState(null); const [routeLoading, setRouteLoading] = useState(false); - const [routeDate, setRouteDate] = useState(new Date().toISOString().split('T')[0]); // YYYY-MM-DD ํ˜•์‹ + const [routeDate, setRouteDate] = useState(new Date().toISOString().split("T")[0]); // YYYY-MM-DD ํ˜•์‹ // dataSources๋ฅผ useMemo๋กœ ์ถ”์ถœ (circular reference ๋ฐฉ์ง€) const dataSources = useMemo(() => { @@ -122,62 +122,59 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { }, []); // ์ด๋™๊ฒฝ๋กœ ๋กœ๋“œ ํ•จ์ˆ˜ - const loadRoute = useCallback(async (userId: string, date?: string) => { - if (!userId) { - console.log("๐Ÿ›ฃ๏ธ ์ด๋™๊ฒฝ๋กœ ์กฐํšŒ ๋ถˆ๊ฐ€: userId ์—†์Œ"); - return; - } + const loadRoute = useCallback( + async (userId: string, date?: string) => { + if (!userId) { + return; + } - setRouteLoading(true); - setSelectedUserId(userId); + setRouteLoading(true); + setSelectedUserId(userId); - try { - // ์„ ํƒํ•œ ๋‚ ์งœ ๊ธฐ์ค€์œผ๋กœ ์ด๋™๊ฒฝ๋กœ ์กฐํšŒ - const targetDate = date || routeDate; - const startOfDay = `${targetDate}T00:00:00.000Z`; - const endOfDay = `${targetDate}T23:59:59.999Z`; - - const query = `SELECT latitude, longitude, recorded_at + try { + // ์„ ํƒํ•œ ๋‚ ์งœ ๊ธฐ์ค€์œผ๋กœ ์ด๋™๊ฒฝ๋กœ ์กฐํšŒ + const targetDate = date || routeDate; + const startOfDay = `${targetDate}T00:00:00.000Z`; + const endOfDay = `${targetDate}T23:59:59.999Z`; + + const query = `SELECT latitude, longitude, recorded_at FROM vehicle_location_history WHERE user_id = '${userId}' AND recorded_at >= '${startOfDay}' AND recorded_at <= '${endOfDay}' ORDER BY recorded_at ASC`; - console.log("๐Ÿ›ฃ๏ธ ์ด๋™๊ฒฝ๋กœ ์ฟผ๋ฆฌ:", query); + const response = await fetch(getApiUrl("/api/dashboards/execute-query"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`, + }, + body: JSON.stringify({ query }), + }); - const response = await fetch(getApiUrl("/api/dashboards/execute-query"), { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`, - }, - body: JSON.stringify({ query }), - }); + if (response.ok) { + const result = await response.json(); + if (result.success && result.data.rows.length > 0) { + const points: RoutePoint[] = result.data.rows.map((row: any) => ({ + lat: parseFloat(row.latitude), + lng: parseFloat(row.longitude), + recordedAt: row.recorded_at, + })); - if (response.ok) { - const result = await response.json(); - if (result.success && result.data.rows.length > 0) { - const points: RoutePoint[] = result.data.rows.map((row: any) => ({ - lat: parseFloat(row.latitude), - lng: parseFloat(row.longitude), - recordedAt: row.recorded_at, - })); - - console.log(`๐Ÿ›ฃ๏ธ ์ด๋™๊ฒฝ๋กœ ${points.length}๊ฐœ ํฌ์ธํŠธ ๋กœ๋“œ ์™„๋ฃŒ`); - setRoutePoints(points); - } else { - console.log("๐Ÿ›ฃ๏ธ ์ด๋™๊ฒฝ๋กœ ๋ฐ์ดํ„ฐ ์—†์Œ"); - setRoutePoints([]); + setRoutePoints(points); + } else { + setRoutePoints([]); + } } + } catch { + setRoutePoints([]); } - } catch (error) { - console.error("์ด๋™๊ฒฝ๋กœ ๋กœ๋“œ ์‹คํŒจ:", error); - setRoutePoints([]); - } - setRouteLoading(false); - }, [routeDate]); + setRouteLoading(false); + }, + [routeDate], + ); // ์ด๋™๊ฒฝ๋กœ ์ˆจ๊ธฐ๊ธฐ const clearRoute = useCallback(() => { @@ -297,6 +294,17 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { }); } + // Request Body ํŒŒ์‹ฑ + let requestBody: any = undefined; + if (source.body) { + try { + requestBody = JSON.parse(source.body); + } catch { + // JSON ํŒŒ์‹ฑ ์‹คํŒจ์‹œ ๋ฌธ์ž์—ด ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ + requestBody = source.body; + } + } + // ๋ฐฑ์—”๋“œ ํ”„๋ก์‹œ๋ฅผ ํ†ตํ•ด API ํ˜ธ์ถœ const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), { method: "POST", @@ -309,6 +317,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { method: source.method || "GET", headers, queryParams, + body: requestBody, + externalConnectionId: source.externalConnectionId, }), }); @@ -344,14 +354,18 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { } } + // ๋ฐ์ดํ„ฐ๊ฐ€ null/undefined๋ฉด ๋นˆ ๊ฒฐ๊ณผ ๋ฐ˜ํ™˜ + if (data === null || data === undefined) { + return { markers: [], polygons: [] }; + } + const rows = Array.isArray(data) ? data : [data]; // ์ปฌ๋Ÿผ ๋งคํ•‘ ์ ์šฉ const mappedRows = applyColumnMapping(rows, source.columnMapping); // ๋งˆ์ปค์™€ ํด๋ฆฌ๊ณค์œผ๋กœ ๋ณ€ํ™˜ (mapDisplayType + dataSource ์ „๋‹ฌ) - const finalResult = convertToMapData(mappedRows, source.name || source.id || "API", source.mapDisplayType, source); - return finalResult; + return convertToMapData(mappedRows, source.name || source.id || "API", source.mapDisplayType, source); }; // Database ๋ฐ์ดํ„ฐ ๋กœ๋”ฉ @@ -485,6 +499,11 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const polygons: PolygonData[] = []; rows.forEach((row, index) => { + // null/undefined ์ฒดํฌ + if (!row) { + return; + } + // ํ…์ŠคํŠธ ๋ฐ์ดํ„ฐ ์ฒดํฌ (๊ธฐ์ƒ์ฒญ API ๋“ฑ) if (row && typeof row === "object" && row.text && typeof row.text === "string") { const parsedData = parseTextData(row.text); @@ -1098,13 +1117,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { }} className="h-6 rounded border-none bg-transparent px-1 text-xs text-blue-600 focus:outline-none" /> - - ({routePoints.length}๊ฐœ) - -
@@ -1409,12 +1423,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // ํŠธ๋Ÿญ ๋งˆ์ปค // ํŠธ๋Ÿญ ์•„์ด์ฝ˜์ด ์˜ค๋ฅธ์ชฝ(90๋„)์„ ๋ณด๊ณ  ์žˆ์œผ๋ฏ€๋กœ, ๋ถ์ชฝ(0๋„)์œผ๋กœ ๊ฐ€๋ ค๋ฉด -90๋„ ํšŒ์ „ ํ•„์š” const rotation = heading - 90; - + // ํšŒ์ „ ๊ฐ๋„๊ฐ€ 90~270๋„ ๋ฒ”์œ„๋ฉด ์ฐจ๋Ÿ‰์ด ๋’ค์ง‘์–ด์ง (๋ฐ”ํ€ด๊ฐ€ ์œ„๋กœ) // ์ด ๊ฒฝ์šฐ scaleY(-1)๋กœ ์ƒํ•˜ ๋ฐ˜์ „ํ•˜์—ฌ ๋ฐ”ํ€ด๊ฐ€ ์•„๋ž˜๋กœ ์˜ค๋„๋ก ํ•จ const normalizedRotation = ((rotation % 360) + 360) % 360; const isFlipped = normalizedRotation > 90 && normalizedRotation < 270; - const transformStyle = isFlipped + const transformStyle = isFlipped ? `translate(-50%, -50%) rotate(${rotation}deg) scaleY(-1)` : `translate(-50%, -50%) rotate(${rotation}deg)`; @@ -1654,9 +1668,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { disabled={routeLoading} className="w-full rounded bg-blue-500 px-2 py-1 text-xs text-white hover:bg-blue-600 disabled:opacity-50" > - {routeLoading && selectedUserId === userId - ? "๋กœ๋”ฉ ์ค‘..." - : "๐Ÿ›ฃ๏ธ ์ด๋™๊ฒฝ๋กœ ๋ณด๊ธฐ"} + {routeLoading && selectedUserId === userId ? "๋กœ๋”ฉ ์ค‘..." : "๐Ÿ›ฃ๏ธ ์ด๋™๊ฒฝ๋กœ ๋ณด๊ธฐ"}
); From 8ec5c987de8d877b33c0a8b34d6936037178ccc4 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Mon, 8 Dec 2025 10:23:54 +0900 Subject: [PATCH 4/4] =?UTF-8?q?restapi=20=EB=8F=84=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=EA=B0=80=EB=8A=A5,=20=EC=B6=9C=EB=B0=9C?= =?UTF-8?q?=EC=A7=80=EB=AA=A9=EC=A0=81=EC=A7=80=20=EB=8F=99=EC=8B=9C?= =?UTF-8?q?=EC=97=90=20=EA=B0=99=EC=9D=80=EA=B1=B0=20=EB=AA=BB=ED=95=98?= =?UTF-8?q?=EA=B2=8C,=20=EC=9E=90=EB=AC=BC=EC=87=A0=EA=B1=B8=EB=A9=B4=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=EC=88=98=EC=A0=95=20=EB=AA=BB=ED=95=A8=20?= =?UTF-8?q?tablelistcomponent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/table-list-component-guide.mdc | 310 ++++++++++++++++++ .../src/controllers/DashboardController.ts | 9 + .../src/controllers/dynamicFormController.ts | 9 +- .../externalRestApiConnectionService.ts | 11 +- .../src/types/externalRestApiTypes.ts | 3 + .../admin/RestApiConnectionModal.tsx | 14 + .../dashboard/data-sources/MultiApiConfig.tsx | 18 + frontend/components/admin/dashboard/types.ts | 3 + .../dashboard/widgets/MapTestWidgetV2.tsx | 77 ++++- frontend/lib/api/externalRestApiConnection.ts | 3 + .../LocationSwapSelectorComponent.tsx | 42 ++- 11 files changed, 485 insertions(+), 14 deletions(-) create mode 100644 .cursor/rules/table-list-component-guide.mdc diff --git a/.cursor/rules/table-list-component-guide.mdc b/.cursor/rules/table-list-component-guide.mdc new file mode 100644 index 00000000..5d3f0e1f --- /dev/null +++ b/.cursor/rules/table-list-component-guide.mdc @@ -0,0 +1,310 @@ +# TableListComponent ๊ฐœ๋ฐœ ๊ฐ€์ด๋“œ + +## ๊ฐœ์š” + +`TableListComponent`๋Š” ERP ์‹œ์Šคํ…œ์˜ ํ•ต์‹ฌ ๋ฐ์ดํ„ฐ ๊ทธ๋ฆฌ๋“œ ์ปดํฌ๋„ŒํŠธ์ž…๋‹ˆ๋‹ค. DevExpress DataGrid ์Šคํƒ€์ผ์˜ ๊ณ ๊ธ‰ ๊ธฐ๋Šฅ๋“ค์„ ๊ตฌํ˜„ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. + +**ํŒŒ์ผ ์œ„์น˜**: `frontend/lib/registry/components/table-list/TableListComponent.tsx` + +--- + +## ํ•ต์‹ฌ ๊ธฐ๋Šฅ ๋ชฉ๋ก + +### 1. ์ธ๋ผ์ธ ํŽธ์ง‘ (Inline Editing) + +- ์…€ ๋”๋ธ”ํด๋ฆญ ๋˜๋Š” F2 ํ‚ค๋กœ ํŽธ์ง‘ ๋ชจ๋“œ ์ง„์ž… +- ์ง์ ‘ ํƒ€์ดํ•‘์œผ๋กœ๋„ ํŽธ์ง‘ ๋ชจ๋“œ ์ง„์ž… ๊ฐ€๋Šฅ +- Enter๋กœ ์ €์žฅ, Escape๋กœ ์ทจ์†Œ +- **์ปฌ๋Ÿผ๋ณ„ ํŽธ์ง‘ ๊ฐ€๋Šฅ ์—ฌ๋ถ€ ์„ค์ •** (`editable` ์†์„ฑ) + +```typescript +// ColumnConfig์—์„œ editable ์†์„ฑ ์‚ฌ์šฉ +interface ColumnConfig { + editable?: boolean; // false๋ฉด ํ•ด๋‹น ์ปฌ๋Ÿผ ์ธ๋ผ์ธ ํŽธ์ง‘ ๋ถˆ๊ฐ€ +} +``` + +**ํŽธ์ง‘ ๋ถˆ๊ฐ€ ์ปฌ๋Ÿผ ์ฒดํฌ ํ•„์ˆ˜ ์œ„์น˜**: +1. `handleCellDoubleClick` - ๋”๋ธ”ํด๋ฆญ ํŽธ์ง‘ +2. `onKeyDown` F2 ์ผ€์ด์Šค - ํ‚ค๋ณด๋“œ ํŽธ์ง‘ +3. `onKeyDown` default ์ผ€์ด์Šค - ์ง์ ‘ ํƒ€์ดํ•‘ ํŽธ์ง‘ +4. ์ปจํ…์ŠคํŠธ ๋ฉ”๋‰ด "์…€ ํŽธ์ง‘" ์˜ต์…˜ + +### 2. ๋ฐฐ์น˜ ํŽธ์ง‘ (Batch Editing) + +- ์—ฌ๋Ÿฌ ์…€ ์ˆ˜์ • ํ›„ ์ผ๊ด„ ์ €์žฅ/์ทจ์†Œ +- `pendingChanges` Map์œผ๋กœ ๋ณ€๊ฒฝ์‚ฌํ•ญ ์ถ”์  +- ์ €์žฅ ์ „ ์œ ํšจ์„ฑ ๊ฒ€์ฆ + +### 3. ๋ฐ์ดํ„ฐ ์œ ํšจ์„ฑ ๊ฒ€์ฆ (Validation) + +```typescript +type ValidationRule = { + required?: boolean; + min?: number; + max?: number; + minLength?: number; + maxLength?: number; + pattern?: RegExp; + customMessage?: string; + validate?: (value: any, row: any) => string | null; +}; +``` + +### 4. ์ปฌ๋Ÿผ ํ—ค๋” ํ•„ํ„ฐ (Header Filter) + +- ๊ฐ ์ปฌ๋Ÿผ ํ—ค๋”์— ํ•„ํ„ฐ ์•„์ด์ฝ˜ +- ๊ณ ์œ ๊ฐ’ ๋ชฉ๋ก์—์„œ ๋‹ค์ค‘ ์„ ํƒ ํ•„ํ„ฐ๋ง +- `headerFilters` Map์œผ๋กœ ํ•„ํ„ฐ ์ƒํƒœ ๊ด€๋ฆฌ + +### 5. ํ•„ํ„ฐ ๋นŒ๋” (Filter Builder) + +```typescript +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[]; +} +``` + +### 6. ๊ฒ€์ƒ‰ ํŒจ๋„ (Search Panel) + +- ์ „์ฒด ๋ฐ์ดํ„ฐ ๊ฒ€์ƒ‰ +- ๊ฒ€์ƒ‰์–ด ํ•˜์ด๋ผ์ดํŒ… +- `searchHighlights` Map์œผ๋กœ ํ•˜์ด๋ผ์ดํŠธ ์œ„์น˜ ๊ด€๋ฆฌ + +### 7. ์—‘์…€ ๋‚ด๋ณด๋‚ด๊ธฐ (Excel Export) + +- `xlsx` ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์‚ฌ์šฉ +- ํ˜„์žฌ ํ‘œ์‹œ ๋ฐ์ดํ„ฐ ๋˜๋Š” ์ „์ฒด ๋ฐ์ดํ„ฐ ๋‚ด๋ณด๋‚ด๊ธฐ + +```typescript +import * as XLSX from "xlsx"; + +// ์‚ฌ์šฉ ์˜ˆ์‹œ +const worksheet = XLSX.utils.json_to_sheet(exportData); +const workbook = XLSX.utils.book_new(); +XLSX.utils.book_append_sheet(workbook, worksheet, "Sheet1"); +XLSX.writeFile(workbook, `${tableName}_${timestamp}.xlsx`); +``` + +### 8. ํด๋ฆฝ๋ณด๋“œ ๋ณต์‚ฌ (Copy to Clipboard) + +- ์„ ํƒ๋œ ํ–‰ ๋˜๋Š” ์ „์ฒด ๋ฐ์ดํ„ฐ ๋ณต์‚ฌ +- ํƒญ ๊ตฌ๋ถ„์ž๋กœ ์—‘์…€ ๋ถ™์—ฌ๋„ฃ๊ธฐ ํ˜ธํ™˜ + +### 9. ์ปจํ…์ŠคํŠธ ๋ฉ”๋‰ด (Context Menu) + +- ์šฐํด๋ฆญ์œผ๋กœ ๋ฉ”๋‰ด ํ‘œ์‹œ +- ์…€ ํŽธ์ง‘, ํ–‰ ๋ณต์‚ฌ, ํ–‰ ์‚ญ์ œ ๋“ฑ ์˜ต์…˜ +- ํŽธ์ง‘ ๋ถˆ๊ฐ€ ์ปฌ๋Ÿผ์€ "(์ž ๊น€)" ํ‘œ์‹œ + +### 10. ํ‚ค๋ณด๋“œ ๋„ค๋น„๊ฒŒ์ด์…˜ + +| ํ‚ค | ๋™์ž‘ | +|---|---| +| Arrow Keys | ์…€ ์ด๋™ | +| Tab | ๋‹ค์Œ ์…€ | +| Shift+Tab | ์ด์ „ ์…€ | +| F2 | ํŽธ์ง‘ ๋ชจ๋“œ | +| Enter | ์ €์žฅ ํ›„ ์•„๋ž˜๋กœ ์ด๋™ | +| Escape | ํŽธ์ง‘ ์ทจ์†Œ | +| Ctrl+C | ๋ณต์‚ฌ | +| Delete | ์…€ ๊ฐ’ ์‚ญ์ œ | + +### 11. ์ปฌ๋Ÿผ ๋ฆฌ์‚ฌ์ด์ง• + +- ์ปฌ๋Ÿผ ํ—ค๋” ๊ฒฝ๊ณ„ ๋“œ๋ž˜๊ทธ๋กœ ๋„ˆ๋น„ ์กฐ์ ˆ +- `columnWidths` ์ƒํƒœ๋กœ ๊ด€๋ฆฌ +- localStorage์— ์ €์žฅ + +### 12. ์ปฌ๋Ÿผ ์ˆœ์„œ ๋ณ€๊ฒฝ + +- ๋“œ๋ž˜๊ทธ ์•ค ๋“œ๋กญ์œผ๋กœ ์ปฌ๋Ÿผ ์ˆœ์„œ ๋ณ€๊ฒฝ +- `columnOrder` ์ƒํƒœ๋กœ ๊ด€๋ฆฌ +- localStorage์— ์ €์žฅ + +### 13. ์ƒํƒœ ์˜์†์„ฑ (State Persistence) + +```typescript +// localStorage ํ‚ค ํŒจํ„ด +const stateKey = `tableState_${tableName}_${userId}`; + +// ์ €์žฅ๋˜๋Š” ์ƒํƒœ +interface TableState { + columnWidths: Record; + columnOrder: string[]; + sortBy: string; + sortOrder: "asc" | "desc"; + frozenColumns: string[]; + columnVisibility: Record; +} +``` + +### 14. ๊ทธ๋ฃนํ™” ๋ฐ ๊ทธ๋ฃน ์†Œ๊ณ„ + +```typescript +interface GroupedData { + groupKey: string; + groupValues: Record; + items: any[]; + count: number; + summary?: Record; +} +``` + +### 15. ์ด๊ณ„ ์š”์•ฝ (Total Summary) + +- ์ˆซ์ž ์ปฌ๋Ÿผ์˜ ํ•ฉ๊ณ„, ํ‰๊ท , ๊ฐœ์ˆ˜ ํ‘œ์‹œ +- ํ…Œ์ด๋ธ” ํ•˜๋‹จ์— ์š”์•ฝ ํ–‰ ๋ Œ๋”๋ง + +--- + +## ์บ์‹ฑ ์ „๋žต + +```typescript +// ํ…Œ์ด๋ธ” ์ปฌ๋Ÿผ ์บ์‹œ +const tableColumnCache = new Map(); +const TABLE_CACHE_TTL = 5 * 60 * 1000; // 5๋ถ„ + +// API ํ˜ธ์ถœ ๋””๋ฐ”์šด์‹ฑ +const debouncedApiCall = ( + key: string, + fn: (...args: T) => Promise, + delay: number = 300 +) => { ... }; +``` + +--- + +## ํ•„์ˆ˜ Import + +```typescript +import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"; +import { TableListConfig, ColumnConfig } from "./types"; +import { tableTypeApi } from "@/lib/api/screen"; +import { entityJoinApi } from "@/lib/api/entityJoin"; +import { codeCache } from "@/lib/caching/codeCache"; +import * as XLSX from "xlsx"; +import { toast } from "sonner"; +``` + +--- + +## ์ฃผ์š” ์ƒํƒœ (State) + +```typescript +// ๋ฐ์ดํ„ฐ ๊ด€๋ จ +const [tableData, setTableData] = useState([]); +const [filteredData, setFilteredData] = useState([]); +const [loading, setLoading] = useState(false); + +// ํŽธ์ง‘ ๊ด€๋ จ +const [editingCell, setEditingCell] = useState<{ + rowIndex: number; + colIndex: number; + columnName: string; + originalValue: any; +} | null>(null); +const [editingValue, setEditingValue] = useState(""); +const [pendingChanges, setPendingChanges] = useState>>(new Map()); +const [validationErrors, setValidationErrors] = useState>>(new Map()); + +// ํ•„ํ„ฐ ๊ด€๋ จ +const [headerFilters, setHeaderFilters] = useState>>(new Map()); +const [filterGroups, setFilterGroups] = useState([]); +const [globalSearchText, setGlobalSearchText] = useState(""); +const [searchHighlights, setSearchHighlights] = useState>(new Map()); + +// ์ปฌ๋Ÿผ ๊ด€๋ จ +const [columnWidths, setColumnWidths] = useState>({}); +const [columnOrder, setColumnOrder] = useState([]); +const [columnVisibility, setColumnVisibility] = useState>({}); +const [frozenColumns, setFrozenColumns] = useState([]); + +// ์„ ํƒ ๊ด€๋ จ +const [selectedRows, setSelectedRows] = useState>(new Set()); +const [focusedCell, setFocusedCell] = useState<{ rowIndex: number; colIndex: number } | null>(null); + +// ์ •๋ ฌ ๊ด€๋ จ +const [sortBy, setSortBy] = useState(""); +const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); + +// ํŽ˜์ด์ง€๋„ค์ด์…˜ +const [currentPage, setCurrentPage] = useState(1); +const [pageSize, setPageSize] = useState(20); +const [totalCount, setTotalCount] = useState(0); +``` + +--- + +## ํŽธ์ง‘ ๋ถˆ๊ฐ€ ์ปฌ๋Ÿผ ๊ตฌํ˜„ ์ฒดํฌ๋ฆฌ์ŠคํŠธ + +์ƒˆ๋กœ์šด ํŽธ์ง‘ ์ง„์ž…์ ์„ ์ถ”๊ฐ€ํ•  ๋•Œ ๋ฐ˜๋“œ์‹œ ๋‹ค์Œ์„ ํ™•์ธํ•˜์„ธ์š”: + +- [ ] `column.editable === false` ์ฒดํฌ ์ถ”๊ฐ€ +- [ ] ํŽธ์ง‘ ๋ถˆ๊ฐ€ ์‹œ `toast.warning()` ๋ฉ”์‹œ์ง€ ํ‘œ์‹œ +- [ ] `return` ๋˜๋Š” `break`๋กœ ํŽธ์ง‘ ๋ชจ๋“œ ์ง„์ž… ๋ฐฉ์ง€ + +```typescript +// ํ‘œ์ค€ ํŽธ์ง‘ ๋ถˆ๊ฐ€ ์ฒดํฌ ํŒจํ„ด +const column = visibleColumns.find((col) => col.columnName === columnName); +if (column?.editable === false) { + toast.warning(`'${column.displayName || columnName}' ์ปฌ๋Ÿผ์€ ํŽธ์ง‘ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.`); + return; +} +``` + +--- + +## ์‹œ๊ฐ์  ํ‘œ์‹œ + +### ํŽธ์ง‘ ๋ถˆ๊ฐ€ ์ปฌ๋Ÿผ ํ‘œ์‹œ + +```tsx +// ํ—ค๋”์— ์ž ๊ธˆ ์•„์ด์ฝ˜ +{column.editable === false && ( + +)} + +// ์…€ ๋ฐฐ๊ฒฝ์ƒ‰ +className={cn( + column.editable === false && "bg-gray-50 dark:bg-gray-900/30" +)} +``` + +--- + +## ์„ฑ๋Šฅ ์ตœ์ ํ™” + +1. **useMemo ์‚ฌ์šฉ**: `visibleColumns`, `filteredData`, `paginatedData` ๋“ฑ ๊ณ„์‚ฐ ๋น„์šฉ์ด ํฐ ๊ฐ’ +2. **useCallback ์‚ฌ์šฉ**: ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ ํ•จ์ˆ˜๋“ค +3. **๋””๋ฐ”์šด์‹ฑ**: API ํ˜ธ์ถœ, ๊ฒ€์ƒ‰, ํ•„ํ„ฐ๋ง +4. **์บ์‹ฑ**: ํ…Œ์ด๋ธ” ์ปฌ๋Ÿผ ์ •๋ณด, ์ฝ”๋“œ ๋ฐ์ดํ„ฐ + +--- + +## ์ฃผ์˜์‚ฌํ•ญ + +1. **visibleColumns ์ •์˜ ์ˆœ์„œ**: `columnOrder`, `columnVisibility` ์ƒํƒœ ์ดํ›„์— ์ •์˜ํ•ด์•ผ ํ•จ +2. **editInputRef ํƒ€์ž… ์ฒดํฌ**: `select()` ํ˜ธ์ถœ ์ „ `instanceof HTMLInputElement` ํ™•์ธ +3. **localStorage ํ‚ค**: `tableName`๊ณผ `userId`๋ฅผ ์กฐํ•ฉํ•˜์—ฌ ๊ณ ์œ ํ•˜๊ฒŒ ์ƒ์„ฑ +4. **๋ฉ€ํ‹ฐํ…Œ๋„Œ์‹œ**: ๋ชจ๋“  API ํ˜ธ์ถœ์— `company_code` ํ•„ํ„ฐ๋ง ์ ์šฉ (๋ฐฑ์—”๋“œ์—์„œ ์ž๋™ ์ฒ˜๋ฆฌ) + +--- + +## ๊ด€๋ จ ํŒŒ์ผ + +- `frontend/lib/registry/components/table-list/types.ts` - ํƒ€์ž… ์ •์˜ +- `frontend/lib/registry/components/table-list/TableListConfigPanel.tsx` - ์„ค์ • ํŒจ๋„ +- `frontend/components/common/TableOptionsModal.tsx` - ์˜ต์…˜ ๋ชจ๋‹ฌ +- `frontend/lib/registry/components/table-list/SingleTableWithSticky.tsx` - ์Šคํ‹ฐํ‚ค ํ—ค๋” ํ…Œ์ด๋ธ” diff --git a/backend-node/src/controllers/DashboardController.ts b/backend-node/src/controllers/DashboardController.ts index d0b22db4..a03478b9 100644 --- a/backend-node/src/controllers/DashboardController.ts +++ b/backend-node/src/controllers/DashboardController.ts @@ -632,6 +632,9 @@ export class DashboardController { validateStatus: () => true, // ๋ชจ๋“  ์ƒํƒœ ์ฝ”๋“œ ํ—ˆ์šฉ (์—๋Ÿฌ๋„ ์‘๋‹ต์œผ๋กœ ์ฒ˜๋ฆฌ) }; + // ์—ฐ๊ฒฐ ์ •๋ณด (์‘๋‹ต์— ํฌํ•จ์šฉ) + let connectionInfo: { saveToHistory?: boolean } | null = null; + // ์™ธ๋ถ€ ์ปค๋„ฅ์…˜ ID๊ฐ€ ์žˆ๋Š” ๊ฒฝ์šฐ, ํ•ด๋‹น ์ปค๋„ฅ์…˜์˜ ์ธ์ฆ ์ •๋ณด(DB ํ† ํฐ ๋“ฑ)๋ฅผ ์ ์šฉ if (externalConnectionId) { try { @@ -652,6 +655,11 @@ export class DashboardController { if (connectionResult.success && connectionResult.data) { const connection = connectionResult.data; + // ์—ฐ๊ฒฐ ์ •๋ณด ์ €์žฅ (์‘๋‹ต์— ํฌํ•จ) + connectionInfo = { + saveToHistory: connection.save_to_history === "Y", + }; + // ์ธ์ฆ ํ—ค๋” ์ƒ์„ฑ (DB ํ† ํฐ ๋“ฑ) const authHeaders = await ExternalRestApiConnectionService.getAuthHeaders( @@ -753,6 +761,7 @@ export class DashboardController { res.status(200).json({ success: true, data, + connectionInfo, // ์™ธ๋ถ€ ์—ฐ๊ฒฐ ์ •๋ณด (saveToHistory ๋“ฑ) }); } catch (error: any) { const status = error.response?.status || 500; diff --git a/backend-node/src/controllers/dynamicFormController.ts b/backend-node/src/controllers/dynamicFormController.ts index 30364189..97cd2cc1 100644 --- a/backend-node/src/controllers/dynamicFormController.ts +++ b/backend-node/src/controllers/dynamicFormController.ts @@ -492,7 +492,7 @@ export const saveLocationHistory = async ( res: Response ): Promise => { try { - const { companyCode, userId } = req.user as any; + const { companyCode, userId: loginUserId } = req.user as any; const { latitude, longitude, @@ -508,10 +508,17 @@ export const saveLocationHistory = async ( destinationName, recordedAt, vehicleId, + userId: requestUserId, // ํ”„๋ก ํŠธ์—”๋“œ์—์„œ ๋ณด๋‚ธ userId (์ฐจ๋Ÿ‰ ๋ฒˆํ˜ธํŒ ๋“ฑ) } = req.body; + // ํ”„๋ก ํŠธ์—”๋“œ์—์„œ ๋ณด๋‚ธ userId๊ฐ€ ์žˆ์œผ๋ฉด ๊ทธ๊ฒƒ์„ ์‚ฌ์šฉ (์ฐจ๋Ÿ‰ ๋ฒˆํ˜ธํŒ ๋“ฑ) + // ์—†์œผ๋ฉด ๋กœ๊ทธ์ธํ•œ ์‚ฌ์šฉ์ž์˜ userId ์‚ฌ์šฉ + const userId = requestUserId || loginUserId; + console.log("๐Ÿ“ [saveLocationHistory] ์š”์ฒญ:", { userId, + requestUserId, + loginUserId, companyCode, latitude, longitude, diff --git a/backend-node/src/services/externalRestApiConnectionService.ts b/backend-node/src/services/externalRestApiConnectionService.ts index 2632a6e6..6f0b1239 100644 --- a/backend-node/src/services/externalRestApiConnectionService.ts +++ b/backend-node/src/services/externalRestApiConnectionService.ts @@ -209,8 +209,8 @@ export class ExternalRestApiConnectionService { connection_name, description, base_url, endpoint_path, default_headers, default_method, default_request_body, auth_type, auth_config, timeout, retry_count, retry_delay, - company_code, is_active, created_by - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + company_code, is_active, created_by, save_to_history + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING * `; @@ -230,6 +230,7 @@ export class ExternalRestApiConnectionService { data.company_code || "*", data.is_active || "Y", data.created_by || "system", + data.save_to_history || "N", ]; // ๋””๋ฒ„๊น…: ์ €์žฅํ•˜๋ ค๋Š” ๋ฐ์ดํ„ฐ ๋กœ๊น… @@ -377,6 +378,12 @@ export class ExternalRestApiConnectionService { paramIndex++; } + if (data.save_to_history !== undefined) { + updateFields.push(`save_to_history = $${paramIndex}`); + params.push(data.save_to_history); + paramIndex++; + } + if (data.updated_by !== undefined) { updateFields.push(`updated_by = $${paramIndex}`); params.push(data.updated_by); diff --git a/backend-node/src/types/externalRestApiTypes.ts b/backend-node/src/types/externalRestApiTypes.ts index 8d95a4a6..416cbe6f 100644 --- a/backend-node/src/types/externalRestApiTypes.ts +++ b/backend-node/src/types/externalRestApiTypes.ts @@ -53,6 +53,9 @@ export interface ExternalRestApiConnection { retry_delay?: number; company_code: string; is_active: string; + + // ์œ„์น˜ ์ด๋ ฅ ์ €์žฅ ์„ค์ • (์ง€๋„ ์œ„์ ฏ์šฉ) + save_to_history?: string; // 'Y' ๋˜๋Š” 'N' - REST API์—์„œ ๊ฐ€์ ธ์˜จ ์œ„์น˜ ๋ฐ์ดํ„ฐ๋ฅผ vehicle_location_history์— ์ €์žฅ created_date?: Date; created_by?: string; updated_date?: Date; diff --git a/frontend/components/admin/RestApiConnectionModal.tsx b/frontend/components/admin/RestApiConnectionModal.tsx index 3de34800..606e3874 100644 --- a/frontend/components/admin/RestApiConnectionModal.tsx +++ b/frontend/components/admin/RestApiConnectionModal.tsx @@ -53,6 +53,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: const [retryCount, setRetryCount] = useState(0); const [retryDelay, setRetryDelay] = useState(1000); const [isActive, setIsActive] = useState(true); + const [saveToHistory, setSaveToHistory] = useState(false); // ์œ„์น˜ ์ด๋ ฅ ์ €์žฅ ์„ค์ • // UI ์ƒํƒœ const [showAdvanced, setShowAdvanced] = useState(false); @@ -80,6 +81,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: setRetryCount(connection.retry_count || 0); setRetryDelay(connection.retry_delay || 1000); setIsActive(connection.is_active === "Y"); + setSaveToHistory(connection.save_to_history === "Y"); // ํ…Œ์ŠคํŠธ ์ดˆ๊ธฐ๊ฐ’ ์„ค์ • setTestEndpoint(""); @@ -100,6 +102,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: setRetryCount(0); setRetryDelay(1000); setIsActive(true); + setSaveToHistory(false); // ํ…Œ์ŠคํŠธ ์ดˆ๊ธฐ๊ฐ’ ์„ค์ • setTestEndpoint(""); @@ -234,6 +237,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: retry_delay: retryDelay, // company_code๋Š” ๋ฐฑ์—”๋“œ์—์„œ ๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž์˜ company_code๋กœ ์ž๋™ ์„ค์ • is_active: isActive ? "Y" : "N", + save_to_history: saveToHistory ? "Y" : "N", }; console.log("์ €์žฅํ•˜๋ ค๋Š” ๋ฐ์ดํ„ฐ:", { @@ -376,6 +380,16 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: ํ™œ์„ฑ ์ƒํƒœ
+ +
+ + + + (์ง€๋„ ์œ„์ ฏ์—์„œ ์ด API ๋ฐ์ดํ„ฐ๋ฅผ vehicle_location_history์— ์ €์žฅ) + +
{/* ํ—ค๋” ๊ด€๋ฆฌ */} diff --git a/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx b/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx index 86da8fe7..f92e440a 100644 --- a/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx +++ b/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx @@ -8,6 +8,7 @@ import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { Plus, Trash2, Loader2, CheckCircle, XCircle } from "lucide-react"; +import { Switch } from "@/components/ui/switch"; import { ExternalDbConnectionAPI, ExternalApiConnection } from "@/lib/api/externalDbConnection"; import { getApiUrl } from "@/lib/utils/apiUrl"; @@ -850,6 +851,23 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M )} + {/* ์œ„์น˜ ์ด๋ ฅ ์ €์žฅ ์„ค์ • (์ง€๋„ ์œ„์ ฏ์šฉ) */} +
+
+ +

+ REST API์—์„œ ๊ฐ€์ ธ์˜จ ์œ„์น˜ ๋ฐ์ดํ„ฐ๋ฅผ vehicle_location_history์— ์ €์žฅํ•ฉ๋‹ˆ๋‹ค +

+
+ onChange({ saveToHistory: checked })} + /> +
+ {/* ์ปฌ๋Ÿผ ๋งคํ•‘ (API ํ…Œ์ŠคํŠธ ์„ฑ๊ณต ํ›„์—๋งŒ ํ‘œ์‹œ) */} {testResult?.success && availableColumns.length > 0 && (
diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts index 19599b69..bc52ecb8 100644 --- a/frontend/components/admin/dashboard/types.ts +++ b/frontend/components/admin/dashboard/types.ts @@ -183,6 +183,9 @@ export interface ChartDataSource { label: string; // ํ‘œ์‹œํ•  ํ•œ๊ธ€๋ช… (์˜ˆ: ์ฐจ๋Ÿ‰ ๋ฒˆํ˜ธ) format?: "text" | "date" | "datetime" | "number" | "url"; // ํ‘œ์‹œ ํฌ๋งท }[]; + + // REST API ์œ„์น˜ ๋ฐ์ดํ„ฐ ์ €์žฅ ์„ค์ • (MapTestWidgetV2์šฉ) + saveToHistory?: boolean; // REST API์—์„œ ๊ฐ€์ ธ์˜จ ์œ„์น˜ ๋ฐ์ดํ„ฐ๋ฅผ vehicle_location_history์— ์ €์žฅ } export interface ChartConfig { diff --git a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx index 94c3a217..9b0db43a 100644 --- a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx +++ b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx @@ -365,7 +365,70 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const mappedRows = applyColumnMapping(rows, source.columnMapping); // ๋งˆ์ปค์™€ ํด๋ฆฌ๊ณค์œผ๋กœ ๋ณ€ํ™˜ (mapDisplayType + dataSource ์ „๋‹ฌ) - return convertToMapData(mappedRows, source.name || source.id || "API", source.mapDisplayType, source); + const mapData = convertToMapData(mappedRows, source.name || source.id || "API", source.mapDisplayType, source); + + // โœ… REST API ๋ฐ์ดํ„ฐ๋ฅผ vehicle_location_history์— ์ž๋™ ์ €์žฅ (๊ฒฝ๋กœ ๋ณด๊ธฐ์šฉ) + // - ๋ชจ๋“  REST API ์ฐจ๋Ÿ‰ ์œ„์น˜ ๋ฐ์ดํ„ฐ๋Š” ์ž๋™์œผ๋กœ ์ €์žฅ๋จ + if (mapData.markers.length > 0) { + try { + const authToken = typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""; + + // ๋งˆ์ปค ๋ฐ์ดํ„ฐ๋ฅผ vehicle_location_history์— ์ €์žฅ + for (const marker of mapData.markers) { + // user_id ์ถ”์ถœ (๋งˆ์ปค description์—์„œ ํŒŒ์‹ฑ) + let userId = ""; + let vehicleId: number | undefined = undefined; + let vehicleName = ""; + + if (marker.description) { + try { + const parsed = JSON.parse(marker.description); + // ๋‹ค์–‘ํ•œ ํ•„๋“œ๋ช… ์ง€์› (plate_no ์šฐ์„  - ์ฐจ๋Ÿ‰ ๋ฒˆํ˜ธํŒ์œผ๋กœ ๊ฒฝ๋กœ ๊ตฌ๋ถ„) + userId = parsed.plate_no || parsed.plateNo || parsed.car_number || parsed.carNumber || + parsed.user_id || parsed.userId || parsed.driver_id || parsed.driverId || + parsed.car_no || parsed.carNo || parsed.vehicle_no || parsed.vehicleNo || + parsed.id || parsed.code || ""; + vehicleId = parsed.vehicle_id || parsed.vehicleId || parsed.car_id || parsed.carId; + vehicleName = parsed.plate_no || parsed.plateNo || parsed.car_name || parsed.carName || + parsed.vehicle_name || parsed.vehicleName || parsed.name || parsed.title || ""; + } catch { + // ํŒŒ์‹ฑ ์‹คํŒจ ์‹œ ๋ฌด์‹œ + } + } + + // user_id๊ฐ€ ์—†์œผ๋ฉด ๋งˆ์ปค ์ด๋ฆ„์ด๋‚˜ ID๋ฅผ ์‚ฌ์šฉ + if (!userId) { + userId = marker.name || marker.id || `marker_${Date.now()}`; + } + + // vehicle_location_history์— ์ €์žฅ + await fetch(getApiUrl("/api/dynamic-form/location-history"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, + credentials: "include", + body: JSON.stringify({ + latitude: marker.lat, + longitude: marker.lng, + userId: userId, + vehicleId: vehicleId, + tripStatus: "api_tracking", // REST API์—์„œ ๊ฐ€์ ธ์˜จ ๋ฐ์ดํ„ฐ ํ‘œ์‹œ + departureName: source.name || "REST API", + destinationName: vehicleName || marker.name, + }), + }); + + console.log("๐Ÿ“ [saveToHistory] ์ €์žฅ ์™„๋ฃŒ:", { userId, lat: marker.lat, lng: marker.lng }); + } + } catch (saveError) { + console.error("โŒ [saveToHistory] ์ €์žฅ ์‹คํŒจ:", saveError); + // ์ €์žฅ ์‹คํŒจํ•ด๋„ ๋งˆ์ปค ํ‘œ์‹œ๋Š” ๊ณ„์† + } + } + + return mapData; }; // Database ๋ฐ์ดํ„ฐ ๋กœ๋”ฉ @@ -1659,16 +1722,20 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { {(() => { try { const parsed = JSON.parse(marker.description || "{}"); - const userId = parsed.user_id; - if (userId) { + // ๋‹ค์–‘ํ•œ ํ•„๋“œ๋ช… ์ง€์› (plate_no ์šฐ์„ ) + const visibleUserId = parsed.plate_no || parsed.plateNo || parsed.car_number || parsed.carNumber || + parsed.user_id || parsed.userId || parsed.driver_id || parsed.driverId || + parsed.car_no || parsed.carNo || parsed.vehicle_no || parsed.vehicleNo || + parsed.id || parsed.code || marker.name; + if (visibleUserId) { return (
); diff --git a/frontend/lib/api/externalRestApiConnection.ts b/frontend/lib/api/externalRestApiConnection.ts index f907ee85..d58545f6 100644 --- a/frontend/lib/api/externalRestApiConnection.ts +++ b/frontend/lib/api/externalRestApiConnection.ts @@ -45,6 +45,9 @@ export interface ExternalRestApiConnection { retry_delay?: number; company_code: string; is_active: string; + + // ์œ„์น˜ ์ด๋ ฅ ์ €์žฅ ์„ค์ • (์ง€๋„ ์œ„์ ฏ์šฉ) + save_to_history?: string; // 'Y' ๋˜๋Š” 'N' - REST API์—์„œ ๊ฐ€์ ธ์˜จ ์œ„์น˜ ๋ฐ์ดํ„ฐ๋ฅผ vehicle_location_history์— ์ €์žฅ created_date?: Date; created_by?: string; updated_date?: Date; diff --git a/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx b/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx index 5dc4a165..3f1a723b 100644 --- a/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx +++ b/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx @@ -343,8 +343,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) {options.map((option) => ( - + {option.label} + {option.value === localDestination && " (๋„์ฐฉ์ง€)"} ))} @@ -387,8 +392,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) {options.map((option) => ( - + {option.label} + {option.value === localDeparture && " (์ถœ๋ฐœ์ง€)"} ))} @@ -419,8 +429,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) {options.map((option) => ( - + {option.label} + {option.value === localDestination && " (๋„์ฐฉ์ง€)"} ))} @@ -451,8 +466,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) {options.map((option) => ( - + {option.label} + {option.value === localDeparture && " (์ถœ๋ฐœ์ง€)"} ))} @@ -479,8 +499,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) {options.map((option) => ( - + {option.label} + {option.value === localDestination && " (๋„์ฐฉ์ง€)"} ))} @@ -508,8 +533,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) {options.map((option) => ( - + {option.label} + {option.value === localDeparture && " (์ถœ๋ฐœ์ง€)"} ))}