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

6280 lines
245 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { TableListConfig, ColumnConfig } from "./types";
import { WebType } from "@/types/common";
import { tableTypeApi } from "@/lib/api/screen";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { codeCache } from "@/lib/caching/codeCache";
import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization";
import { getFullImageUrl } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
RefreshCw,
ArrowUp,
ArrowDown,
TableIcon,
Settings,
X,
Layers,
ChevronDown,
Filter,
Check,
Download,
FileSpreadsheet,
Copy,
ClipboardCopy,
Edit,
CheckSquare,
Trash2,
Lock,
} 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";
import { tableDisplayStore } from "@/stores/tableDisplayStore";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Label } from "@/components/ui/label";
import { AdvancedSearchFilters } from "@/components/screen/filters/AdvancedSearchFilters";
import { SingleTableWithSticky } from "./SingleTableWithSticky";
import { CardModeRenderer } from "./CardModeRenderer";
import { TableOptionsModal } from "@/components/common/TableOptionsModal";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import { TableFilter, ColumnVisibility } from "@/types/table-options";
import { useAuth } from "@/hooks/useAuth";
import { useScreenContextOptional } from "@/contexts/ScreenContext";
import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext";
import type { DataProvidable, DataReceivable, DataReceiverConfig } from "@/types/data-transfer";
// ========================================
// 인터페이스
// ========================================
// 그룹화된 데이터 인터페이스
interface GroupedData {
groupKey: string;
groupValues: Record<string, any>;
items: any[];
count: number;
summary?: Record<string, { sum: number; avg: number; count: number }>; // 🆕 그룹별 소계
}
// ========================================
// 캐시 및 유틸리티
// ========================================
const tableColumnCache = new Map<string, { columns: any[]; timestamp: number }>();
const tableInfoCache = new Map<string, { tables: any[]; timestamp: number }>();
const TABLE_CACHE_TTL = 5 * 60 * 1000; // 5분
const cleanupTableCache = () => {
const now = Date.now();
for (const [key, entry] of tableColumnCache.entries()) {
if (now - entry.timestamp > TABLE_CACHE_TTL) {
tableColumnCache.delete(key);
}
}
for (const [key, entry] of tableInfoCache.entries()) {
if (now - entry.timestamp > TABLE_CACHE_TTL) {
tableInfoCache.delete(key);
}
}
};
if (typeof window !== "undefined") {
setInterval(cleanupTableCache, 10 * 60 * 1000);
}
const debounceTimers = new Map<string, NodeJS.Timeout>();
const activeRequests = new Map<string, Promise<any>>();
const debouncedApiCall = <T extends any[], R>(key: string, fn: (...args: T) => Promise<R>, delay: number = 300) => {
return (...args: T): Promise<R> => {
const activeRequest = activeRequests.get(key);
if (activeRequest) {
return activeRequest as Promise<R>;
}
return new Promise((resolve, reject) => {
const existingTimer = debounceTimers.get(key);
if (existingTimer) {
clearTimeout(existingTimer);
}
const timer = setTimeout(async () => {
try {
const requestPromise = fn(...args);
activeRequests.set(key, requestPromise);
const result = await requestPromise;
resolve(result);
} catch (error) {
reject(error);
} finally {
debounceTimers.delete(key);
activeRequests.delete(key);
}
}, delay);
debounceTimers.set(key, timer);
});
};
};
// ========================================
// 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 인터페이스
// ========================================
export interface TableListComponentProps {
component: any;
isDesignMode?: boolean;
isSelected?: boolean;
isInteractive?: boolean;
onClick?: () => void;
onDragStart?: (e: React.DragEvent) => void;
onDragEnd?: (e: React.DragEvent) => void;
className?: string;
style?: React.CSSProperties;
formData?: Record<string, any>;
onFormDataChange?: (data: any) => void;
config?: TableListConfig;
size?: { width: number; height: number };
position?: { x: number; y: number; z?: number };
componentConfig?: any;
selectedScreen?: any;
onZoneComponentDrop?: any;
onZoneClick?: any;
tableName?: string;
onRefresh?: () => void;
onClose?: () => void;
screenId?: number | string; // 화면 ID (필터 설정 저장용)
userId?: string; // 사용자 ID (컬럼 순서 저장용)
onSelectedRowsChange?: (
selectedRows: any[],
selectedRowsData: any[],
sortBy?: string,
sortOrder?: "asc" | "desc",
columnOrder?: string[],
tableDisplayData?: any[],
) => void;
onConfigChange?: (config: any) => void;
refreshKey?: number;
}
// ========================================
// 메인 컴포넌트
// ========================================
export const TableListComponent: React.FC<TableListComponentProps> = ({
component,
isDesignMode = false,
isSelected = false,
onClick,
onDragStart,
onDragEnd,
config,
className,
style,
formData: propFormData, // 🆕 부모에서 전달받은 formData
onFormDataChange,
componentConfig,
onSelectedRowsChange,
onConfigChange,
refreshKey,
tableName,
userId,
screenId, // 화면 ID 추출
}) => {
// ========================================
// 설정 및 스타일
// ========================================
const tableConfig = {
...config,
...component.config,
...componentConfig,
} as TableListConfig;
// selectedTable 안전하게 추출 (문자열인지 확인)
let finalSelectedTable =
componentConfig?.selectedTable || component.config?.selectedTable || config?.selectedTable || tableName;
// 디버그 로그 제거 (성능 최적화)
// 객체인 경우 tableName 속성 추출 시도
if (typeof finalSelectedTable === "object" && finalSelectedTable !== null) {
console.warn("⚠️ selectedTable이 객체입니다:", finalSelectedTable);
finalSelectedTable = (finalSelectedTable as any).tableName || (finalSelectedTable as any).name || tableName;
console.log("✅ 객체에서 추출한 테이블명:", finalSelectedTable);
}
tableConfig.selectedTable = finalSelectedTable;
// 디버그 로그 제거 (성능 최적화)
const buttonColor = component.style?.labelColor || "#212121";
const buttonTextColor = component.config?.buttonTextColor || "#ffffff";
const gridColumns = component.gridColumns || 1;
let calculatedWidth: string;
if (isDesignMode) {
if (gridColumns === 1) {
calculatedWidth = "400px";
} else if (gridColumns === 2) {
calculatedWidth = "800px";
} else {
calculatedWidth = "100%";
}
} else {
calculatedWidth = "100%";
}
const componentStyle: React.CSSProperties = {
position: "relative",
display: "flex",
flexDirection: "column",
backgroundColor: "hsl(var(--background))",
overflow: "hidden",
boxSizing: "border-box",
width: "100%",
height: "100%",
minHeight: isDesignMode ? "300px" : "100%",
...style, // style prop이 위의 기본값들을 덮어씀
};
// ========================================
// 상태 관리
// ========================================
// 사용자 정보 (props에서 받거나 useAuth에서 가져오기)
const { userId: authUserId } = useAuth();
const currentUserId = userId || authUserId;
// 화면 컨텍스트 (데이터 제공자로 등록)
const screenContext = useScreenContextOptional();
// 분할 패널 컨텍스트 (분할 패널 내부에서 데이터 수신자로 등록)
const splitPanelContext = useSplitPanelContext();
// 🆕 ScreenContext에서 splitPanelPosition 가져오기 (중첩 화면에서도 작동)
const splitPanelPosition = screenContext?.splitPanelPosition;
// 🆕 연결된 필터 상태 (다른 컴포넌트 값으로 필터링)
const [linkedFilterValues, setLinkedFilterValues] = useState<Record<string, any>>({});
// TableOptions Context
const { registerTable, unregisterTable, updateTableDataCount } = useTableOptions();
const [filters, setFilters] = useState<TableFilter[]>([]);
const [grouping, setGrouping] = useState<string[]>([]);
const [columnVisibility, setColumnVisibility] = useState<ColumnVisibility[]>([]);
// filters가 변경되면 searchValues 업데이트 (실시간 검색)
useEffect(() => {
const newSearchValues: Record<string, any> = {};
filters.forEach((filter) => {
if (filter.value) {
newSearchValues[filter.columnName] = filter.value;
}
});
// console.log("🔍 [TableListComponent] filters → searchValues:", {
// filters: filters.length,
// searchValues: newSearchValues,
// });
setSearchValues(newSearchValues);
setCurrentPage(1); // 필터 변경 시 첫 페이지로
}, [filters]);
// grouping이 변경되면 groupByColumns 업데이트
useEffect(() => {
setGroupByColumns(grouping);
}, [grouping]);
// 초기 로드 시 localStorage에서 저장된 설정 불러오기
useEffect(() => {
if (tableConfig.selectedTable && currentUserId) {
const storageKey = `table_column_visibility_${tableConfig.selectedTable}_${currentUserId}`;
const savedSettings = localStorage.getItem(storageKey);
if (savedSettings) {
try {
const parsed = JSON.parse(savedSettings) as ColumnVisibility[];
setColumnVisibility(parsed);
} catch (error) {
console.error("저장된 컬럼 설정 불러오기 실패:", error);
}
}
}
}, [tableConfig.selectedTable, currentUserId]);
// columnVisibility 변경 시 컬럼 순서 및 가시성 적용
useEffect(() => {
if (columnVisibility.length > 0) {
const newOrder = columnVisibility
.map((cv) => cv.columnName)
.filter((name) => name !== "__checkbox__"); // 체크박스 제외
setColumnOrder(newOrder);
// localStorage에 저장 (사용자별)
if (tableConfig.selectedTable && currentUserId) {
const storageKey = `table_column_visibility_${tableConfig.selectedTable}_${currentUserId}`;
localStorage.setItem(storageKey, JSON.stringify(columnVisibility));
}
}
}, [columnVisibility, tableConfig.selectedTable, currentUserId]);
// 🆕 columnOrder를 visibleColumns 이전에 정의 (visibleColumns에서 사용)
const [columnOrder, setColumnOrder] = useState<string[]>([]);
// 🆕 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: "",
visible: true,
sortable: false,
searchable: false,
width: 40,
align: "center" as const,
order: -1,
editable: false, // 체크박스는 편집 불가
};
}
// 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<Record<string, any>[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 🆕 컬럼 헤더 필터 상태 (상단에서 선언)
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
const [openFilterColumn, setOpenFilterColumn] = useState<string | null>(null);
// 🆕 Filter Builder (고급 필터) 관련 상태 - filteredData보다 먼저 정의해야 함
const [filterGroups, setFilterGroups] = useState<FilterGroup[]>([]);
// 🆕 분할 패널에서 우측에 이미 추가된 항목 필터링 (좌측 테이블에만 적용) + 헤더 필터
const filteredData = useMemo(() => {
let result = data;
// 1. 분할 패널 좌측에 있고, 우측에 추가된 항목이 있는 경우 필터링
if (splitPanelPosition === "left" && splitPanelContext?.addedItemIds && splitPanelContext.addedItemIds.size > 0) {
const addedIds = splitPanelContext.addedItemIds;
result = result.filter((row) => {
const rowId = String(row.id || row.po_item_id || row.item_id || "");
return !addedIds.has(rowId);
});
}
// 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);
const [searchTerm, setSearchTerm] = useState("");
const [sortColumn, setSortColumn] = useState<string | null>(null);
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const hasInitializedSort = useRef(false);
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({});
const [tableLabel, setTableLabel] = useState<string>("");
const [localPageSize, setLocalPageSize] = useState<number>(tableConfig.pagination?.pageSize || 20);
const [displayColumns, setDisplayColumns] = useState<ColumnConfig[]>([]);
const [joinColumnMapping, setJoinColumnMapping] = useState<Record<string, string>>({});
const [columnMeta, setColumnMeta] = useState<
Record<string, { webType?: string; codeCategory?: string; inputType?: string }>
>({});
// 🆕 엔티티 조인 테이블의 컬럼 메타데이터 (테이블명.컬럼명 → inputType)
const [joinedColumnMeta, setJoinedColumnMeta] = useState<
Record<string, { webType?: string; codeCategory?: string; inputType?: string }>
>({});
const [categoryMappings, setCategoryMappings] = useState<
Record<string, Record<string, { label: string; color?: string }>>
>({});
const [categoryMappingsKey, setCategoryMappingsKey] = useState(0); // 강제 리렌더링용
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
const [refreshTrigger, setRefreshTrigger] = useState(0);
// columnOrder는 상단에서 정의됨 (visibleColumns보다 먼저 필요)
const columnRefs = useRef<Record<string, HTMLTableCellElement | null>>({});
const [isAllSelected, setIsAllSelected] = useState(false);
const hasInitializedWidths = useRef(false);
const isResizing = useRef(false);
// 필터 설정 관련 상태
const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false);
const [visibleFilterColumns, setVisibleFilterColumns] = useState<Set<string>>(new Set());
// 🆕 키보드 네비게이션 관련 상태
const [focusedCell, setFocusedCell] = useState<{ rowIndex: number; colIndex: number } | null>(null);
const tableContainerRef = useRef<HTMLDivElement>(null);
// 🆕 인라인 셀 편집 관련 상태
const [editingCell, setEditingCell] = useState<{
rowIndex: number;
colIndex: number;
columnName: string;
originalValue: any;
} | null>(null);
const [editingValue, setEditingValue] = useState<string>("");
const editInputRef = useRef<HTMLInputElement>(null);
// 🆕 배치 편집 관련 상태
const [editMode, setEditMode] = useState<"immediate" | "batch">("immediate"); // 편집 모드
const [pendingChanges, setPendingChanges] = useState<Map<string, {
rowIndex: number;
columnName: string;
originalValue: any;
newValue: any;
primaryKeyValue: any;
}>>(new Map()); // key: `${rowIndex}-${columnName}`
const [localEditedData, setLocalEditedData] = useState<Record<number, Record<string, any>>>({}); // 로컬 수정 데이터
// 🆕 유효성 검사 관련 상태
const [validationErrors, setValidationErrors] = useState<Map<string, string>>(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<Record<string, { value: string; label: string }[]>>({});
const [loadingCascading, setLoadingCascading] = useState<Record<string, boolean>>({});
// 🆕 Multi-Level Headers (Column Bands) 타입
type ColumnBand = {
caption: string;
columns: string[]; // 포함될 컬럼명 배열
};
// 그룹 설정 관련 상태
const [groupByColumns, setGroupByColumns] = useState<string[]>([]);
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
// 🆕 Master-Detail 관련 상태
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set()); // 확장된 행 키 목록
const [detailData, setDetailData] = useState<Record<string, any[]>>({}); // 상세 데이터 캐시
// 🆕 Drag & Drop 재정렬 관련 상태
const [draggedRowIndex, setDraggedRowIndex] = useState<number | null>(null);
const [dropTargetIndex, setDropTargetIndex] = useState<number | null>(null);
const [isDragEnabled, setIsDragEnabled] = useState<boolean>((tableConfig as any).enableRowDrag ?? false);
// 🆕 Virtual Scrolling 관련 상태
const [isVirtualScrollEnabled] = useState<boolean>((tableConfig as any).virtualScroll ?? false);
const [scrollTop, setScrollTop] = useState(0);
const ROW_HEIGHT = 40; // 각 행의 높이 (픽셀)
const OVERSCAN = 5; // 버퍼로 추가 렌더링할 행 수
const scrollContainerRef = useRef<HTMLDivElement>(null);
// 🆕 Column Reordering 관련 상태
const [draggedColumnIndex, setDraggedColumnIndex] = useState<number | null>(null);
const [dropTargetColumnIndex, setDropTargetColumnIndex] = useState<number | null>(null);
const [isColumnDragEnabled] = useState<boolean>((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<boolean>((tableConfig as any).realTimeUpdates ?? false);
const [wsConnectionStatus, setWsConnectionStatus] = useState<"connecting" | "connected" | "disconnected">("disconnected");
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(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<string[]>([]);
// 🆕 Search Panel (통합 검색) 관련 상태
const [globalSearchTerm, setGlobalSearchTerm] = useState("");
const [isSearchPanelOpen, setIsSearchPanelOpen] = useState(false);
const [searchHighlights, setSearchHighlights] = useState<Set<string>>(new Set()); // "rowIndex-colIndex" 형식
// 🆕 Filter Builder (고급 필터) 관련 상태 추가
const [isFilterBuilderOpen, setIsFilterBuilderOpen] = useState(false);
const [activeFilterCount, setActiveFilterCount] = useState(0);
// 🆕 연결된 필터 처리 (셀렉트박스 등 다른 컴포넌트 값으로 필터링)
useEffect(() => {
const linkedFilters = tableConfig.linkedFilters;
if (!linkedFilters || linkedFilters.length === 0 || !screenContext) {
return;
}
// 연결된 소스 컴포넌트들의 값을 주기적으로 확인
const checkLinkedFilters = () => {
const newFilterValues: Record<string, any> = {};
let hasChanges = false;
linkedFilters.forEach((filter) => {
if (filter.enabled === false) return;
const sourceProvider = screenContext.getDataProvider(filter.sourceComponentId);
if (sourceProvider) {
const selectedData = sourceProvider.getSelectedData();
if (selectedData && selectedData.length > 0) {
const sourceField = filter.sourceField || "value";
const value = selectedData[0][sourceField];
if (value !== linkedFilterValues[filter.targetColumn]) {
newFilterValues[filter.targetColumn] = value;
hasChanges = true;
} else {
newFilterValues[filter.targetColumn] = linkedFilterValues[filter.targetColumn];
}
}
}
});
if (hasChanges) {
console.log("🔗 [TableList] 연결된 필터 값 변경:", newFilterValues);
setLinkedFilterValues(newFilterValues);
// searchValues에 연결된 필터 값 병합
setSearchValues(prev => ({
...prev,
...newFilterValues
}));
// 첫 페이지로 이동
setCurrentPage(1);
}
};
// 초기 체크
checkLinkedFilters();
// 주기적으로 체크 (500ms마다)
const intervalId = setInterval(checkLinkedFilters, 500);
return () => {
clearInterval(intervalId);
};
}, [screenContext, tableConfig.linkedFilters, linkedFilterValues]);
// DataProvidable 인터페이스 구현
const dataProvider: DataProvidable = {
componentId: component.id,
componentType: "table-list",
getSelectedData: () => {
// 🆕 필터링된 데이터에서 선택된 행만 반환 (우측에 추가된 항목 제외)
const selectedData = filteredData.filter((row) => {
const rowId = String(row.id || row[tableConfig.selectedTable + "_id"] || "");
return selectedRows.has(rowId);
});
return selectedData;
},
getAllData: () => {
// 🆕 필터링된 데이터 반환
return filteredData;
},
clearSelection: () => {
setSelectedRows(new Set());
setIsAllSelected(false);
},
};
// DataReceivable 인터페이스 구현
const dataReceiver: DataReceivable = {
componentId: component.id,
componentType: "table",
receiveData: async (receivedData: any[], config: DataReceiverConfig) => {
console.log("📥 TableList 데이터 수신:", {
componentId: component.id,
receivedDataCount: receivedData.length,
mode: config.mode,
currentDataCount: data.length,
});
try {
let newData: any[] = [];
switch (config.mode) {
case "append":
// 기존 데이터에 추가
newData = [...data, ...receivedData];
console.log("✅ Append 모드: 기존 데이터에 추가", { newDataCount: newData.length });
break;
case "replace":
// 기존 데이터를 완전히 교체
newData = receivedData;
console.log("✅ Replace 모드: 데이터 교체", { newDataCount: newData.length });
break;
case "merge":
// 기존 데이터와 병합 (ID 기반)
const existingMap = new Map(data.map(item => [item.id, item]));
receivedData.forEach(item => {
if (item.id && existingMap.has(item.id)) {
// 기존 데이터 업데이트
existingMap.set(item.id, { ...existingMap.get(item.id), ...item });
} else {
// 새 데이터 추가
existingMap.set(item.id || Date.now() + Math.random(), item);
}
});
newData = Array.from(existingMap.values());
console.log("✅ Merge 모드: 데이터 병합", { newDataCount: newData.length });
break;
}
// 상태 업데이트
setData(newData);
// 총 아이템 수 업데이트
setTotalItems(newData.length);
console.log("✅ 데이터 수신 완료:", { finalDataCount: newData.length });
} catch (error) {
console.error("❌ 데이터 수신 실패:", error);
throw error;
}
},
getData: () => {
return data;
},
};
// 화면 컨텍스트에 데이터 제공자/수신자로 등록
useEffect(() => {
if (screenContext && component.id) {
screenContext.registerDataProvider(component.id, dataProvider);
screenContext.registerDataReceiver(component.id, dataReceiver);
return () => {
screenContext.unregisterDataProvider(component.id);
screenContext.unregisterDataReceiver(component.id);
};
}
}, [screenContext, component.id, data, selectedRows]);
// 분할 패널 컨텍스트에 데이터 수신자로 등록
// useSplitPanelPosition 훅으로 위치 가져오기 (중첩된 화면에서도 작동)
const currentSplitPosition = splitPanelPosition || splitPanelContext?.getPositionByScreenId(screenId as number) || null;
useEffect(() => {
if (splitPanelContext && component.id && currentSplitPosition) {
const splitPanelReceiver = {
componentId: component.id,
componentType: "table-list",
receiveData: async (incomingData: any[], mode: "append" | "replace" | "merge") => {
console.log("📥 [TableListComponent] 분할 패널에서 데이터 수신:", {
count: incomingData.length,
mode,
position: currentSplitPosition,
});
await dataReceiver.receiveData(incomingData, {
targetComponentId: component.id,
targetComponentType: "table-list",
mode,
mappingRules: [],
});
},
};
splitPanelContext.registerReceiver(currentSplitPosition, component.id, splitPanelReceiver);
return () => {
splitPanelContext.unregisterReceiver(currentSplitPosition, component.id);
};
}
}, [splitPanelContext, component.id, currentSplitPosition, dataReceiver]);
// 테이블 등록 (Context에 등록)
const tableId = `table-list-${component.id}`;
useEffect(() => {
// tableConfig.columns를 직접 사용 (displayColumns는 비어있을 수 있음)
const columnsToRegister = (tableConfig.columns || [])
.filter((col) => col.visible !== false && col.columnName !== "__checkbox__");
if (!tableConfig.selectedTable || !columnsToRegister || columnsToRegister.length === 0) {
return;
}
// 컬럼의 고유 값 조회 함수
const getColumnUniqueValues = async (columnName: string) => {
console.log("🔍 [getColumnUniqueValues] 호출됨:", {
columnName,
dataLength: data.length,
columnMeta: columnMeta[columnName],
sampleData: data[0],
});
const meta = columnMeta[columnName];
const inputType = meta?.inputType || "text";
// 카테고리 타입인 경우 전체 정의된 값 조회 (백엔드 API)
if (inputType === "category") {
try {
console.log("🔍 [getColumnUniqueValues] 카테고리 전체 값 조회:", {
tableName: tableConfig.selectedTable,
columnName,
});
// API 클라이언트 사용 (쿠키 인증 자동 처리)
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(
`/table-categories/${tableConfig.selectedTable}/${columnName}/values`
);
if (response.data.success && response.data.data) {
const categoryOptions = response.data.data.map((item: any) => ({
value: item.valueCode, // 카멜케이스
label: item.valueLabel, // 카멜케이스
}));
console.log("✅ [getColumnUniqueValues] 카테고리 전체 값:", {
columnName,
count: categoryOptions.length,
options: categoryOptions,
});
return categoryOptions;
} else {
console.warn("⚠️ [getColumnUniqueValues] 응답 형식 오류:", response.data);
}
} catch (error: any) {
console.error("❌ [getColumnUniqueValues] 카테고리 조회 실패:", {
error: error.message,
response: error.response?.data,
status: error.response?.status,
columnName,
tableName: tableConfig.selectedTable,
});
// 에러 시 현재 데이터 기반으로 fallback
}
}
// 일반 타입 또는 카테고리 조회 실패 시: 현재 데이터 기반
const isLabelType = ["category", "entity", "code"].includes(inputType);
const labelField = isLabelType ? `${columnName}_name` : columnName;
console.log("🔍 [getColumnUniqueValues] 데이터 기반 조회:", {
columnName,
inputType,
isLabelType,
labelField,
hasLabelField: data[0] && labelField in data[0],
sampleLabelValue: data[0] ? data[0][labelField] : undefined,
});
// 현재 로드된 데이터에서 고유 값 추출
const uniqueValuesMap = new Map<string, string>(); // value -> label
data.forEach((row) => {
const value = row[columnName];
if (value !== null && value !== undefined && value !== "") {
// 백엔드 조인된 _name 필드 사용 (없으면 원본 값)
const label = isLabelType && row[labelField] ? row[labelField] : String(value);
uniqueValuesMap.set(String(value), label);
}
});
// Map을 배열로 변환하고 라벨 기준으로 정렬
const result = Array.from(uniqueValuesMap.entries())
.map(([value, label]) => ({
value: value,
label: label,
}))
.sort((a, b) => a.label.localeCompare(b.label));
console.log("✅ [getColumnUniqueValues] 데이터 기반 결과:", {
columnName,
inputType,
isLabelType,
labelField,
uniqueCount: result.length,
values: result,
});
return result;
};
const registration = {
tableId,
label: tableLabel || tableConfig.selectedTable,
tableName: tableConfig.selectedTable,
dataCount: totalItems || data.length, // 초기 데이터 건수 포함
columns: columnsToRegister.map((col) => ({
columnName: col.columnName || col.field,
columnLabel: columnLabels[col.columnName] || col.displayName || col.label || col.columnName || col.field,
inputType: columnMeta[col.columnName]?.inputType || "text",
visible: col.visible !== false,
width: columnWidths[col.columnName] || col.width || 150,
sortable: col.sortable !== false,
filterable: col.searchable !== false,
})),
onFilterChange: setFilters,
onGroupChange: setGrouping,
onColumnVisibilityChange: setColumnVisibility,
getColumnUniqueValues, // 고유 값 조회 함수 등록
};
registerTable(registration);
return () => {
unregisterTable(tableId);
};
}, [
tableId,
tableConfig.selectedTable,
tableConfig.columns,
columnLabels,
columnMeta, // columnMeta가 변경되면 재등록 (inputType 정보 필요)
columnWidths,
tableLabel,
data, // 데이터 자체가 변경되면 재등록 (고유 값 조회용)
totalItems, // 전체 항목 수가 변경되면 재등록
registerTable,
unregisterTable,
]);
// 🎯 초기 로드 시 localStorage에서 정렬 상태 불러오기
useEffect(() => {
if (!tableConfig.selectedTable || !userId || hasInitializedSort.current) return;
const storageKey = `table_sort_state_${tableConfig.selectedTable}_${userId}`;
const savedSort = localStorage.getItem(storageKey);
if (savedSort) {
try {
const { column, direction } = JSON.parse(savedSort);
if (column && direction) {
setSortColumn(column);
setSortDirection(direction);
hasInitializedSort.current = true;
console.log("📂 localStorage에서 정렬 상태 복원:", { column, direction });
}
} catch (error) {
console.error("❌ 정렬 상태 복원 실패:", error);
}
}
}, [tableConfig.selectedTable, userId]);
// 🆕 초기 로드 시 localStorage에서 컬럼 순서 불러오기
useEffect(() => {
if (!tableConfig.selectedTable || !userId) return;
const userKey = userId || "guest";
const storageKey = `table_column_order_${tableConfig.selectedTable}_${userKey}`;
const savedOrder = localStorage.getItem(storageKey);
if (savedOrder) {
try {
const parsedOrder = JSON.parse(savedOrder);
console.log("📂 localStorage에서 컬럼 순서 불러오기:", { storageKey, columnOrder: parsedOrder });
setColumnOrder(parsedOrder);
// 부모 컴포넌트에 초기 컬럼 순서 전달
if (onSelectedRowsChange && parsedOrder.length > 0) {
console.log("✅ 초기 컬럼 순서 전달:", parsedOrder);
// 초기 데이터도 함께 전달 (컬럼 순서대로 재정렬)
const initialData = data.map((row: any) => {
const reordered: any = {};
parsedOrder.forEach((colName: string) => {
if (colName in row) {
reordered[colName] = row[colName];
}
});
// 나머지 컬럼 추가
Object.keys(row).forEach((key) => {
if (!(key in reordered)) {
reordered[key] = row[key];
}
});
return reordered;
});
// 전역 저장소에 데이터 저장
if (tableConfig.selectedTable) {
// 컬럼 라벨 매핑 생성 (tableConfig.columns 사용 - visibleColumns는 아직 정의되지 않음)
const cols = (tableConfig.columns || []).filter((col) => col.visible !== false);
const labels: Record<string, string> = {};
cols.forEach((col) => {
labels[col.columnName] = columnLabels[col.columnName] || col.columnName;
});
tableDisplayStore.setTableData(
tableConfig.selectedTable,
initialData,
parsedOrder.filter((col) => col !== "__checkbox__"),
sortColumn,
sortDirection,
{
filterConditions: Object.keys(searchValues).length > 0 ? searchValues : undefined,
searchTerm: searchTerm || undefined,
visibleColumns: cols.map((col) => col.columnName),
columnLabels: labels,
currentPage: currentPage,
pageSize: localPageSize,
totalItems: totalItems,
},
);
}
onSelectedRowsChange([], [], sortColumn, sortDirection, parsedOrder, initialData);
}
} catch (error) {
console.error("❌ 컬럼 순서 파싱 실패:", error);
}
}
}, [tableConfig.selectedTable, userId, data.length]); // data.length 추가 (데이터 로드 후 실행)
const { optimizedConvertCode } = useEntityJoinOptimization(columnMeta, {
enableBatchLoading: true,
preloadCommonCodes: true,
maxBatchSize: 5,
});
// ========================================
// 컬럼 라벨 가져오기
// ========================================
const fetchColumnLabels = useCallback(async () => {
if (!tableConfig.selectedTable) return;
try {
// 🔥 FIX: 캐시 키에 회사 코드 포함 (멀티테넌시 지원)
const currentUser = JSON.parse(localStorage.getItem("currentUser") || "{}");
const companyCode = currentUser.companyCode || "UNKNOWN";
const cacheKey = `columns_${tableConfig.selectedTable}_${companyCode}`;
const cached = tableColumnCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < TABLE_CACHE_TTL) {
const labels: Record<string, string> = {};
const meta: Record<string, { webType?: string; codeCategory?: string; inputType?: string }> = {};
// 캐시된 inputTypes 맵 생성
const inputTypeMap: Record<string, string> = {};
if (cached.inputTypes) {
cached.inputTypes.forEach((col: any) => {
inputTypeMap[col.columnName] = col.inputType;
});
}
cached.columns.forEach((col: any) => {
labels[col.columnName] = col.displayName || col.comment || col.columnName;
meta[col.columnName] = {
webType: col.webType,
codeCategory: col.codeCategory,
inputType: inputTypeMap[col.columnName], // 캐시된 inputType 사용!
};
});
setColumnLabels(labels);
setColumnMeta(meta);
return;
}
const columns = await tableTypeApi.getColumns(tableConfig.selectedTable);
// 컬럼 입력 타입 정보 가져오기
const inputTypes = await tableTypeApi.getColumnInputTypes(tableConfig.selectedTable);
const inputTypeMap: Record<string, string> = {};
inputTypes.forEach((col: any) => {
inputTypeMap[col.columnName] = col.inputType;
});
tableColumnCache.set(cacheKey, {
columns,
inputTypes,
timestamp: Date.now(),
});
const labels: Record<string, string> = {};
const meta: Record<string, { webType?: string; codeCategory?: string; inputType?: string }> = {};
columns.forEach((col: any) => {
labels[col.columnName] = col.displayName || col.comment || col.columnName;
meta[col.columnName] = {
webType: col.webType,
codeCategory: col.codeCategory,
inputType: inputTypeMap[col.columnName],
};
});
setColumnLabels(labels);
setColumnMeta(meta);
} catch (error) {
console.error("컬럼 라벨 가져오기 실패:", error);
}
}, [tableConfig.selectedTable]);
// ========================================
// 테이블 라벨 가져오기
// ========================================
const fetchTableLabel = useCallback(async () => {
if (!tableConfig.selectedTable) return;
try {
const cacheKey = `table_info_${tableConfig.selectedTable}`;
const cached = tableInfoCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < TABLE_CACHE_TTL) {
const tables = cached.tables || [];
const tableInfo = tables.find((t: any) => t.tableName === tableConfig.selectedTable);
const label =
tableInfo?.displayName || (tableInfo as any)?.comment || tableInfo?.description || tableConfig.selectedTable;
setTableLabel(label);
return;
}
const tables = await tableTypeApi.getTables();
tableInfoCache.set(cacheKey, {
tables,
timestamp: Date.now(),
});
const tableInfo = tables.find((t: any) => t.tableName === tableConfig.selectedTable);
const label =
tableInfo?.displayName || (tableInfo as any)?.comment || tableInfo?.description || tableConfig.selectedTable;
setTableLabel(label);
} catch (error) {
console.error("테이블 라벨 가져오기 실패:", error);
}
}, [tableConfig.selectedTable]);
// ========================================
// 카테고리 값 매핑 로드
// ========================================
// 카테고리 컬럼 목록 추출 (useMemo로 최적화)
const categoryColumns = useMemo(() => {
const cols = Object.entries(columnMeta)
.filter(([_, meta]) => meta.inputType === "category")
.map(([columnName, _]) => columnName);
return cols;
}, [columnMeta]);
// 카테고리 매핑 로드 (columnMeta 변경 시 즉시 실행)
useEffect(() => {
const loadCategoryMappings = async () => {
if (!tableConfig.selectedTable) {
return;
}
if (categoryColumns.length === 0) {
setCategoryMappings({});
return;
}
try {
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
const apiClient = (await import("@/lib/api/client")).apiClient;
for (const columnName of categoryColumns) {
try {
// 🆕 엔티티 조인 컬럼 처리: "테이블명.컬럼명" 형태인지 확인
let targetTable = tableConfig.selectedTable;
let targetColumn = columnName;
if (columnName.includes(".")) {
const parts = columnName.split(".");
targetTable = parts[0]; // 조인된 테이블명 (예: item_info)
targetColumn = parts[1]; // 실제 컬럼명 (예: material)
console.log(`🔗 [TableList] 엔티티 조인 컬럼 감지:`, {
originalColumn: columnName,
targetTable,
targetColumn,
});
}
console.log(`📡 [TableList] API 호출 시작 [${columnName}]:`, {
url: `/table-categories/${targetTable}/${targetColumn}/values`,
});
const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values`);
console.log(`📡 [TableList] API 응답 [${columnName}]:`, {
success: response.data.success,
dataLength: response.data.data?.length,
rawData: response.data,
items: response.data.data,
});
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
const mapping: Record<string, { label: string; color?: string }> = {};
response.data.data.forEach((item: any) => {
// valueCode를 문자열로 변환하여 키로 사용
const key = String(item.valueCode);
mapping[key] = {
label: item.valueLabel,
color: item.color,
};
console.log(` 🔑 [${columnName}] "${key}" => "${item.valueLabel}" (색상: ${item.color})`);
});
if (Object.keys(mapping).length > 0) {
// 🆕 원래 컬럼명(item_info.material)으로 매핑 저장
mappings[columnName] = mapping;
console.log(`✅ [TableList] 카테고리 매핑 로드 완료 [${columnName}]:`, {
columnName,
mappingCount: Object.keys(mapping).length,
mappingKeys: Object.keys(mapping),
mapping,
});
} else {
console.warn(`⚠️ [TableList] 매핑 데이터가 비어있음 [${columnName}]`);
}
} else {
console.warn(`⚠️ [TableList] 카테고리 값 없음 [${columnName}]:`, {
success: response.data.success,
hasData: !!response.data.data,
isArray: Array.isArray(response.data.data),
response: response.data,
});
}
} catch (error: any) {
console.error(`❌ [TableList] 카테고리 값 로드 실패 [${columnName}]:`, {
error: error.message,
stack: error.stack,
response: error.response?.data,
status: error.response?.status,
});
}
}
// 🆕 엔티티 조인 컬럼의 inputType 정보 가져오기 및 카테고리 매핑 로드
// 1. "테이블명.컬럼명" 형태의 조인 컬럼 추출
const joinedColumns = tableConfig.columns
?.filter((col) => col.columnName?.includes("."))
.map((col) => col.columnName) || [];
// 2. additionalJoinInfo가 있는 컬럼도 추출 (예: item_code_material → item_info.material)
const additionalJoinColumns = tableConfig.columns
?.filter((col: any) => col.additionalJoinInfo?.referenceTable)
.map((col: any) => ({
columnName: col.columnName, // 예: item_code_material
referenceTable: col.additionalJoinInfo.referenceTable, // 예: item_info
// joinAlias에서 실제 컬럼명 추출 (item_code_material → material)
actualColumn: col.additionalJoinInfo.joinAlias?.replace(`${col.additionalJoinInfo.sourceColumn}_`, '') || col.columnName,
})) || [];
console.log("🔍 [TableList] additionalJoinInfo 컬럼:", additionalJoinColumns);
// 조인 테이블별로 그룹화
const joinedTableColumns: Record<string, { columnName: string; actualColumn: string }[]> = {};
// "테이블명.컬럼명" 형태 처리
for (const joinedColumn of joinedColumns) {
const parts = joinedColumn.split(".");
if (parts.length !== 2) continue;
const joinedTable = parts[0];
const joinedColumnName = parts[1];
if (!joinedTableColumns[joinedTable]) {
joinedTableColumns[joinedTable] = [];
}
joinedTableColumns[joinedTable].push({
columnName: joinedColumn,
actualColumn: joinedColumnName,
});
}
// additionalJoinInfo 형태 처리
for (const col of additionalJoinColumns) {
if (!joinedTableColumns[col.referenceTable]) {
joinedTableColumns[col.referenceTable] = [];
}
joinedTableColumns[col.referenceTable].push({
columnName: col.columnName, // 예: item_code_material
actualColumn: col.actualColumn, // 예: material
});
}
console.log("🔍 [TableList] 조인 테이블별 컬럼:", joinedTableColumns);
// 조인된 테이블별로 inputType 정보 가져오기
const newJoinedColumnMeta: Record<string, { webType?: string; codeCategory?: string; inputType?: string }> = {};
for (const [joinedTable, columns] of Object.entries(joinedTableColumns)) {
try {
// 조인 테이블의 컬럼 inputType 정보 가져오기 (이미 import된 tableTypeApi 사용)
const inputTypes = await tableTypeApi.getColumnInputTypes(joinedTable);
console.log(`📡 [TableList] 조인 테이블 inputType 로드 [${joinedTable}]:`, inputTypes);
for (const col of columns) {
const inputTypeInfo = inputTypes.find((it: any) => it.columnName === col.actualColumn);
// 컬럼명 그대로 저장 (item_code_material 또는 item_info.material)
newJoinedColumnMeta[col.columnName] = {
inputType: inputTypeInfo?.inputType,
};
console.log(` 🔗 [${col.columnName}] (실제: ${col.actualColumn}) inputType: ${inputTypeInfo?.inputType || "unknown"}`);
// inputType이 category인 경우 카테고리 매핑 로드
if (inputTypeInfo?.inputType === "category" && !mappings[col.columnName]) {
try {
console.log(`📡 [TableList] 조인 테이블 카테고리 로드 시도 [${col.columnName}]:`, {
url: `/table-categories/${joinedTable}/${col.actualColumn}/values`,
});
const response = await apiClient.get(`/table-categories/${joinedTable}/${col.actualColumn}/values`);
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
const mapping: Record<string, { label: string; color?: string }> = {};
response.data.data.forEach((item: any) => {
const key = String(item.valueCode);
mapping[key] = {
label: item.valueLabel,
color: item.color,
};
});
if (Object.keys(mapping).length > 0) {
mappings[col.columnName] = mapping;
console.log(`✅ [TableList] 조인 테이블 카테고리 매핑 로드 완료 [${col.columnName}]:`, {
mappingCount: Object.keys(mapping).length,
});
}
}
} catch (error) {
console.log(` [TableList] 조인 테이블 카테고리 없음 (${col.columnName})`);
}
}
}
} catch (error) {
console.error(`❌ [TableList] 조인 테이블 inputType 로드 실패 [${joinedTable}]:`, error);
}
}
// 조인 컬럼 메타데이터 상태 업데이트
if (Object.keys(newJoinedColumnMeta).length > 0) {
setJoinedColumnMeta(newJoinedColumnMeta);
console.log("✅ [TableList] 조인 컬럼 메타데이터 설정:", newJoinedColumnMeta);
}
console.log("📊 [TableList] 전체 카테고리 매핑 설정:", {
mappingsCount: Object.keys(mappings).length,
mappingsKeys: Object.keys(mappings),
mappings,
});
if (Object.keys(mappings).length > 0) {
setCategoryMappings(mappings);
setCategoryMappingsKey((prev) => prev + 1);
console.log("✅ [TableList] setCategoryMappings 호출 완료");
} else {
console.warn("⚠️ [TableList] 매핑이 비어있어 상태 업데이트 스킵");
}
} catch (error) {
console.error("❌ [TableList] 카테고리 매핑 로드 실패:", error);
}
};
loadCategoryMappings();
}, [tableConfig.selectedTable, categoryColumns.length, JSON.stringify(categoryColumns), JSON.stringify(tableConfig.columns)]); // 더 명확한 의존성
// ========================================
// 데이터 가져오기
// ========================================
const fetchTableDataInternal = useCallback(async () => {
console.log("📡 [TableList] fetchTableDataInternal 호출됨", {
tableName: tableConfig.selectedTable,
isDesignMode,
currentPage,
});
if (!tableConfig.selectedTable || isDesignMode) {
setData([]);
setTotalPages(0);
setTotalItems(0);
return;
}
setLoading(true);
setError(null);
try {
const page = tableConfig.pagination?.currentPage || currentPage;
const pageSize = localPageSize;
const sortBy = sortColumn || undefined;
const sortOrder = sortDirection;
const search = searchTerm || undefined;
// 🆕 연결 필터 값 가져오기 (분할 패널 내부일 때)
let linkedFilterValues: Record<string, any> = {};
let hasLinkedFiltersConfigured = false; // 연결 필터가 설정되어 있는지 여부
let hasSelectedLeftData = false; // 좌측에서 데이터가 선택되었는지 여부
console.log("🔍 [TableList] 분할 패널 컨텍스트 확인:", {
hasSplitPanelContext: !!splitPanelContext,
tableName: tableConfig.selectedTable,
selectedLeftData: splitPanelContext?.selectedLeftData,
linkedFilters: splitPanelContext?.linkedFilters,
});
if (splitPanelContext) {
// 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지)
const linkedFiltersConfig = splitPanelContext.linkedFilters || [];
hasLinkedFiltersConfigured = linkedFiltersConfig.some(
(filter) => filter.targetColumn?.startsWith(tableConfig.selectedTable + ".") ||
filter.targetColumn === tableConfig.selectedTable
);
// 좌측 데이터 선택 여부 확인
hasSelectedLeftData = splitPanelContext.selectedLeftData &&
Object.keys(splitPanelContext.selectedLeftData).length > 0;
const allLinkedFilters = splitPanelContext.getLinkedFilterValues();
console.log("🔗 [TableList] 연결 필터 원본:", allLinkedFilters);
// 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서)
for (const [key, value] of Object.entries(allLinkedFilters)) {
if (key.includes(".")) {
const [tableName, columnName] = key.split(".");
if (tableName === tableConfig.selectedTable) {
linkedFilterValues[columnName] = value;
hasLinkedFiltersConfigured = true; // 이 테이블에 대한 필터가 있음
}
} else {
// 테이블명 없이 컬럼명만 있는 경우 그대로 사용
linkedFilterValues[key] = value;
}
}
if (Object.keys(linkedFilterValues).length > 0) {
console.log("🔗 [TableList] 연결 필터 적용:", linkedFilterValues);
}
}
// 🆕 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우
// → 빈 데이터 표시 (모든 데이터를 보여주지 않음)
if (hasLinkedFiltersConfigured && !hasSelectedLeftData) {
console.log("⚠️ [TableList] 연결 필터 설정됨 but 좌측 데이터 미선택 → 빈 데이터 표시");
setData([]);
setTotalItems(0);
setLoading(false);
return;
}
// 검색 필터와 연결 필터 병합
const filters = {
...(Object.keys(searchValues).length > 0 ? searchValues : {}),
...linkedFilterValues,
};
const hasFilters = Object.keys(filters).length > 0;
// 🆕 REST API 데이터 소스 처리
const isRestApiTable = tableConfig.selectedTable.startsWith("restapi_") || tableConfig.selectedTable.startsWith("_restapi_");
let response: any;
if (isRestApiTable) {
// REST API 데이터 소스인 경우
const connectionIdMatch = tableConfig.selectedTable.match(/restapi_(\d+)/);
const connectionId = connectionIdMatch ? parseInt(connectionIdMatch[1]) : null;
if (connectionId) {
console.log("🌐 [TableList] REST API 데이터 소스 호출", { connectionId });
// REST API 연결 정보 가져오기 및 데이터 조회
const { ExternalRestApiConnectionAPI } = await import("@/lib/api/externalRestApiConnection");
const restApiData = await ExternalRestApiConnectionAPI.fetchData(
connectionId,
undefined, // endpoint - 연결 정보에서 가져옴
"response", // jsonPath - 기본값 response
);
response = {
data: restApiData.rows || [],
total: restApiData.total || restApiData.rows?.length || 0,
totalPages: Math.ceil((restApiData.total || restApiData.rows?.length || 0) / pageSize),
};
console.log("✅ [TableList] REST API 응답:", {
dataLength: response.data.length,
total: response.total
});
} else {
throw new Error("REST API 연결 ID를 찾을 수 없습니다.");
}
} else {
// 일반 DB 테이블인 경우 (기존 로직)
const entityJoinColumns = (tableConfig.columns || [])
.filter((col) => col.additionalJoinInfo)
.map((col) => ({
sourceTable: col.additionalJoinInfo!.sourceTable,
sourceColumn: col.additionalJoinInfo!.sourceColumn,
joinAlias: col.additionalJoinInfo!.joinAlias,
referenceTable: col.additionalJoinInfo!.referenceTable,
}));
// 🎯 화면별 엔티티 표시 설정 수집
const screenEntityConfigs: Record<string, any> = {};
(tableConfig.columns || [])
.filter((col) => col.entityDisplayConfig && col.entityDisplayConfig.displayColumns?.length > 0)
.forEach((col) => {
screenEntityConfigs[col.columnName] = {
displayColumns: col.entityDisplayConfig!.displayColumns,
separator: col.entityDisplayConfig!.separator || " - ",
sourceTable: col.entityDisplayConfig!.sourceTable || tableConfig.selectedTable,
joinTable: col.entityDisplayConfig!.joinTable,
};
});
console.log("🎯 [TableList] 화면별 엔티티 설정:", screenEntityConfigs);
// 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외)
let excludeFilterParam: any = undefined;
if (tableConfig.excludeFilter?.enabled) {
const excludeConfig = tableConfig.excludeFilter;
let filterValue: any = undefined;
// 필터 값 소스에 따라 값 가져오기 (우선순위: formData > URL > 분할패널)
if (excludeConfig.filterColumn && excludeConfig.filterValueField) {
const fieldName = excludeConfig.filterValueField;
// 1순위: props로 전달받은 formData에서 값 가져오기 (모달에서 사용)
if (propFormData && propFormData[fieldName]) {
filterValue = propFormData[fieldName];
console.log("🔗 [TableList] formData에서 excludeFilter 값 가져오기:", {
field: fieldName,
value: filterValue,
});
}
// 2순위: URL 파라미터에서 값 가져오기
else if (typeof window !== "undefined") {
const urlParams = new URLSearchParams(window.location.search);
filterValue = urlParams.get(fieldName);
if (filterValue) {
console.log("🔗 [TableList] URL에서 excludeFilter 값 가져오기:", {
field: fieldName,
value: filterValue,
});
}
}
// 3순위: 분할 패널 부모 데이터에서 값 가져오기
if (!filterValue && splitPanelContext?.selectedLeftData) {
filterValue = splitPanelContext.selectedLeftData[fieldName];
if (filterValue) {
console.log("🔗 [TableList] 분할패널에서 excludeFilter 값 가져오기:", {
field: fieldName,
value: filterValue,
});
}
}
}
if (filterValue || !excludeConfig.filterColumn) {
excludeFilterParam = {
enabled: true,
referenceTable: excludeConfig.referenceTable,
referenceColumn: excludeConfig.referenceColumn,
sourceColumn: excludeConfig.sourceColumn,
filterColumn: excludeConfig.filterColumn,
filterValue: filterValue,
};
console.log("🚫 [TableList] 제외 필터 적용:", excludeFilterParam);
}
}
// 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원)
response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
page,
size: pageSize,
sortBy,
sortOrder,
search: hasFilters ? filters : undefined,
enableEntityJoin: true,
additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined,
screenEntityConfigs: Object.keys(screenEntityConfigs).length > 0 ? screenEntityConfigs : undefined, // 🎯 화면별 엔티티 설정 전달
dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달
excludeFilter: excludeFilterParam, // 🆕 제외 필터 전달
});
// 실제 데이터의 item_number만 추출하여 중복 확인
const itemNumbers = (response.data || []).map((item: any) => item.item_number);
const uniqueItemNumbers = [...new Set(itemNumbers)];
// console.log("✅ [TableList] API 응답 받음");
// console.log(` - dataLength: ${response.data?.length || 0}`);
// console.log(` - total: ${response.total}`);
// console.log(` - itemNumbers: ${JSON.stringify(itemNumbers)}`);
// console.log(` - uniqueItemNumbers: ${JSON.stringify(uniqueItemNumbers)}`);
// console.log(` - isDuplicated: ${itemNumbers.length !== uniqueItemNumbers.length}`);
// 🔍 디버그: attachments 컬럼 데이터 확인
if (response.data && response.data.length > 0) {
const firstRow = response.data[0];
if ('attachments' in firstRow) {
console.log("📎 [TableList] attachments 데이터 확인:", {
tableName: tableConfig.selectedTable,
firstRowAttachments: firstRow.attachments,
type: typeof firstRow.attachments,
isArray: Array.isArray(firstRow.attachments),
});
}
}
setData(response.data || []);
setTotalPages(response.totalPages || 0);
setTotalItems(response.total || 0);
setError(null);
// 🎯 Store에 필터 조건 저장 (엑셀 다운로드용)
// tableConfig.columns 사용 (visibleColumns는 이 시점에서 아직 정의되지 않을 수 있음)
const cols = (tableConfig.columns || []).filter((col) => col.visible !== false);
const labels: Record<string, string> = {};
cols.forEach((col) => {
labels[col.columnName] = columnLabels[col.columnName] || col.columnName;
});
tableDisplayStore.setTableData(
tableConfig.selectedTable,
response.data || [],
cols.map((col) => col.columnName),
sortBy,
sortOrder,
{
filterConditions: filters,
searchTerm: search,
visibleColumns: cols.map((col) => col.columnName),
columnLabels: labels,
currentPage: page,
pageSize: pageSize,
totalItems: response.total || 0,
}
);
}
} catch (err: any) {
console.error("데이터 가져오기 실패:", err);
setData([]);
setTotalPages(0);
setTotalItems(0);
setError(err.message || "데이터를 불러오지 못했습니다.");
} finally {
setLoading(false);
}
}, [
tableConfig.selectedTable,
tableConfig.pagination?.currentPage,
tableConfig.columns,
currentPage,
localPageSize,
sortColumn,
sortDirection,
searchTerm,
searchValues,
isDesignMode,
splitPanelContext?.selectedLeftData, // 🆕 연결 필터 변경 시 재조회
]);
const fetchTableDataDebounced = useCallback(
(...args: Parameters<typeof fetchTableDataInternal>) => {
const key = `fetchData_${tableConfig.selectedTable}_${currentPage}_${sortColumn}_${sortDirection}`;
return debouncedApiCall(key, fetchTableDataInternal, 300)(...args);
},
[fetchTableDataInternal, tableConfig.selectedTable, currentPage, sortColumn, sortDirection],
);
// ========================================
// 이벤트 핸들러
// ========================================
const handlePageChange = (newPage: number) => {
if (newPage < 1 || newPage > totalPages) return;
setCurrentPage(newPage);
if (tableConfig.pagination) {
tableConfig.pagination.currentPage = newPage;
}
if (onConfigChange) {
onConfigChange({ ...tableConfig, pagination: { ...tableConfig.pagination, currentPage: newPage } });
}
};
const handleSort = (column: string) => {
console.log("🔄 정렬 클릭:", { column, currentSortColumn: sortColumn, currentSortDirection: sortDirection });
let newSortColumn = column;
let newSortDirection: "asc" | "desc" = "asc";
if (sortColumn === column) {
newSortDirection = sortDirection === "asc" ? "desc" : "asc";
setSortDirection(newSortDirection);
} else {
setSortColumn(column);
setSortDirection("asc");
newSortColumn = column;
newSortDirection = "asc";
}
// 🎯 정렬 상태를 localStorage에 저장 (사용자별)
if (tableConfig.selectedTable && userId) {
const storageKey = `table_sort_state_${tableConfig.selectedTable}_${userId}`;
try {
localStorage.setItem(storageKey, JSON.stringify({
column: newSortColumn,
direction: newSortDirection
}));
console.log("💾 정렬 상태 저장:", { column: newSortColumn, direction: newSortDirection });
} catch (error) {
console.error("❌ 정렬 상태 저장 실패:", error);
}
}
console.log("📊 새로운 정렬 정보:", { newSortColumn, newSortDirection });
console.log("🔍 onSelectedRowsChange 존재 여부:", !!onSelectedRowsChange);
// 정렬 변경 시 선택 정보와 함께 정렬 정보도 전달
if (onSelectedRowsChange) {
const selectedRowsData = data.filter((row, index) => selectedRows.has(getRowKey(row, index)));
// 1단계: 데이터를 정렬
const sortedData = [...data].sort((a, b) => {
const aVal = a[newSortColumn];
const bVal = b[newSortColumn];
// null/undefined 처리
if (aVal == null && bVal == null) return 0;
if (aVal == null) return 1;
if (bVal == null) return -1;
// 숫자 비교 (문자열이어도 숫자로 변환 가능하면 숫자로 비교)
const aNum = Number(aVal);
const bNum = Number(bVal);
// 둘 다 유효한 숫자이고, 원본 값이 빈 문자열이 아닌 경우
if (!isNaN(aNum) && !isNaN(bNum) && aVal !== "" && bVal !== "") {
return newSortDirection === "desc" ? bNum - aNum : aNum - bNum;
}
// 문자열 비교 (대소문자 구분 없이, 숫자 포함 문자열도 자연스럽게 정렬)
const aStr = String(aVal).toLowerCase();
const bStr = String(bVal).toLowerCase();
// 자연스러운 정렬 (숫자 포함 문자열)
const comparison = aStr.localeCompare(bStr, undefined, { numeric: true, sensitivity: "base" });
return newSortDirection === "desc" ? -comparison : comparison;
});
// 2단계: 정렬된 데이터를 컬럼 순서대로 재정렬
// tableConfig.columns 사용 (visibleColumns는 이 시점에서 아직 정의되지 않을 수 있음)
const cols = (tableConfig.columns || []).filter((col) => col.visible !== false);
const reorderedData = sortedData.map((row: any) => {
const reordered: any = {};
cols.forEach((col) => {
if (col.columnName in row) {
reordered[col.columnName] = row[col.columnName];
}
});
// 나머지 컬럼 추가
Object.keys(row).forEach((key) => {
if (!(key in reordered)) {
reordered[key] = row[key];
}
});
return reordered;
});
console.log("✅ 정렬 정보 전달:", {
selectedRowsCount: selectedRows.size,
selectedRowsDataCount: selectedRowsData.length,
sortBy: newSortColumn,
sortOrder: newSortDirection,
columnOrder: columnOrder.length > 0 ? columnOrder : undefined,
tableDisplayDataCount: reorderedData.length,
firstRowAfterSort: reorderedData[0]?.[newSortColumn],
lastRowAfterSort: reorderedData[reorderedData.length - 1]?.[newSortColumn],
});
onSelectedRowsChange(
Array.from(selectedRows),
selectedRowsData,
newSortColumn,
newSortDirection,
columnOrder.length > 0 ? columnOrder : undefined,
reorderedData,
);
// 전역 저장소에 정렬된 데이터 저장
if (tableConfig.selectedTable) {
const cleanColumnOrder = (
columnOrder.length > 0 ? columnOrder : cols.map((c) => c.columnName)
).filter((col) => col !== "__checkbox__");
// 컬럼 라벨 정보도 함께 저장
const labels: Record<string, string> = {};
cols.forEach((col) => {
labels[col.columnName] = columnLabels[col.columnName] || col.columnName;
});
tableDisplayStore.setTableData(
tableConfig.selectedTable,
reorderedData,
cleanColumnOrder,
newSortColumn,
newSortDirection,
{
filterConditions: Object.keys(searchValues).length > 0 ? searchValues : undefined,
searchTerm: searchTerm || undefined,
visibleColumns: cols.map((col) => col.columnName),
columnLabels: labels,
currentPage: currentPage,
pageSize: localPageSize,
totalItems: totalItems,
},
);
}
} else {
console.warn("⚠️ onSelectedRowsChange 콜백이 없습니다!");
}
};
const handleSearchValueChange = (columnName: string, value: any) => {
setSearchValues((prev) => ({ ...prev, [columnName]: value }));
};
const handleAdvancedSearch = () => {
setCurrentPage(1);
fetchTableDataDebounced();
};
const handleClearAdvancedFilters = useCallback(() => {
console.log("🔄 필터 초기화 시작", { 이전searchValues: searchValues });
// 상태를 초기화하고 useEffect로 데이터 새로고침
setSearchValues({});
setCurrentPage(1);
// 강제로 데이터 새로고침 트리거
setRefreshTrigger((prev) => prev + 1);
}, [searchValues]);
const handleRefresh = () => {
fetchTableDataDebounced();
};
const getRowKey = (row: any, index: number) => {
return row.id || row.uuid || `row-${index}`;
};
const handleRowSelection = (rowKey: string, checked: boolean) => {
const newSelectedRows = new Set(selectedRows);
if (checked) {
newSelectedRows.add(rowKey);
} else {
newSelectedRows.delete(rowKey);
}
setSelectedRows(newSelectedRows);
const selectedRowsData = data.filter((row, index) => newSelectedRows.has(getRowKey(row, index)));
if (onSelectedRowsChange) {
onSelectedRowsChange(Array.from(newSelectedRows), selectedRowsData, sortColumn || undefined, sortDirection);
}
if (onFormDataChange) {
onFormDataChange({
selectedRows: Array.from(newSelectedRows),
selectedRowsData,
});
}
// 🆕 modalDataStore에 선택된 데이터 자동 저장 (테이블명 기반 dataSourceId)
if (tableConfig.selectedTable && selectedRowsData.length > 0) {
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
const modalItems = selectedRowsData.map((row, idx) => ({
id: getRowKey(row, idx),
originalData: row,
additionalData: {},
}));
useModalDataStore.getState().setData(tableConfig.selectedTable!, modalItems);
console.log("✅ [TableList] modalDataStore에 데이터 저장:", {
dataSourceId: tableConfig.selectedTable,
count: modalItems.length,
});
});
} else if (tableConfig.selectedTable && selectedRowsData.length === 0) {
// 선택 해제 시 데이터 제거
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
useModalDataStore.getState().clearData(tableConfig.selectedTable!);
console.log("🗑️ [TableList] modalDataStore 데이터 제거:", tableConfig.selectedTable);
});
}
const allRowsSelected = filteredData.every((row, index) => newSelectedRows.has(getRowKey(row, index)));
setIsAllSelected(allRowsSelected && filteredData.length > 0);
};
const handleSelectAll = (checked: boolean) => {
if (checked) {
const allKeys = filteredData.map((row, index) => getRowKey(row, index));
const newSelectedRows = new Set(allKeys);
setSelectedRows(newSelectedRows);
setIsAllSelected(true);
if (onSelectedRowsChange) {
onSelectedRowsChange(Array.from(newSelectedRows), filteredData, sortColumn || undefined, sortDirection);
}
if (onFormDataChange) {
onFormDataChange({
selectedRows: Array.from(newSelectedRows),
selectedRowsData: filteredData,
});
}
// 🆕 modalDataStore에 전체 데이터 저장
if (tableConfig.selectedTable && filteredData.length > 0) {
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
const modalItems = filteredData.map((row, idx) => ({
id: getRowKey(row, idx),
originalData: row,
additionalData: {},
}));
useModalDataStore.getState().setData(tableConfig.selectedTable!, modalItems);
console.log("✅ [TableList] modalDataStore에 전체 데이터 저장:", {
dataSourceId: tableConfig.selectedTable,
count: modalItems.length,
});
});
}
} else {
setSelectedRows(new Set());
setIsAllSelected(false);
if (onSelectedRowsChange) {
onSelectedRowsChange([], [], sortColumn || undefined, sortDirection);
}
if (onFormDataChange) {
onFormDataChange({ selectedRows: [], selectedRowsData: [] });
}
// 🆕 modalDataStore 데이터 제거
if (tableConfig.selectedTable) {
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
useModalDataStore.getState().clearData(tableConfig.selectedTable!);
console.log("🗑️ [TableList] modalDataStore 전체 데이터 제거:", tableConfig.selectedTable);
});
}
}
};
const handleRowClick = (row: any, index: number, e: React.MouseEvent) => {
// 체크박스 클릭은 무시 (이미 handleRowSelection에서 처리됨)
const target = e.target as HTMLElement;
if (target.closest('input[type="checkbox"]')) {
return;
}
// 행 선택/해제 토글
const rowKey = getRowKey(row, index);
const isCurrentlySelected = selectedRows.has(rowKey);
handleRowSelection(rowKey, !isCurrentlySelected);
// 🆕 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우)
// disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달)
if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
if (!isCurrentlySelected) {
// 선택된 경우: 데이터 저장
splitPanelContext.setSelectedLeftData(row);
console.log("🔗 [TableList] 분할 패널 좌측 데이터 저장:", {
row,
parentDataMapping: splitPanelContext.parentDataMapping,
});
} else {
// 선택 해제된 경우: 데이터 초기화
splitPanelContext.setSelectedLeftData(null);
console.log("🔗 [TableList] 분할 패널 좌측 데이터 초기화");
}
}
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;
// 🆕 편집 불가 컬럼 체크
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>(() => {});
// 🆕 각 컬럼의 고유값 목록 계산
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
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<string>();
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<string>();
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<string, { type: string; label?: string }> = {};
// 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<string, { value: number | string; label: string }> = {};
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<HTMLDivElement>) => {
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<string>)])
),
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<string, Set<string>> = {};
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<string>();
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<HTMLTableCellElement>, 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<HTMLTableCellElement>, 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<HTMLTableCellElement>, 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<HTMLTableRowElement>, 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<HTMLTableRowElement>, 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<HTMLTableRowElement>, 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 = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>${tableLabel || tableConfig.selectedTable || "데이터"}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif; padding: 20px; }
h1 { font-size: 18px; margin-bottom: 10px; text-align: center; }
.info { font-size: 12px; color: #666; margin-bottom: 20px; text-align: center; }
table { width: 100%; border-collapse: collapse; font-size: 11px; }
th, td { border: 1px solid #ddd; padding: 6px 8px; text-align: left; }
th { background-color: #f5f5f5; font-weight: 600; }
tr:nth-child(even) { background-color: #fafafa; }
.number { text-align: right; }
@media print {
body { padding: 0; }
table { page-break-inside: auto; }
tr { page-break-inside: avoid; page-break-after: auto; }
}
</style>
</head>
<body>
<h1>${tableLabel || tableConfig.selectedTable || "데이터 목록"}</h1>
<div class="info">
출력일: ${new Date().toLocaleDateString("ko-KR")} |
${exportData.length}
</div>
<table>
<thead>
<tr>
${exportColumns.map((col) => `<th>${columnLabels[col.columnName] || col.columnName}</th>`).join("")}
</tr>
</thead>
<tbody>
${exportData.map((row) => `
<tr>
${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 `<td class="${isNumeric ? "number" : ""}">${value ?? ""}</td>`;
}).join("")}
</tr>
`).join("")}
</tbody>
</table>
</body>
</html>
`;
// 새 창에서 인쇄
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<HTMLInputElement>) => {
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) => {
e.stopPropagation();
onClick?.();
};
// ========================================
// 컬럼 관련 (visibleColumns는 상단에서 정의됨)
// ========================================
// 🆕 visibleColumns가 변경될 때마다 현재 컬럼 순서를 부모에게 전달
const lastColumnOrderRef = useRef<string>("");
useEffect(() => {
// console.log("🔍 [컬럼 순서 전달 useEffect] 실행됨:", {
// hasCallback: !!onSelectedRowsChange,
// visibleColumnsLength: visibleColumns.length,
// visibleColumnsNames: visibleColumns.map((c) => c.columnName),
// });
if (!onSelectedRowsChange) {
// console.warn("⚠️ onSelectedRowsChange 콜백이 없습니다!");
return;
}
if (visibleColumns.length === 0) {
// console.warn("⚠️ visibleColumns가 비어있습니다!");
return;
}
const currentColumnOrder = visibleColumns.map((col) => col.columnName).filter((name) => name !== "__checkbox__"); // 체크박스 컬럼 제외
// console.log("🔍 [컬럼 순서] 체크박스 제외 후:", currentColumnOrder);
// 컬럼 순서가 실제로 변경되었을 때만 전달 (무한 루프 방지)
const columnOrderString = currentColumnOrder.join(",");
// console.log("🔍 [컬럼 순서] 비교:", {
// current: columnOrderString,
// last: lastColumnOrderRef.current,
// isDifferent: columnOrderString !== lastColumnOrderRef.current,
// });
if (columnOrderString === lastColumnOrderRef.current) {
// console.log("⏭️ 컬럼 순서 변경 없음, 전달 스킵");
return;
}
lastColumnOrderRef.current = columnOrderString;
// console.log("📊 현재 화면 컬럼 순서 전달:", currentColumnOrder);
// 선택된 행 데이터 가져오기
const selectedRowsData = data.filter((row, index) => selectedRows.has(getRowKey(row, index)));
// 화면에 표시된 데이터를 컬럼 순서대로 재정렬
const reorderedData = data.map((row: any) => {
const reordered: any = {};
visibleColumns.forEach((col) => {
if (col.columnName in row) {
reordered[col.columnName] = row[col.columnName];
}
});
// 나머지 컬럼 추가
Object.keys(row).forEach((key) => {
if (!(key in reordered)) {
reordered[key] = row[key];
}
});
return reordered;
});
onSelectedRowsChange(
Array.from(selectedRows),
selectedRowsData,
sortColumn,
sortDirection,
currentColumnOrder,
reorderedData,
);
}, [visibleColumns.length, visibleColumns.map((c) => c.columnName).join(",")]); // 의존성 단순화
// 🆕 키보드 네비게이션 핸들러 (visibleColumns 정의 후에 배치)
const handleTableKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
// 편집 중일 때는 테이블 키보드 핸들러 무시 (편집 입력에서 처리)
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__") {
// 🆕 편집 불가 컬럼 체크
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];
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__") {
// 🆕 편집 불가 컬럼 체크
if (column.editable === false) {
toast.warning(`'${column.displayName || column.columnName}' 컬럼은 편집할 수 없습니다.`);
break;
}
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;
switch (column.format) {
case "date":
return 120;
case "number":
case "currency":
return 100;
case "boolean":
return 80;
default:
return 150;
}
};
const renderCheckboxHeader = () => {
if (!tableConfig.checkbox?.selectAll) return null;
return <Checkbox checked={isAllSelected} onCheckedChange={handleSelectAll} aria-label="전체 선택" />;
};
const renderCheckboxCell = (row: any, index: number) => {
const rowKey = getRowKey(row, index);
const isChecked = selectedRows.has(rowKey);
return (
<Checkbox
checked={isChecked}
onCheckedChange={(checked) => handleRowSelection(rowKey, checked as boolean)}
aria-label={`${index + 1} 선택`}
/>
);
};
const formatCellValue = useCallback(
(value: any, column: ColumnConfig, rowData?: Record<string, any>) => {
// 🎯 엔티티 컬럼 표시 설정이 있는 경우 - value가 null이어도 rowData에서 조합 가능
// 이 체크를 가장 먼저 수행 (null 체크보다 앞에)
if (column.entityDisplayConfig && rowData) {
const displayColumns = column.entityDisplayConfig.displayColumns || (column.entityDisplayConfig as any).selectedColumns;
const separator = column.entityDisplayConfig.separator;
if (displayColumns && displayColumns.length > 0) {
// 선택된 컬럼들의 값을 구분자로 조합
const values = displayColumns
.map((colName: string) => {
// 1. 먼저 직접 컬럼명으로 시도 (기본 테이블 컬럼인 경우)
let cellValue = rowData[colName];
// 2. 없으면 ${sourceColumn}_${colName} 형식으로 시도 (조인 테이블 컬럼인 경우)
if (cellValue === null || cellValue === undefined) {
const joinedKey = `${column.columnName}_${colName}`;
cellValue = rowData[joinedKey];
}
if (cellValue === null || cellValue === undefined) return "";
return String(cellValue);
})
.filter((v: string) => v !== ""); // 빈 값 제외
const result = values.join(separator || " - ");
if (result) {
return result; // 결과가 있으면 반환
}
// 결과가 비어있으면 아래로 계속 진행 (원래 값 사용)
}
}
// value가 null/undefined면 "-" 반환
if (value === null || value === undefined) return "-";
// 🎯 writer 컬럼 자동 변환: user_id -> user_name
if (column.columnName === "writer" && rowData && rowData.writer_name) {
return rowData.writer_name;
}
// 🆕 메인 테이블 메타 또는 조인 테이블 메타에서 정보 가져오기
const meta = columnMeta[column.columnName] || joinedColumnMeta[column.columnName];
// inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선)
const inputType = meta?.inputType || column.inputType;
// 🖼️ 이미지 타입: 작은 썸네일 표시
if (inputType === "image" && value && typeof value === "string") {
const imageUrl = getFullImageUrl(value);
return (
<img
src={imageUrl}
alt="이미지"
className="h-10 w-10 rounded object-cover"
onError={(e) => {
e.currentTarget.src =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Crect width='40' height='40' fill='%23f3f4f6'/%3E%3C/svg%3E";
}}
/>
);
}
// 📎 첨부파일 타입: 파일 아이콘과 개수 표시
// 컬럼명이 'attachments'를 포함하거나, inputType이 file/attachment인 경우
const isAttachmentColumn =
inputType === "file" ||
inputType === "attachment" ||
column.columnName === "attachments" ||
column.columnName?.toLowerCase().includes("attachment") ||
column.columnName?.toLowerCase().includes("file");
if (isAttachmentColumn) {
// JSONB 배열 또는 JSON 문자열 파싱
let files: any[] = [];
try {
if (typeof value === "string" && value.trim()) {
const parsed = JSON.parse(value);
files = Array.isArray(parsed) ? parsed : [];
} else if (Array.isArray(value)) {
files = value;
} else if (value && typeof value === "object") {
// 단일 객체인 경우 배열로 변환
files = [value];
}
} catch (e) {
// 파싱 실패 시 빈 배열
console.warn("📎 [TableList] 첨부파일 파싱 실패:", { columnName: column.columnName, value, error: e });
}
if (!files || files.length === 0) {
return <span className="text-muted-foreground text-xs">-</span>;
}
// 파일 개수와 아이콘 표시
const { Paperclip } = require("lucide-react");
return (
<div className="flex items-center gap-1 text-sm">
<Paperclip className="h-4 w-4 text-gray-500" />
<span className="text-blue-600 font-medium">{files.length}</span>
<span className="text-muted-foreground text-xs"></span>
</div>
);
}
// 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원, 다중 값 지원)
if (inputType === "category") {
if (!value) return "";
// 🆕 엔티티 조인 컬럼의 경우 여러 형태로 매핑 찾기
// 1. 원래 컬럼명 (item_info.material)
// 2. 점(.) 뒤의 컬럼명만 (material)
let mapping = categoryMappings[column.columnName];
if (!mapping && column.columnName.includes(".")) {
const simpleColumnName = column.columnName.split(".").pop();
if (simpleColumnName) {
mapping = categoryMappings[simpleColumnName];
}
}
const { Badge } = require("@/components/ui/badge");
// 다중 값 처리: 콤마로 구분된 값들을 분리
const valueStr = String(value);
const values = valueStr.includes(",")
? valueStr.split(",").map(v => v.trim()).filter(v => v)
: [valueStr];
// 단일 값인 경우 (기존 로직)
if (values.length === 1) {
const categoryData = mapping?.[values[0]];
const displayLabel = categoryData?.label || values[0];
const displayColor = categoryData?.color || "#64748b";
if (displayColor === "none") {
return <span className="text-sm">{displayLabel}</span>;
}
return (
<Badge
style={{
backgroundColor: displayColor,
borderColor: displayColor,
}}
className="text-white"
>
{displayLabel}
</Badge>
);
}
// 다중 값인 경우: 여러 배지 렌더링
return (
<div className="flex flex-wrap gap-1">
{values.map((val, idx) => {
const categoryData = mapping?.[val];
const displayLabel = categoryData?.label || val;
const displayColor = categoryData?.color || "#64748b";
if (displayColor === "none") {
return (
<span key={idx} className="text-sm">
{displayLabel}
{idx < values.length - 1 && ", "}
</span>
);
}
return (
<Badge
key={idx}
style={{
backgroundColor: displayColor,
borderColor: displayColor,
}}
className="text-white"
>
{displayLabel}
</Badge>
);
})}
</div>
);
}
// 코드 타입: 코드 값 → 코드명 변환
if (inputType === "code" && meta?.codeCategory && value) {
try {
// optimizedConvertCode(categoryCode, codeValue) 순서 주의!
const convertedValue = optimizedConvertCode(meta.codeCategory, value);
// 변환에 성공했으면 변환된 코드명 반환
if (convertedValue && convertedValue !== value) {
return convertedValue;
}
} catch (error) {
console.error(`코드 변환 실패: ${column.columnName}, 카테고리: ${meta.codeCategory}, 값: ${value}`, error);
}
// 변환 실패 시 원본 코드 값 반환
return String(value);
}
// 날짜 타입 포맷팅 (yyyy-mm-dd)
if (inputType === "date" || inputType === "datetime") {
if (value) {
try {
const date = new Date(value);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
} catch {
return String(value);
}
}
return "-";
}
// 숫자 타입 포맷팅 (천단위 구분자 설정 확인)
if (inputType === "number" || inputType === "decimal") {
if (value !== null && value !== undefined && value !== "") {
const numValue = typeof value === "string" ? parseFloat(value) : value;
if (!isNaN(numValue)) {
// thousandSeparator가 false가 아닌 경우(기본값 true) 천단위 구분자 적용
if (column.thousandSeparator !== false) {
return numValue.toLocaleString("ko-KR");
}
return String(numValue);
}
}
return String(value);
}
switch (column.format) {
case "number":
if (value !== null && value !== undefined && value !== "") {
const numValue = typeof value === "string" ? parseFloat(value) : value;
if (!isNaN(numValue)) {
// thousandSeparator가 false가 아닌 경우(기본값 true) 천단위 구분자 적용
if (column.thousandSeparator !== false) {
return numValue.toLocaleString("ko-KR");
}
return String(numValue);
}
}
return String(value);
case "date":
if (value) {
try {
const date = new Date(value);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
} catch {
return value;
}
}
return "-";
case "currency":
if (typeof value === "number") {
// thousandSeparator가 false가 아닌 경우(기본값 true) 천단위 구분자 적용
if (column.thousandSeparator !== false) {
return `${value.toLocaleString()}`;
}
return `${value}`;
}
return value;
case "boolean":
return value ? "예" : "아니오";
default:
return String(value);
}
},
[columnMeta, joinedColumnMeta, optimizedConvertCode, categoryMappings],
);
// ========================================
// useEffect 훅
// ========================================
// 필터 설정 localStorage 키 생성 (화면별로 독립적)
const filterSettingKey = useMemo(() => {
if (!tableConfig.selectedTable) return null;
return screenId
? `tableList_filterSettings_${tableConfig.selectedTable}_screen_${screenId}`
: `tableList_filterSettings_${tableConfig.selectedTable}`;
}, [tableConfig.selectedTable, screenId]);
// 그룹 설정 localStorage 키 생성 (화면별로 독립적)
const groupSettingKey = useMemo(() => {
if (!tableConfig.selectedTable) return null;
return screenId
? `tableList_groupSettings_${tableConfig.selectedTable}_screen_${screenId}`
: `tableList_groupSettings_${tableConfig.selectedTable}`;
}, [tableConfig.selectedTable, screenId]);
// 저장된 필터 설정 불러오기
useEffect(() => {
if (!filterSettingKey || visibleColumns.length === 0) return;
try {
const saved = localStorage.getItem(filterSettingKey);
if (saved) {
const savedFilters = JSON.parse(saved);
setVisibleFilterColumns(new Set(savedFilters));
} else {
// 초기값: 빈 Set (아무것도 선택 안 함)
setVisibleFilterColumns(new Set());
}
} catch (error) {
console.error("필터 설정 불러오기 실패:", error);
setVisibleFilterColumns(new Set());
}
}, [filterSettingKey, visibleColumns]);
// 필터 설정 저장
const saveFilterSettings = useCallback(() => {
if (!filterSettingKey) return;
try {
localStorage.setItem(filterSettingKey, JSON.stringify(Array.from(visibleFilterColumns)));
setIsFilterSettingOpen(false);
toast.success("검색 필터 설정이 저장되었습니다");
// 검색 값 초기화
setSearchValues({});
} catch (error) {
console.error("필터 설정 저장 실패:", error);
toast.error("설정 저장에 실패했습니다");
}
}, [filterSettingKey, visibleFilterColumns]);
// 필터 컬럼 토글
const toggleFilterVisibility = useCallback((columnName: string) => {
setVisibleFilterColumns((prev) => {
const newSet = new Set(prev);
if (newSet.has(columnName)) {
newSet.delete(columnName);
} else {
newSet.add(columnName);
}
return newSet;
});
}, []);
// 전체 선택/해제
const toggleAllFilters = useCallback(() => {
const filterableColumns = visibleColumns.filter((col) => col.columnName !== "__checkbox__");
const columnNames = filterableColumns.map((col) => col.columnName);
if (visibleFilterColumns.size === columnNames.length) {
// 전체 해제
setVisibleFilterColumns(new Set());
} else {
// 전체 선택
setVisibleFilterColumns(new Set(columnNames));
}
}, [visibleFilterColumns, visibleColumns]);
// 표시할 필터 목록 (선택된 컬럼만)
const activeFilters = useMemo(() => {
return visibleColumns
.filter((col) => col.columnName !== "__checkbox__" && visibleFilterColumns.has(col.columnName))
.map((col) => ({
columnName: col.columnName,
label: columnLabels[col.columnName] || col.displayName || col.columnName,
type: col.format || "text",
}));
}, [visibleColumns, visibleFilterColumns, columnLabels]);
// 그룹 설정 자동 저장 (localStorage)
useEffect(() => {
if (!groupSettingKey) return;
try {
localStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns));
} catch (error) {
console.error("그룹 설정 저장 실패:", error);
}
}, [groupSettingKey, groupByColumns]);
// 그룹 컬럼 토글
const toggleGroupColumn = useCallback((columnName: string) => {
setGroupByColumns((prev) => {
if (prev.includes(columnName)) {
return prev.filter((col) => col !== columnName);
} else {
return [...prev, columnName];
}
});
}, []);
// 사용자 옵션 저장 핸들러
const handleTableOptionsSave = useCallback(
(config: {
columns: Array<{ columnName: string; label: string; visible: boolean; width?: number; frozen?: boolean }>;
showGridLines: boolean;
viewMode: "table" | "card" | "grouped-card";
}) => {
// 컬럼 순서 업데이트
const newColumnOrder = config.columns.map((col) => col.columnName);
setColumnOrder(newColumnOrder);
// 컬럼 너비 업데이트
const newWidths: Record<string, number> = {};
config.columns.forEach((col) => {
if (col.width) {
newWidths[col.columnName] = col.width;
}
});
setColumnWidths(newWidths);
// 틀고정 컬럼 업데이트
const newFrozenColumns = config.columns.filter((col) => col.frozen).map((col) => col.columnName);
setFrozenColumns(newFrozenColumns);
// 그리드선 표시 업데이트
setShowGridLines(config.showGridLines);
// 보기 모드 업데이트
setViewMode(config.viewMode);
// 컬럼 표시/숨기기 업데이트
const newDisplayColumns = displayColumns.map((col) => {
const configCol = config.columns.find((c) => c.columnName === col.columnName);
if (configCol) {
return { ...col, visible: configCol.visible };
}
return col;
});
setDisplayColumns(newDisplayColumns);
toast.success("테이블 옵션이 저장되었습니다");
},
[displayColumns],
);
// 그룹 펼치기/접기 토글
const toggleGroupCollapse = useCallback((groupKey: string) => {
setCollapsedGroups((prev) => {
const newSet = new Set(prev);
if (newSet.has(groupKey)) {
newSet.delete(groupKey);
} else {
newSet.add(groupKey);
}
return newSet;
});
}, []);
// 그룹 해제
const clearGrouping = useCallback(() => {
setGroupByColumns([]);
setCollapsedGroups(new Set());
if (groupSettingKey) {
localStorage.removeItem(groupSettingKey);
}
toast.success("그룹이 해제되었습니다");
}, [groupSettingKey]);
// 데이터 그룹화
const groupedData = useMemo((): GroupedData[] => {
if (groupByColumns.length === 0 || filteredData.length === 0) return [];
const grouped = new Map<string, any[]>();
filteredData.forEach((item) => {
// 그룹 키 생성: "통화:KRW > 단위:EA"
const keyParts = groupByColumns.map((col) => {
// 카테고리/엔티티 타입인 경우 _name 필드 사용
const inputType = columnMeta?.[col]?.inputType;
let displayValue = item[col];
if (inputType === 'category' || inputType === 'entity' || inputType === 'code') {
// _name 필드가 있으면 사용 (예: division_name, writer_name)
const nameField = `${col}_name`;
if (item[nameField] !== undefined && item[nameField] !== null) {
displayValue = item[nameField];
}
}
const label = columnLabels[col] || col;
return `${label}:${displayValue !== null && displayValue !== undefined ? displayValue : "-"}`;
});
const groupKey = keyParts.join(" > ");
if (!grouped.has(groupKey)) {
grouped.set(groupKey, []);
}
grouped.get(groupKey)!.push(item);
});
return Array.from(grouped.entries()).map(([groupKey, items]) => {
const groupValues: Record<string, any> = {};
groupByColumns.forEach((col) => {
groupValues[col] = items[0]?.[col];
});
// 🆕 그룹별 소계 계산
const groupSummary: Record<string, { sum: number; avg: number; count: number }> = {};
// 숫자형 컬럼에 대해 소계 계산
(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, tableConfig.columns]);
// 저장된 그룹 설정 불러오기
useEffect(() => {
if (!groupSettingKey || visibleColumns.length === 0) return;
try {
const saved = localStorage.getItem(groupSettingKey);
if (saved) {
const savedGroups = JSON.parse(saved);
setGroupByColumns(savedGroups);
}
} catch (error) {
console.error("그룹 설정 불러오기 실패:", error);
}
}, [groupSettingKey, visibleColumns]);
useEffect(() => {
fetchColumnLabels();
fetchTableLabel();
}, [tableConfig.selectedTable, fetchColumnLabels, fetchTableLabel]);
useEffect(() => {
// console.log("🔍 [TableList] useEffect 실행 - 데이터 조회 트리거", {
// isDesignMode,
// tableName: tableConfig.selectedTable,
// currentPage,
// sortColumn,
// sortDirection,
// });
if (!isDesignMode && tableConfig.selectedTable) {
fetchTableDataDebounced();
}
}, [
tableConfig.selectedTable,
currentPage,
localPageSize,
sortColumn,
sortDirection,
searchTerm,
searchValues, // 필터 값 변경 시에도 데이터 새로고침
refreshKey,
refreshTrigger, // 강제 새로고침 트리거
isDesignMode,
splitPanelContext?.selectedLeftData, // 🆕 좌측 데이터 선택 변경 시 데이터 새로고침
// fetchTableDataDebounced 제거: useCallback 재생성으로 인한 무한 루프 방지
]);
useEffect(() => {
if (tableConfig.refreshInterval && !isDesignMode) {
const interval = setInterval(() => {
fetchTableDataDebounced();
}, tableConfig.refreshInterval * 1000);
return () => clearInterval(interval);
}
}, [tableConfig.refreshInterval, isDesignMode]);
// 🆕 전역 테이블 새로고침 이벤트 리스너
useEffect(() => {
const handleRefreshTable = () => {
if (tableConfig.selectedTable && !isDesignMode) {
console.log("🔄 [TableList] refreshTable 이벤트 수신 - 데이터 새로고침");
setRefreshTrigger((prev) => prev + 1);
}
};
window.addEventListener("refreshTable", handleRefreshTable);
return () => {
window.removeEventListener("refreshTable", handleRefreshTable);
};
}, [tableConfig.selectedTable, isDesignMode]);
// 🎯 컬럼 너비 자동 계산 (내용 기반)
const calculateOptimalColumnWidth = useCallback((columnName: string, displayName: string): number => {
// 기본 너비 설정
const MIN_WIDTH = 100;
const MAX_WIDTH = 400;
const PADDING = 48; // 좌우 패딩 + 여유 공간
const HEADER_PADDING = 60; // 헤더 추가 여유 (정렬 아이콘 등)
// 헤더 텍스트 너비 계산 (대략 8px per character)
const headerWidth = (displayName?.length || columnName.length) * 10 + HEADER_PADDING;
// 데이터 셀 너비 계산 (상위 50개 샘플링)
const sampleSize = Math.min(50, data.length);
let maxDataWidth = headerWidth;
for (let i = 0; i < sampleSize; i++) {
const cellValue = data[i]?.[columnName];
if (cellValue !== null && cellValue !== undefined) {
const cellText = String(cellValue);
// 숫자는 좁게, 텍스트는 넓게 계산
const isNumber = !isNaN(Number(cellValue)) && cellValue !== "";
const charWidth = isNumber ? 8 : 9;
const cellWidth = cellText.length * charWidth + PADDING;
maxDataWidth = Math.max(maxDataWidth, cellWidth);
}
}
// 최소/최대 범위 내로 제한
return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, Math.ceil(maxDataWidth)));
}, [data]);
// 🎯 localStorage에서 컬럼 너비 불러오기 및 초기 계산
useEffect(() => {
if (!hasInitializedWidths.current && visibleColumns.length > 0 && data.length > 0) {
const timer = setTimeout(() => {
const storageKey = tableConfig.selectedTable && userId
? `table_column_widths_${tableConfig.selectedTable}_${userId}`
: null;
// 1. localStorage에서 저장된 너비 불러오기
let savedWidths: Record<string, number> = {};
if (storageKey) {
try {
const saved = localStorage.getItem(storageKey);
if (saved) {
savedWidths = JSON.parse(saved);
}
} catch (error) {
console.error("컬럼 너비 불러오기 실패:", error);
}
}
// 2. 자동 계산 또는 저장된 너비 적용
const newWidths: Record<string, number> = {};
let hasAnyWidth = false;
visibleColumns.forEach((column) => {
// 체크박스 컬럼은 제외 (고정 48px)
if (column.columnName === "__checkbox__") return;
// 저장된 너비가 있으면 우선 사용
if (savedWidths[column.columnName]) {
newWidths[column.columnName] = savedWidths[column.columnName];
hasAnyWidth = true;
} else {
// 저장된 너비가 없으면 자동 계산
const optimalWidth = calculateOptimalColumnWidth(
column.columnName,
columnLabels[column.columnName] || column.displayName
);
newWidths[column.columnName] = optimalWidth;
hasAnyWidth = true;
}
});
if (hasAnyWidth) {
setColumnWidths(newWidths);
hasInitializedWidths.current = true;
}
}, 150); // DOM 렌더링 대기
return () => clearTimeout(timer);
}
}, [visibleColumns, data, tableConfig.selectedTable, userId, calculateOptimalColumnWidth, columnLabels]);
// ========================================
// 페이지네이션 JSX
// ========================================
const paginationJSX = useMemo(() => {
if (!tableConfig.pagination?.enabled || isDesignMode) return null;
return (
<div className="border-border bg-background relative flex h-14 w-full flex-shrink-0 items-center justify-center border-t-2 px-4 sm:h-[60px] sm:px-6">
{/* 중앙 페이지네이션 컨트롤 */}
<div className="flex items-center gap-2 sm:gap-4">
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(1)}
disabled={currentPage === 1 || loading}
className="h-8 w-8 p-0 sm:h-9 sm:w-auto sm:px-3"
>
<ChevronsLeft className="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1 || loading}
className="h-8 w-8 p-0 sm:h-9 sm:w-auto sm:px-3"
>
<ChevronLeft className="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
<span className="text-foreground min-w-[60px] text-center text-xs font-medium sm:min-w-[80px] sm:text-sm">
{currentPage} / {totalPages || 1}
</span>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= totalPages || loading}
className="h-8 w-8 p-0 sm:h-9 sm:w-auto sm:px-3"
>
<ChevronRight className="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(totalPages)}
disabled={currentPage >= totalPages || loading}
className="h-8 w-8 p-0 sm:h-9 sm:w-auto sm:px-3"
>
<ChevronsRight className="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
</div>
{/* 우측 버튼 그룹 */}
<div className="absolute right-2 flex items-center gap-1 sm:right-6">
{/* 🆕 내보내기 버튼 (Excel/PDF) */}
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 sm:h-9 sm:w-auto sm:px-3"
title="내보내기"
>
<Download className="h-3 w-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-56 p-2" align="end">
<div className="flex flex-col gap-1">
<div className="text-muted-foreground px-2 py-1 text-xs font-medium">Excel</div>
<Button
variant="ghost"
size="sm"
className="justify-start text-xs"
onClick={() => exportToExcel(true)}
>
<FileSpreadsheet className="mr-2 h-3 w-3 text-green-600" />
Excel
</Button>
<Button
variant="ghost"
size="sm"
className="justify-start text-xs"
onClick={() => exportToExcel(false)}
disabled={selectedRows.size === 0}
>
<FileSpreadsheet className="mr-2 h-3 w-3 text-green-600" />
({selectedRows.size})
</Button>
<div className="border-border my-1 border-t" />
<div className="text-muted-foreground px-2 py-1 text-xs font-medium">PDF/</div>
<Button
variant="ghost"
size="sm"
className="justify-start text-xs"
onClick={() => exportToPdf(true)}
>
<FileText className="mr-2 h-3 w-3 text-red-600" />
PDF
</Button>
<Button
variant="ghost"
size="sm"
className="justify-start text-xs"
onClick={() => exportToPdf(false)}
disabled={selectedRows.size === 0}
>
<FileText className="mr-2 h-3 w-3 text-red-600" />
({selectedRows.size})
</Button>
</div>
</PopoverContent>
</Popover>
{/* 새로고침 버튼 (하단 페이지네이션) */}
{(tableConfig.toolbar?.showPaginationRefresh ?? true) && (
<Button
variant="ghost"
size="sm"
onClick={handleRefresh}
disabled={loading}
className="h-8 w-8 p-0 sm:h-9 sm:w-auto sm:px-3"
>
<RefreshCw className={cn("h-3 w-3", loading && "animate-spin")} />
</Button>
)}
</div>
</div>
);
}, [tableConfig.pagination, tableConfig.toolbar?.showPaginationRefresh, isDesignMode, currentPage, totalPages, totalItems, loading, selectedRows.size, exportToExcel, exportToPdf]);
// ========================================
// 렌더링
// ========================================
const domProps = {
onClick: handleClick,
onDragStart: isDesignMode ? onDragStart : undefined,
onDragEnd: isDesignMode ? onDragEnd : undefined,
draggable: isDesignMode,
className: cn("w-full h-full", className, isDesignMode && "cursor-move"), // customer-item-mapping과 동일
style: componentStyle,
};
// 카드 모드
if (tableConfig.displayMode === "card" && !isDesignMode) {
return (
<div {...domProps}>
<CardModeRenderer
data={data}
loading={loading}
error={error}
cardConfig={tableConfig.cardConfig}
onRowClick={handleRowClick}
/>
{paginationJSX}
</div>
);
}
// SingleTableWithSticky 모드
if (tableConfig.stickyHeader && !isDesignMode) {
return (
<div {...domProps}>
{/* 필터 헤더는 TableSearchWidget으로 이동 */}
{/* 그룹 표시 배지 */}
{groupByColumns.length > 0 && (
<div className="border-border bg-muted/30 border-b px-4 py-1.5 sm:px-6">
<div className="flex items-center gap-2 text-xs sm:text-sm">
<span className="text-muted-foreground">:</span>
<div className="flex flex-wrap items-center gap-2">
{groupByColumns.map((col, idx) => (
<span key={col} className="flex items-center">
{idx > 0 && <span className="text-muted-foreground mx-1"></span>}
<span className="bg-primary/10 text-primary rounded px-2 py-1 text-xs font-medium">
{columnLabels[col] || col}
</span>
</span>
))}
</div>
<button
onClick={clearGrouping}
className="hover:bg-destructive/10 text-destructive ml-auto rounded p-1"
title="그룹 해제"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
)}
<div style={{ flex: 1, overflow: "hidden" }}>
<SingleTableWithSticky
data={data}
columns={visibleColumns}
loading={loading}
error={error}
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={handleSort}
tableConfig={tableConfig}
isDesignMode={isDesignMode}
isAllSelected={isAllSelected}
handleSelectAll={handleSelectAll}
handleRowClick={handleRowClick}
columnLabels={columnLabels}
renderCheckboxHeader={renderCheckboxHeader}
renderCheckboxCell={renderCheckboxCell}
formatCellValue={(value: any, format?: string, columnName?: string, rowData?: Record<string, any>) => {
const column = visibleColumns.find((c) => c.columnName === columnName);
return column ? formatCellValue(value, column, rowData) : String(value);
}}
getColumnWidth={getColumnWidth}
containerWidth={calculatedWidth}
/>
</div>
{paginationJSX}
</div>
);
}
// 일반 테이블 모드 (네이티브 HTML 테이블)
return (
<>
<div {...domProps}>
{/* 필터 헤더는 TableSearchWidget으로 이동 */}
{/* 🆕 DevExpress 스타일 기능 툴바 */}
<div className="border-border bg-muted/20 flex flex-wrap items-center gap-1 border-b px-2 py-1.5 sm:gap-2 sm:px-4 sm:py-2">
{/* 편집 모드 토글 */}
{(tableConfig.toolbar?.showEditMode ?? true) && (
<div className="flex items-center gap-1 border-r border-border pr-2">
<Button
variant={editMode === "batch" ? "default" : "ghost"}
size="sm"
onClick={() => setEditMode(editMode === "batch" ? "immediate" : "batch")}
className="h-7 text-xs"
title="배치 편집 모드 (Ctrl+B)"
>
<Edit className="mr-1 h-3 w-3" />
{editMode === "batch" ? "배치 모드" : "즉시 저장"}
</Button>
</div>
)}
{/* 내보내기 버튼들 */}
{((tableConfig.toolbar?.showExcel ?? true) || (tableConfig.toolbar?.showPdf ?? true)) && (
<div className="flex items-center gap-1 border-r border-border pr-2">
{(tableConfig.toolbar?.showExcel ?? true) && (
<Button
variant="ghost"
size="sm"
onClick={() => exportToExcel(true)}
className="h-7 text-xs"
title="Excel 내보내기"
>
<FileSpreadsheet className="mr-1 h-3 w-3 text-green-600" />
Excel
</Button>
)}
{(tableConfig.toolbar?.showPdf ?? true) && (
<Button
variant="ghost"
size="sm"
onClick={() => exportToPdf(true)}
className="h-7 text-xs"
title="PDF 내보내기"
>
<FileText className="mr-1 h-3 w-3 text-red-600" />
PDF
</Button>
)}
</div>
)}
{/* 복사 버튼 */}
{(tableConfig.toolbar?.showCopy ?? true) && (
<div className="flex items-center gap-1 border-r border-border pr-2">
<Button
variant="ghost"
size="sm"
onClick={handleCopy}
disabled={selectedRows.size === 0 && !focusedCell}
className="h-7 text-xs"
title="복사 (Ctrl+C)"
>
<Copy className="mr-1 h-3 w-3" />
</Button>
</div>
)}
{/* 선택 정보 */}
{selectedRows.size > 0 && (
<div className="flex items-center gap-1 border-r border-border pr-2">
<span className="bg-primary/10 text-primary rounded px-2 py-0.5 text-xs">
{selectedRows.size}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedRows(new Set())}
className="h-6 w-6 p-0"
title="선택 해제"
>
<X className="h-3 w-3" />
</Button>
</div>
)}
{/* 🆕 통합 검색 패널 */}
{(tableConfig.toolbar?.showSearch ?? true) && (
<div className="flex items-center gap-1 border-r border-border pr-2">
{isSearchPanelOpen ? (
<div className="flex items-center gap-1">
<input
type="text"
value={globalSearchTerm}
onChange={(e) => 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 && (
<span className="text-muted-foreground text-xs">
{searchHighlights.size}
</span>
)}
<Button
variant="ghost"
size="sm"
onClick={goToPrevSearchResult}
disabled={searchHighlights.size === 0}
className="h-6 w-6 p-0"
title="이전 (Shift+F3)"
>
<ChevronLeft className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={goToNextSearchResult}
disabled={searchHighlights.size === 0}
className="h-6 w-6 p-0"
title="다음 (F3)"
>
<ChevronRight className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={clearGlobalSearch}
className="h-6 w-6 p-0"
title="닫기 (Esc)"
>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => setIsSearchPanelOpen(true)}
className="h-7 text-xs"
title="통합 검색 (Ctrl+F)"
>
<Filter className="mr-1 h-3 w-3" />
</Button>
)}
</div>
)}
{/* 🆕 Filter Builder (고급 필터) 버튼 */}
{(tableConfig.toolbar?.showFilter ?? true) && (
<div className="flex items-center gap-1 border-r border-border pr-2">
<Button
variant={activeFilterCount > 0 ? "default" : "ghost"}
size="sm"
onClick={() => setIsFilterBuilderOpen(true)}
className="h-7 text-xs"
title="고급 필터"
>
<Layers className="mr-1 h-3 w-3" />
{activeFilterCount > 0 && (
<span className="ml-1 rounded-full bg-white/20 px-1.5 text-[10px]">
{activeFilterCount}
</span>
)}
</Button>
{activeFilterCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={clearFilterBuilder}
className="h-6 w-6 p-0"
title="필터 초기화"
>
<X className="h-3 w-3" />
</Button>
)}
</div>
)}
{/* 새로고침 */}
{(tableConfig.toolbar?.showRefresh ?? true) && (
<div className="ml-auto flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={handleRefresh}
disabled={loading}
className="h-7 text-xs"
title="새로고침"
>
<RefreshCw className={cn("mr-1 h-3 w-3", loading && "animate-spin")} />
</Button>
</div>
)}
</div>
{/* 🆕 배치 편집 툴바 */}
{(editMode === "batch" || pendingChanges.size > 0) && (
<div className="border-border bg-amber-50 dark:bg-amber-950/30 flex items-center justify-between border-b px-4 py-2 sm:px-6">
<div className="flex items-center gap-3 text-xs sm:text-sm">
<span className="bg-amber-100 dark:bg-amber-900/50 text-amber-700 dark:text-amber-300 rounded px-2 py-1 font-medium">
</span>
{pendingChanges.size > 0 && (
<span className="text-amber-600 dark:text-amber-400">
{pendingChanges.size}
</span>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={cancelBatchChanges}
disabled={pendingChanges.size === 0}
className="h-7 text-xs"
>
</Button>
<Button
variant="default"
size="sm"
onClick={saveBatchChanges}
disabled={pendingChanges.size === 0}
className="h-7 text-xs"
>
({pendingChanges.size})
</Button>
</div>
</div>
)}
{/* 그룹 표시 배지 */}
{groupByColumns.length > 0 && (
<div className="border-border bg-muted/30 border-b px-4 py-2 sm:px-6">
<div className="flex items-center gap-2 text-xs sm:text-sm">
<span className="text-muted-foreground">:</span>
<div className="flex flex-wrap items-center gap-2">
{groupByColumns.map((col, idx) => (
<span key={col} className="flex items-center">
{idx > 0 && <span className="text-muted-foreground mx-1"></span>}
<span className="bg-primary/10 text-primary rounded px-2 py-1 text-xs font-medium">
{columnLabels[col] || col}
</span>
</span>
))}
</div>
<button
onClick={clearGrouping}
className="hover:bg-destructive/10 text-destructive ml-auto rounded p-1"
title="그룹 해제"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
)}
{/* 테이블 컨테이너 - 키보드 네비게이션 지원 */}
<div
ref={tableContainerRef}
className="flex flex-1 flex-col focus:outline-none"
style={{
width: "100%",
height: "100%",
overflow: "hidden",
}}
tabIndex={0}
onKeyDown={handleTableKeyDown}
role="grid"
aria-label="데이터 테이블"
>
{/* 스크롤 영역 */}
<div
ref={scrollContainerRef}
className="bg-background flex-1"
style={{
position: "relative",
width: "100%",
height: "100%",
overflow: "auto",
}}
onScroll={handleVirtualScroll}
>
{/* 테이블 */}
<table
className={cn("table-mobile-fixed", !showGridLines && "hide-grid")}
style={{
borderCollapse: "collapse",
width: "100%",
tableLayout: "fixed",
}}
>
{/* 헤더 (sticky) */}
<thead
className="sticky z-50"
style={{
position: "sticky",
top: 0,
zIndex: 50,
backgroundColor: "hsl(var(--background))",
}}
>
{/* 🆕 Multi-Level Headers (Column Bands) */}
{columnBandsInfo?.hasBands && (
<tr
className="border-primary/10 bg-muted/70 h-8 border-b sm:h-10"
style={{ backgroundColor: "hsl(var(--muted) / 0.7)" }}
>
{visibleColumns.map((column, colIdx) => {
// 이 컬럼이 속한 band 찾기
const band = columnBandsInfo.bands.find(
(b) => b.columns.includes(column.columnName) && b.startIndex === colIdx
);
// band의 첫 번째 컬럼인 경우에만 렌더링
if (band) {
return (
<th
key={`band-${column.columnName}`}
colSpan={band.colSpan}
className="border-r border-primary/10 px-2 py-1 text-center text-xs font-semibold sm:px-4 sm:text-sm"
>
{band.caption}
</th>
);
}
// band에 속하지 않은 컬럼 (개별 표시)
const isInAnyBand = columnBandsInfo.bands.some(
(b) => b.columns.includes(column.columnName)
);
if (!isInAnyBand) {
return (
<th
key={`noband-${column.columnName}`}
rowSpan={2}
className="border-r border-primary/10 px-2 py-1 text-center text-xs font-semibold sm:px-4 sm:text-sm"
>
{columnLabels[column.columnName] || column.columnName}
</th>
);
}
// band의 중간 컬럼은 렌더링하지 않음
return null;
})}
</tr>
)}
<tr
className="border-primary/20 bg-muted h-10 border-b-2 sm:h-12"
style={{
backgroundColor: "hsl(var(--muted))",
}}
>
{visibleColumns.map((column, columnIndex) => {
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;
}
}
// 🆕 Column Reordering 상태
const isColumnDragging = draggedColumnIndex === columnIndex;
const isColumnDropTarget = dropTargetColumnIndex === columnIndex;
return (
<th
key={column.columnName}
ref={(el) => (columnRefs.current[column.columnName] = el)}
className={cn(
"text-foreground/90 relative h-8 overflow-hidden text-xs font-bold text-ellipsis whitespace-nowrap select-none sm:h-10 sm:text-sm",
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-2",
column.sortable !== false &&
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",
width:
column.columnName === "__checkbox__"
? "48px"
: columnWidth
? `${columnWidth}px`
: undefined,
minWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
userSelect: "none",
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__") {
handleSort(column.columnName);
}
}}
>
{column.columnName === "__checkbox__" ? (
renderCheckboxHeader()
) : (
<div style={{ display: "flex", alignItems: "center", gap: "4px", justifyContent: "center" }}>
{/* 🆕 편집 불가 컬럼 표시 */}
{column.editable === false && (
<span title="편집 불가">
<Lock className="h-3 w-3 text-muted-foreground" />
</span>
)}
<span>{columnLabels[column.columnName] || column.displayName}</span>
{column.sortable !== false && sortColumn === column.columnName && (
<span>{sortDirection === "asc" ? "↑" : "↓"}</span>
)}
{/* 🆕 헤더 필터 버튼 */}
{tableConfig.headerFilter !== false && columnUniqueValues[column.columnName]?.length > 0 && (
<Popover
open={openFilterColumn === column.columnName}
onOpenChange={(open) => setOpenFilterColumn(open ? column.columnName : null)}
>
<PopoverTrigger asChild>
<button
onClick={(e) => {
e.stopPropagation();
setOpenFilterColumn(openFilterColumn === column.columnName ? null : column.columnName);
}}
className={cn(
"ml-1 p-0.5 rounded hover:bg-primary/20 transition-colors",
headerFilters[column.columnName]?.size > 0 && "text-primary bg-primary/10"
)}
title="필터"
>
<Filter className="h-3 w-3" />
</button>
</PopoverTrigger>
<PopoverContent
className="w-48 p-2"
align="start"
onClick={(e) => e.stopPropagation()}
>
<div className="space-y-2">
<div className="flex items-center justify-between border-b pb-2">
<span className="text-xs font-medium">: {columnLabels[column.columnName] || column.displayName}</span>
{headerFilters[column.columnName]?.size > 0 && (
<button
onClick={() => clearHeaderFilter(column.columnName)}
className="text-xs text-destructive hover:underline"
>
</button>
)}
</div>
<div className="max-h-48 overflow-y-auto space-y-1">
{columnUniqueValues[column.columnName]?.slice(0, 50).map((val) => {
const isSelected = headerFilters[column.columnName]?.has(val);
return (
<div
key={val}
className={cn(
"flex items-center gap-2 px-2 py-1 rounded cursor-pointer text-xs hover:bg-muted",
isSelected && "bg-primary/10"
)}
onClick={() => toggleHeaderFilter(column.columnName, val)}
>
<div className={cn(
"w-4 h-4 rounded border flex items-center justify-center",
isSelected ? "bg-primary border-primary" : "border-input"
)}>
{isSelected && <Check className="h-3 w-3 text-primary-foreground" />}
</div>
<span className="truncate">{val || "(빈 값)"}</span>
</div>
);
})}
{(columnUniqueValues[column.columnName]?.length || 0) > 50 && (
<div className="text-xs text-muted-foreground px-2 py-1">
... {(columnUniqueValues[column.columnName]?.length || 0) - 50}
</div>
)}
</div>
</div>
</PopoverContent>
</Popover>
)}
</div>
)}
{/* 리사이즈 핸들 (체크박스 제외) */}
{columnIndex < visibleColumns.length - 1 && column.columnName !== "__checkbox__" && (
<div
className="absolute top-0 right-0 z-20 h-full w-2 cursor-col-resize hover:bg-blue-500"
style={{ marginRight: "-4px", paddingLeft: "4px", paddingRight: "4px" }}
onClick={(e) => e.stopPropagation()} // 정렬 클릭 방지
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
const thElement = columnRefs.current[column.columnName];
if (!thElement) return;
isResizing.current = true;
const startX = e.clientX;
const startWidth = columnWidth || thElement.offsetWidth;
// 드래그 중 텍스트 선택 방지
document.body.style.userSelect = "none";
document.body.style.cursor = "col-resize";
const handleMouseMove = (moveEvent: MouseEvent) => {
moveEvent.preventDefault();
const diff = moveEvent.clientX - startX;
const newWidth = Math.max(80, startWidth + diff);
// 직접 DOM 스타일 변경 (리렌더링 없음)
if (thElement) {
thElement.style.width = `${newWidth}px`;
}
};
const handleMouseUp = () => {
// 최종 너비를 state에 저장
if (thElement) {
const finalWidth = Math.max(80, thElement.offsetWidth);
setColumnWidths((prev) => {
const newWidths = { ...prev, [column.columnName]: finalWidth };
// 🎯 localStorage에 컬럼 너비 저장 (사용자별)
if (tableConfig.selectedTable && userId) {
const storageKey = `table_column_widths_${tableConfig.selectedTable}_${userId}`;
try {
localStorage.setItem(storageKey, JSON.stringify(newWidths));
} catch (error) {
console.error("컬럼 너비 저장 실패:", error);
}
}
return newWidths;
});
}
// 텍스트 선택 복원
document.body.style.userSelect = "";
document.body.style.cursor = "";
// 약간의 지연 후 리사이즈 플래그 해제 (클릭 이벤트가 먼저 처리되지 않도록)
setTimeout(() => {
isResizing.current = false;
}, 100);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}}
/>
)}
</th>
);
})}
</tr>
</thead>
{/* 바디 (스크롤) */}
<tbody key={`tbody-${categoryMappingsKey}`} style={{ position: "relative" }}>
{loading ? (
<tr>
<td colSpan={visibleColumns.length} className="p-12 text-center">
<div className="flex flex-col items-center gap-3">
<RefreshCw className="text-muted-foreground h-8 w-8 animate-spin" />
<div className="text-muted-foreground text-sm font-medium"> ...</div>
</div>
</td>
</tr>
) : error ? (
<tr>
<td colSpan={visibleColumns.length} className="p-12 text-center">
<div className="flex flex-col items-center gap-3">
<div className="text-destructive text-sm font-medium"> </div>
<div className="text-muted-foreground text-xs">{error}</div>
</div>
</td>
</tr>
) : data.length === 0 ? (
<tr>
<td colSpan={visibleColumns.length} className="p-12 text-center">
<div className="flex flex-col items-center gap-3">
<TableIcon className="text-muted-foreground/50 h-12 w-12" />
<div className="text-muted-foreground text-sm font-medium"> </div>
<div className="text-muted-foreground text-xs">
</div>
</div>
</td>
</tr>
) : groupByColumns.length > 0 && groupedData.length > 0 ? (
// 그룹화된 렌더링
groupedData.map((group) => {
const isCollapsed = collapsedGroups.has(group.groupKey);
return (
<React.Fragment key={group.groupKey}>
{/* 그룹 헤더 */}
<tr>
<td
colSpan={visibleColumns.length}
className="bg-muted/50 border-border sticky top-[48px] z-[5] border-b"
style={{ top: "48px" }}
>
<div
className="hover:bg-muted flex cursor-pointer items-center gap-3 p-3"
onClick={() => toggleGroupCollapse(group.groupKey)}
>
{isCollapsed ? (
<ChevronRight className="h-4 w-4 flex-shrink-0" />
) : (
<ChevronDown className="h-4 w-4 flex-shrink-0" />
)}
<span className="flex-1 text-sm font-medium">{group.groupKey}</span>
<span className="text-muted-foreground text-xs">({group.count})</span>
</div>
</td>
</tr>
{/* 그룹 데이터 */}
{!isCollapsed &&
group.items.map((row, index) => (
<tr
key={index}
className={cn(
"bg-background hover:bg-muted/50 cursor-pointer border-b transition-colors",
)}
onClick={(e) => handleRowClick(row, index, e)}
>
{visibleColumns.map((column, colIndex) => {
const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName;
const cellValue = row[mappedColumnName];
const meta = columnMeta[column.columnName];
const inputType = meta?.inputType || column.inputType;
const isNumeric = inputType === "number" || inputType === "decimal";
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;
}
}
return (
<td
key={column.columnName}
className={cn(
"text-foreground overflow-hidden text-xs text-ellipsis whitespace-nowrap font-normal sm:text-sm",
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-1.5",
isFrozen && "bg-background sticky z-10 shadow-[2px_0_4px_rgba(0,0,0,0.05)]",
)}
style={{
textAlign:
column.columnName === "__checkbox__"
? "center"
: isNumeric
? "right"
: column.align || "left",
width:
column.columnName === "__checkbox__"
? "48px"
: `${100 / visibleColumns.length}%`,
minWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
...(isFrozen && { left: `${leftPosition}px` }),
}}
>
{column.columnName === "__checkbox__"
? renderCheckboxCell(row, index)
: formatCellValue(cellValue, column, row)}
</td>
);
})}
</tr>
))}
{/* 🆕 그룹별 소계 행 */}
{!isCollapsed && group.summary && Object.keys(group.summary).length > 0 && (
<tr className="bg-muted/30 border-b-2 border-primary/20">
{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 (
<td key={column.columnName} className="px-2 py-1 sm:px-4">
<span className="text-muted-foreground text-xs font-medium"></span>
</td>
);
}
if (colIndex === 0 && column.columnName !== "__checkbox__") {
return (
<td key={column.columnName} className="px-2 py-1 sm:px-4">
<span className="text-muted-foreground text-xs font-medium"> ({group.count})</span>
</td>
);
}
if (summary) {
return (
<td
key={column.columnName}
className="px-2 py-1 text-xs font-semibold sm:px-4"
style={{ textAlign: isNumeric ? "right" : "left" }}
>
{summary.sum.toLocaleString()}
</td>
);
}
return <td key={column.columnName} className="px-2 py-1 sm:px-4" />;
})}
</tr>
)}
</React.Fragment>
);
})
) : (
// 일반 렌더링 (그룹 없음) - 키보드 네비게이션 지원
<>
{/* 🆕 Virtual Scrolling: Top Spacer */}
{isVirtualScrollEnabled && virtualScrollInfo.topSpacerHeight > 0 && (
<tr style={{ height: `${virtualScrollInfo.topSpacerHeight}px` }}>
<td colSpan={visibleColumns.length} />
</tr>
)}
{/* 데이터 행 렌더링 */}
{(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 (
<tr
key={index}
className={cn(
"bg-background hover:bg-muted/50 cursor-pointer border-b transition-colors",
isRowSelected && "bg-primary/10 hover:bg-primary/15",
isRowFocused && "ring-1 ring-primary/50 ring-inset",
// 🆕 Drag & Drop 스타일
isDragEnabled && "cursor-grab active:cursor-grabbing",
isDragging && "opacity-50 bg-muted",
isDropTarget && "border-t-2 border-t-primary",
)}
onClick={(e) => 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 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;
}
}
return (
<td
key={column.columnName}
data-row={index}
data-col={colIndex}
className={cn(
"text-foreground overflow-hidden text-xs text-ellipsis whitespace-nowrap font-normal sm:text-sm",
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-1.5",
isFrozen && "bg-background sticky z-10 shadow-[2px_0_4px_rgba(0,0,0,0.05)]",
// 🆕 포커스된 셀 스타일
isCellFocused && !editingCell && "ring-2 ring-primary ring-inset bg-primary/5",
// 🆕 편집 중인 셀 스타일
editingCell?.rowIndex === index && editingCell?.colIndex === colIndex && "p-0",
// 🆕 배치 편집: 수정된 셀 스타일 (노란 배경)
isModified && !cellValidationError && "bg-amber-100 dark:bg-amber-900/40",
// 🆕 유효성 에러: 빨간 테두리 및 배경
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}
style={{
textAlign:
column.columnName === "__checkbox__"
? "center"
: isNumeric
? "right"
: column.align || "left",
width: column.columnName === "__checkbox__" ? "48px" : `${100 / visibleColumns.length}%`,
minWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
...(isFrozen && { left: `${leftPosition}px` }),
}}
onClick={(e) => 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 (
<select
ref={editInputRef as any}
value={editingValue}
onChange={(e) => 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"
autoFocus
>
<option value=""></option>
{selectOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
}
// 일반 입력 필드
return (
<input
ref={editInputRef}
type={isNumeric ? "number" : "text"}
value={editingValue}
onChange={(e) => 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)
)}
</td>
);
})}
</tr>
);
})}
{/* 🆕 Virtual Scrolling: Bottom Spacer */}
{isVirtualScrollEnabled && virtualScrollInfo.bottomSpacerHeight > 0 && (
<tr style={{ height: `${virtualScrollInfo.bottomSpacerHeight}px` }}>
<td colSpan={visibleColumns.length} />
</tr>
)}
</>
)}
</tbody>
{/* 🆕 데이터 요약 (Total Summaries) */}
{summaryData && Object.keys(summaryData).length > 0 && (
<tfoot className="bg-muted/80 sticky bottom-0 z-10 border-t-2 border-primary/20">
<tr>
{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 (
<td
key={column.columnName}
className={cn(
"text-foreground text-xs font-semibold sm:text-sm",
column.columnName === "__checkbox__" ? "px-0 py-2" : "px-2 py-2 sm:px-4",
isFrozen && "bg-muted/80 sticky z-10 shadow-[2px_0_4px_rgba(0,0,0,0.05)]",
)}
style={{
textAlign: isNumeric ? "right" : column.align || "left",
width:
column.columnName === "__checkbox__"
? "48px"
: columnWidth
? `${columnWidth}px`
: undefined,
...(isFrozen && { left: `${leftPosition}px` }),
}}
>
{summary ? (
<div className="flex flex-col">
<span className="text-muted-foreground text-[10px] sm:text-xs">{summary.label}</span>
<span className="text-primary font-bold">
{typeof summary.value === "number"
? summary.value.toLocaleString("ko-KR", {
maximumFractionDigits: 2,
})
: summary.value}
</span>
</div>
) : colIndex === 0 ? (
<span className="text-muted-foreground text-xs"></span>
) : null}
</td>
);
})}
</tr>
</tfoot>
)}
</table>
</div>
</div>
{/* 페이지네이션 */}
{paginationJSX}
</div>
{/* 필터 설정 다이얼로그 */}
<Dialog open={isFilterSettingOpen} onOpenChange={setIsFilterSettingOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
. .
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
{/* 전체 선택/해제 */}
<div className="bg-muted/50 flex items-center gap-3 rounded border p-3">
<Checkbox
id="select-all-filters"
checked={
visibleFilterColumns.size ===
visibleColumns.filter((col) => col.columnName !== "__checkbox__").length &&
visibleColumns.filter((col) => col.columnName !== "__checkbox__").length > 0
}
onCheckedChange={toggleAllFilters}
/>
<Label htmlFor="select-all-filters" className="flex-1 cursor-pointer text-xs font-semibold sm:text-sm">
/
</Label>
<span className="text-muted-foreground text-xs">
{visibleFilterColumns.size} / {visibleColumns.filter((col) => col.columnName !== "__checkbox__").length}
</span>
</div>
{/* 컬럼 목록 */}
<div className="max-h-[50vh] space-y-2 overflow-y-auto rounded border p-2">
{visibleColumns
.filter((col) => col.columnName !== "__checkbox__")
.map((col) => (
<div key={col.columnName} className="hover:bg-muted/50 flex items-center gap-3 rounded p-2">
<Checkbox
id={`filter-${col.columnName}`}
checked={visibleFilterColumns.has(col.columnName)}
onCheckedChange={() => toggleFilterVisibility(col.columnName)}
/>
<Label
htmlFor={`filter-${col.columnName}`}
className="flex-1 cursor-pointer text-xs font-normal sm:text-sm"
>
{columnLabels[col.columnName] || col.displayName || col.columnName}
</Label>
</div>
))}
</div>
{/* 선택된 컬럼 개수 안내 */}
<div className="text-muted-foreground bg-muted/30 rounded p-3 text-center text-xs">
{visibleFilterColumns.size === 0 ? (
<span> 1 </span>
) : (
<span>
<span className="text-primary font-semibold">{visibleFilterColumns.size}</span>
</span>
)}
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setIsFilterSettingOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button onClick={saveFilterSettings} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 🆕 Context Menu (우클릭 메뉴) */}
{contextMenu && (
<div
className="bg-popover text-popover-foreground fixed z-[9999] min-w-[160px] rounded-md border shadow-md"
style={{
left: contextMenu.x,
top: contextMenu.y,
}}
onClick={(e) => e.stopPropagation()}
>
<div className="p-1">
{/* 셀 복사 */}
<button
className="hover:bg-accent hover:text-accent-foreground flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm"
onClick={() => {
const col = visibleColumns[contextMenu.colIndex];
if (col) {
const value = contextMenu.row[col.columnName];
navigator.clipboard.writeText(String(value ?? ""));
toast.success("셀 값이 복사되었습니다");
}
closeContextMenu();
}}
>
<Copy className="h-4 w-4" />
</button>
{/* 행 복사 */}
<button
className="hover:bg-accent hover:text-accent-foreground flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm"
onClick={() => {
const rowData = visibleColumns
.map((col) => String(contextMenu.row[col.columnName] ?? ""))
.join("\t");
navigator.clipboard.writeText(rowData);
toast.success("행 데이터가 복사되었습니다");
closeContextMenu();
}}
>
<ClipboardCopy className="h-4 w-4" />
</button>
<div className="bg-border my-1 h-px" />
{/* 셀 편집 */}
{(() => {
const col = visibleColumns[contextMenu.colIndex];
const isEditable = col?.editable !== false && col?.columnName !== "__checkbox__";
return (
<button
className={`flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm ${
isEditable
? "hover:bg-accent hover:text-accent-foreground"
: "opacity-50 cursor-not-allowed"
}`}
onClick={() => {
if (!isEditable) {
toast.warning(`'${col?.displayName || col?.columnName}' 컬럼은 편집할 수 없습니다.`);
closeContextMenu();
return;
}
if (col) {
setEditingCell({
rowIndex: contextMenu.rowIndex,
colIndex: contextMenu.colIndex,
columnName: col.columnName,
originalValue: contextMenu.row[col.columnName],
});
setEditingValue(String(contextMenu.row[col.columnName] ?? ""));
}
closeContextMenu();
}}
>
<Edit className="h-4 w-4" />
{!isEditable && "(잠김)"}
</button>
);
})()}
{/* 행 선택/해제 */}
<button
className="hover:bg-accent hover:text-accent-foreground flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm"
onClick={() => {
const rowKey = getRowKey(contextMenu.row, contextMenu.rowIndex);
setSelectedRows((prev) => {
const next = new Set(prev);
if (next.has(rowKey)) {
next.delete(rowKey);
} else {
next.add(rowKey);
}
return next;
});
closeContextMenu();
}}
>
<CheckSquare className="h-4 w-4" />
{selectedRows.has(getRowKey(contextMenu.row, contextMenu.rowIndex)) ? "선택 해제" : "행 선택"}
</button>
<div className="bg-border my-1 h-px" />
{/* 행 삭제 */}
<button
className="hover:bg-destructive hover:text-destructive-foreground flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-red-600"
onClick={async () => {
if (confirm("이 행을 삭제하시겠습니까?")) {
try {
const rowId = contextMenu.row.id || contextMenu.row.uuid;
if (!rowId) {
toast.error("삭제할 행의 ID를 찾을 수 없습니다");
closeContextMenu();
return;
}
const tableName = tableConfig.selectedTable;
if (!tableName) {
toast.error("테이블명을 찾을 수 없습니다");
closeContextMenu();
return;
}
await tableTypeApi.deleteTableData(tableName, { ids: [String(rowId)] });
toast.success("행이 삭제되었습니다");
handleRefresh();
} catch (error) {
console.error("삭제 오류:", error);
toast.error("삭제 중 오류가 발생했습니다");
}
}
closeContextMenu();
}}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
)}
{/* 🆕 Filter Builder 모달 */}
<Dialog open={isFilterBuilderOpen} onOpenChange={setIsFilterBuilderOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[700px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
.
</DialogDescription>
</DialogHeader>
<div className="max-h-[60vh] space-y-4 overflow-y-auto">
{filterGroups.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-sm">
. .
</div>
) : (
filterGroups.map((group, groupIndex) => (
<div key={group.id} className="rounded-lg border p-4">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs"> {groupIndex + 1}</span>
<select
value={group.logic}
onChange={(e) => updateGroupLogic(group.id, e.target.value as "AND" | "OR")}
className="border-input bg-background h-7 rounded border px-2 text-xs"
>
<option value="AND">AND ( )</option>
<option value="OR">OR ( )</option>
</select>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeFilterGroup(group.id)}
className="h-6 w-6 p-0 text-red-500 hover:text-red-700"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="space-y-2">
{group.conditions.map((condition) => (
<div key={condition.id} className="flex items-center gap-2">
{/* 컬럼 선택 */}
<select
value={condition.column}
onChange={(e) => updateFilterCondition(group.id, condition.id, "column", e.target.value)}
className="border-input bg-background h-8 w-32 rounded border px-2 text-xs sm:w-40"
>
{visibleColumns
.filter((c) => c.columnName !== "__checkbox__")
.map((col) => (
<option key={col.columnName} value={col.columnName}>
{columnLabels[col.columnName] || col.displayName || col.columnName}
</option>
))}
</select>
{/* 연산자 선택 */}
<select
value={condition.operator}
onChange={(e) => updateFilterCondition(group.id, condition.id, "operator", e.target.value)}
className="border-input bg-background h-8 w-28 rounded border px-2 text-xs sm:w-36"
>
<option value="contains"></option>
<option value="notContains"> </option>
<option value="equals"></option>
<option value="notEquals"> </option>
<option value="startsWith"></option>
<option value="endsWith"></option>
<option value="greaterThan"> </option>
<option value="lessThan"> </option>
<option value="greaterOrEqual"></option>
<option value="lessOrEqual"></option>
<option value="isEmpty"></option>
<option value="isNotEmpty"> </option>
</select>
{/* 값 입력 (isEmpty/isNotEmpty가 아닌 경우만) */}
{condition.operator !== "isEmpty" && condition.operator !== "isNotEmpty" && (
<input
type="text"
value={condition.value}
onChange={(e) => 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"
/>
)}
{/* 조건 삭제 */}
<Button
variant="ghost"
size="sm"
onClick={() => removeFilterCondition(group.id, condition.id)}
className="h-6 w-6 p-0 text-red-500 hover:text-red-700"
disabled={group.conditions.length === 1}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
{/* 조건 추가 버튼 */}
<Button
variant="ghost"
size="sm"
onClick={() => addFilterCondition(group.id, visibleColumns[0]?.columnName)}
className="mt-2 h-7 text-xs"
>
+
</Button>
</div>
))
)}
{/* 그룹 추가 버튼 */}
<Button
variant="outline"
size="sm"
onClick={() => addFilterGroup(visibleColumns[0]?.columnName)}
className="w-full"
>
+
</Button>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={clearFilterBuilder}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
variant="outline"
onClick={() => setIsFilterBuilderOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={applyFilterBuilder}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 테이블 옵션 모달 */}
<TableOptionsModal
isOpen={isTableOptionsOpen}
onClose={() => setIsTableOptionsOpen(false)}
columns={visibleColumns.map((col) => ({
columnName: col.columnName,
label: columnLabels[col.columnName] || col.displayName || col.columnName,
visible: col.visible !== false,
width: columnWidths[col.columnName],
frozen: frozenColumns.includes(col.columnName),
}))}
onSave={handleTableOptionsSave}
tableName={tableConfig.selectedTable || "table"}
userId={userId}
/>
</>
);
};
export const TableListWrapper: React.FC<TableListComponentProps> = (props) => {
return <TableListComponent {...props} />;
};