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

3250 lines
125 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,
} 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;
}
// ========================================
// 캐시 및 유틸리티
// ========================================
const tableColumnCache = new Map<string, { columns: any[]; timestamp: number }>();
const tableInfoCache = new Map<string, { tables: any[]; timestamp: number }>();
const TABLE_CACHE_TTL = 5 * 60 * 1000; // 5분
const cleanupTableCache = () => {
const now = Date.now();
for (const [key, entry] of tableColumnCache.entries()) {
if (now - entry.timestamp > TABLE_CACHE_TTL) {
tableColumnCache.delete(key);
}
}
for (const [key, entry] of tableInfoCache.entries()) {
if (now - entry.timestamp > TABLE_CACHE_TTL) {
tableInfoCache.delete(key);
}
}
};
if (typeof window !== "undefined") {
setInterval(cleanupTableCache, 10 * 60 * 1000);
}
const debounceTimers = new Map<string, NodeJS.Timeout>();
const activeRequests = new Map<string, Promise<any>>();
const debouncedApiCall = <T extends any[], R>(key: string, fn: (...args: T) => Promise<R>, delay: number = 300) => {
return (...args: T): Promise<R> => {
const activeRequest = activeRequests.get(key);
if (activeRequest) {
return activeRequest as Promise<R>;
}
return new Promise((resolve, reject) => {
const existingTimer = debounceTimers.get(key);
if (existingTimer) {
clearTimeout(existingTimer);
}
const timer = setTimeout(async () => {
try {
const requestPromise = fn(...args);
activeRequests.set(key, requestPromise);
const result = await requestPromise;
resolve(result);
} catch (error) {
reject(error);
} finally {
debounceTimers.delete(key);
activeRequests.delete(key);
}
}, delay);
debounceTimers.set(key, timer);
});
};
};
// ========================================
// Props 인터페이스
// ========================================
export interface TableListComponentProps {
component: any;
isDesignMode?: boolean;
isSelected?: boolean;
isInteractive?: boolean;
onClick?: () => void;
onDragStart?: (e: React.DragEvent) => void;
onDragEnd?: (e: React.DragEvent) => void;
className?: string;
style?: React.CSSProperties;
formData?: Record<string, any>;
onFormDataChange?: (data: any) => void;
config?: TableListConfig;
size?: { width: number; height: number };
position?: { x: number; y: number; z?: number };
componentConfig?: any;
selectedScreen?: any;
onZoneComponentDrop?: any;
onZoneClick?: any;
tableName?: string;
onRefresh?: () => void;
onClose?: () => void;
screenId?: 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]);
const [data, setData] = useState<Record<string, any>[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 🆕 분할 패널에서 우측에 이미 추가된 항목 필터링 (좌측 테이블에만 적용)
const filteredData = useMemo(() => {
// 분할 패널 좌측에 있고, 우측에 추가된 항목이 있는 경우에만 필터링
if (splitPanelPosition === "left" && splitPanelContext?.addedItemIds && splitPanelContext.addedItemIds.size > 0) {
const addedIds = splitPanelContext.addedItemIds;
const filtered = data.filter((row) => {
const rowId = String(row.id || row.po_item_id || row.item_id || "");
return !addedIds.has(rowId);
});
console.log("🔍 [TableList] 우측 추가 항목 필터링:", {
originalCount: data.length,
filteredCount: filtered.length,
addedIdsCount: addedIds.size,
});
return filtered;
}
return data;
}, [data, splitPanelPosition, splitPanelContext?.addedItemIds]);
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);
const [columnOrder, setColumnOrder] = useState<string[]>([]);
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 [groupByColumns, setGroupByColumns] = useState<string[]>([]);
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
// 사용자 옵션 모달 관련 상태
const [isTableOptionsOpen, setIsTableOptionsOpen] = useState(false);
const [showGridLines, setShowGridLines] = useState(true);
const [viewMode, setViewMode] = useState<"table" | "card" | "grouped-card">("table");
const [frozenColumns, setFrozenColumns] = useState<string[]>([]);
// 🆕 연결된 필터 처리 (셀렉트박스 등 다른 컴포넌트 값으로 필터링)
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) {
// 컬럼 라벨 매핑 생성
const labels: Record<string, string> = {};
visibleColumns.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: visibleColumns.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}`);
setData(response.data || []);
setTotalPages(response.totalPages || 0);
setTotalItems(response.total || 0);
setError(null);
// 🎯 Store에 필터 조건 저장 (엑셀 다운로드용)
const labels: Record<string, string> = {};
visibleColumns.forEach((col) => {
labels[col.columnName] = columnLabels[col.columnName] || col.columnName;
});
tableDisplayStore.setTableData(
tableConfig.selectedTable,
response.data || [],
visibleColumns.map((col) => col.columnName),
sortBy,
sortOrder,
{
filterConditions: filters,
searchTerm: search,
visibleColumns: visibleColumns.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단계: 정렬된 데이터를 컬럼 순서대로 재정렬
const reorderedData = sortedData.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;
});
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 : visibleColumns.map((c) => c.columnName)
).filter((col) => col !== "__checkbox__");
// 컬럼 라벨 정보도 함께 저장
const labels: Record<string, string> = {};
visibleColumns.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: visibleColumns.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);
// 🆕 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우)
if (splitPanelContext && splitPanelPosition === "left") {
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 handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.();
};
// ========================================
// 컬럼 관련
// ========================================
const visibleColumns = useMemo(() => {
let cols = (tableConfig.columns || []).filter((col) => col.visible !== false);
// columnVisibility가 있으면 가시성 적용
if (columnVisibility.length > 0) {
cols = cols.filter((col) => {
const visibilityConfig = columnVisibility.find((cv) => cv.columnName === col.columnName);
return visibilityConfig ? visibilityConfig.visible : true;
});
}
// 체크박스 컬럼 (나중에 위치 결정)
// 기본값: enabled가 undefined면 true로 처리
let checkboxCol: ColumnConfig | null = null;
if (tableConfig.checkbox?.enabled ?? true) {
checkboxCol = {
columnName: "__checkbox__",
displayName: "",
visible: true,
sortable: false,
searchable: false,
width: 50,
align: "center",
order: -1,
};
}
// columnOrder 상태가 있으면 그 순서대로 정렬 (체크박스 제외)
if (columnOrder.length > 0) {
const orderedCols = columnOrder
.map((colName) => cols.find((c) => c.columnName === colName))
.filter(Boolean) as ColumnConfig[];
// columnOrder에 없는 새로운 컬럼들 추가
const remainingCols = cols.filter((c) => !columnOrder.includes(c.columnName));
cols = [...orderedCols, ...remainingCols];
} else {
cols = cols.sort((a, b) => (a.order || 0) - (b.order || 0));
}
// 체크박스를 맨 앞 또는 맨 뒤에 추가
if (checkboxCol) {
if (tableConfig.checkbox?.position === "right") {
cols = [...cols, checkboxCol];
} else {
cols = [checkboxCol, ...cols];
}
}
return cols;
}, [tableConfig.columns, tableConfig.checkbox, columnOrder, columnVisibility]);
// 🆕 visibleColumns가 변경될 때마다 현재 컬럼 순서를 부모에게 전달
const lastColumnOrderRef = useRef<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(",")]); // 의존성 단순화
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";
}}
/>
);
}
// 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원, 다중 값 지원)
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];
});
return {
groupKey,
groupValues,
items,
count: items.length,
};
});
}, [data, groupByColumns, columnLabels, columnMeta]);
// 저장된 그룹 설정 불러오기
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>
{/* 우측 새로고침 버튼 */}
<Button
variant="ghost"
size="sm"
onClick={handleRefresh}
disabled={loading}
className="absolute right-2 h-8 w-8 p-0 sm:right-6 sm:h-9 sm:w-auto sm:px-3"
>
<RefreshCw className={cn("h-3 w-3", loading && "animate-spin")} />
</Button>
</div>
);
}, [tableConfig.pagination, isDesignMode, currentPage, totalPages, totalItems, loading]);
// ========================================
// 렌더링
// ========================================
const domProps = {
onClick: handleClick,
onDragStart: isDesignMode ? onDragStart : undefined,
onDragEnd: isDesignMode ? onDragEnd : undefined,
draggable: isDesignMode,
className: cn("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으로 이동 */}
{/* 그룹 표시 배지 */}
{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
className="flex flex-1 flex-col"
style={{
width: "100%",
height: "100%",
overflow: "hidden",
}}
>
{/* 스크롤 영역 */}
<div
className="bg-background flex-1"
style={{
position: "relative",
width: "100%",
height: "100%",
overflow: "auto",
}}
>
{/* 테이블 */}
<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))",
}}
>
<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;
}
}
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)]",
)}
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` }),
}}
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: "8px" }}>
<span>{columnLabels[column.columnName] || column.displayName}</span>
{column.sortable !== false && sortColumn === column.columnName && (
<span>{sortDirection === "asc" ? "↑" : "↓"}</span>
)}
</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>
))}
</React.Fragment>
);
})
) : (
// 일반 렌더링 (그룹 없음)
filteredData.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>
))
)}
</tbody>
</table>
</div>
</div>
{/* 페이지네이션 */}
{paginationJSX}
</div>
{/* 필터 설정 다이얼로그 */}
<Dialog open={isFilterSettingOpen} onOpenChange={setIsFilterSettingOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
. .
</DialogDescription>
</DialogHeader>
<div className="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>
{/* 테이블 옵션 모달 */}
<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} />;
};