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

2488 lines
94 KiB
TypeScript

"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";
// ========================================
// 인터페이스
// ========================================
// 그룹화된 데이터 인터페이스
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?: string;
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,
onFormDataChange,
componentConfig,
onSelectedRowsChange,
onConfigChange,
refreshKey,
tableName,
userId,
}) => {
// ========================================
// 설정 및 스타일
// ========================================
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 = {
width: calculatedWidth,
height: isDesignMode ? "auto" : "100%",
minHeight: isDesignMode ? "300px" : "100%",
display: "flex",
flexDirection: "column",
backgroundColor: "hsl(var(--background))",
overflow: "hidden",
...style,
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
width: "100%",
};
// ========================================
// 상태 관리
// ========================================
// TableOptions Context
const { registerTable, unregisterTable } = useTableOptions();
const [filters, setFilters] = useState<TableFilter[]>([]);
const [grouping, setGrouping] = useState<string[]>([]);
const [columnVisibility, setColumnVisibility] = useState<ColumnVisibility[]>([]);
const [data, setData] = useState<Record<string, any>[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [totalItems, setTotalItems] = useState(0);
const [searchTerm, setSearchTerm] = useState("");
const [sortColumn, setSortColumn] = useState<string | null>(null);
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({});
const [tableLabel, setTableLabel] = useState<string>("");
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 }>
>({});
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[]>([]);
// 테이블 등록 (Context에 등록)
const tableId = `table-list-${component.id}`;
useEffect(() => {
if (!tableConfig.selectedTable || !displayColumns || displayColumns.length === 0) {
return;
}
registerTable({
tableId,
label: tableLabel || tableConfig.selectedTable,
tableName: tableConfig.selectedTable,
columns: displayColumns.map((col) => ({
columnName: col.field,
columnLabel: columnLabels[col.field] || col.label || col.field,
inputType: columnMeta[col.field]?.inputType || "text",
visible: col.visible !== false,
width: columnWidths[col.field] || col.width || 150,
sortable: col.sortable !== false,
filterable: col.filterable !== false,
})),
onFilterChange: setFilters,
onGroupChange: setGrouping,
onColumnVisibilityChange: setColumnVisibility,
});
return () => unregisterTable(tableId);
}, [
component.id,
tableConfig.selectedTable,
displayColumns,
columnLabels,
columnMeta,
columnWidths,
tableLabel,
]);
// 🆕 초기 로드 시 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);
console.log("🔍 [TableList] 카테고리 컬럼 추출:", {
columnMeta,
categoryColumns: cols,
columnMetaKeys: Object.keys(columnMeta),
});
return cols;
}, [columnMeta]);
// 카테고리 매핑 로드 (columnMeta 변경 시 즉시 실행)
useEffect(() => {
const loadCategoryMappings = async () => {
console.log("🔄 [TableList] loadCategoryMappings 트리거:", {
hasTable: !!tableConfig.selectedTable,
table: tableConfig.selectedTable,
categoryColumnsLength: categoryColumns.length,
categoryColumns,
columnMetaKeys: Object.keys(columnMeta),
});
if (!tableConfig.selectedTable) {
console.log("⏭️ [TableList] 테이블 선택 안됨, 카테고리 매핑 로드 스킵");
return;
}
if (categoryColumns.length === 0) {
console.log("⏭️ [TableList] 카테고리 컬럼 없음, 카테고리 매핑 로드 스킵");
setCategoryMappings({});
return;
}
console.log("🚀 [TableList] 카테고리 매핑 로드 시작:", {
table: tableConfig.selectedTable,
categoryColumns,
columnMetaKeys: Object.keys(columnMeta),
});
try {
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
for (const columnName of categoryColumns) {
try {
console.log(`📡 [TableList] API 호출 시작 [${columnName}]:`, {
url: `/table-categories/${tableConfig.selectedTable}/${columnName}/values`,
});
const apiClient = (await import("@/lib/api/client")).apiClient;
const response = await apiClient.get(`/table-categories/${tableConfig.selectedTable}/${columnName}/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) {
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,
});
}
}
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)]); // 더 명확한 의존성
// ========================================
// 데이터 가져오기
// ========================================
const fetchTableDataInternal = useCallback(async () => {
if (!tableConfig.selectedTable || isDesignMode) {
setData([]);
setTotalPages(0);
setTotalItems(0);
return;
}
// 테이블명 확인 로그 (개발 시에만)
// console.log("🔍 fetchTableDataInternal - selectedTable:", tableConfig.selectedTable);
// console.log("🔍 selectedTable 타입:", typeof tableConfig.selectedTable);
// console.log("🔍 전체 tableConfig:", tableConfig);
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;
const filters = Object.keys(searchValues).length > 0 ? searchValues : undefined;
const entityJoinColumns = (tableConfig.columns || [])
.filter((col) => col.additionalJoinInfo)
.map((col) => ({
sourceTable: col.additionalJoinInfo!.sourceTable,
sourceColumn: col.additionalJoinInfo!.sourceColumn,
joinAlias: col.additionalJoinInfo!.joinAlias,
referenceTable: col.additionalJoinInfo!.referenceTable,
}));
// 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원)
const response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
page,
size: pageSize,
sortBy,
sortOrder,
search: filters,
enableEntityJoin: true,
additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined,
});
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,
]);
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";
}
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,
});
}
const allRowsSelected = data.every((row, index) => newSelectedRows.has(getRowKey(row, index)));
setIsAllSelected(allRowsSelected && data.length > 0);
};
const handleSelectAll = (checked: boolean) => {
if (checked) {
const allKeys = data.map((row, index) => getRowKey(row, index));
const newSelectedRows = new Set(allKeys);
setSelectedRows(newSelectedRows);
setIsAllSelected(true);
if (onSelectedRowsChange) {
onSelectedRowsChange(Array.from(newSelectedRows), data, sortColumn || undefined, sortDirection);
}
if (onFormDataChange) {
onFormDataChange({
selectedRows: Array.from(newSelectedRows),
selectedRowsData: data,
});
}
} else {
setSelectedRows(new Set());
setIsAllSelected(false);
if (onSelectedRowsChange) {
onSelectedRowsChange([], [], sortColumn || undefined, sortDirection);
}
if (onFormDataChange) {
onFormDataChange({ selectedRows: [], selectedRowsData: [] });
}
}
};
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);
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);
if (tableConfig.checkbox?.enabled) {
const checkboxCol: ColumnConfig = {
columnName: "__checkbox__",
displayName: "",
visible: true,
sortable: false,
searchable: false,
width: 50,
align: "center",
order: -1,
};
if (tableConfig.checkbox.position === "right") {
cols = [...cols, checkboxCol];
} else {
cols = [checkboxCol, ...cols];
}
}
// 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));
console.log("🔄 columnOrder 기반 정렬:", {
columnOrder,
orderedColsCount: orderedCols.length,
remainingColsCount: remainingCols.length,
});
return [...orderedCols, ...remainingCols];
}
return cols.sort((a, b) => (a.order || 0) - (b.order || 0));
}, [tableConfig.columns, tableConfig.checkbox, columnOrder]);
// 🆕 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>) => {
if (value === null || value === undefined) return "-";
// 🎯 writer 컬럼 자동 변환: user_id -> user_name
if (column.columnName === "writer" && rowData && rowData.writer_name) {
return rowData.writer_name;
}
// 🎯 엔티티 컬럼 표시 설정이 있는 경우
if (column.entityDisplayConfig && rowData) {
// displayColumns 또는 selectedColumns 둘 다 체크
const displayColumns = column.entityDisplayConfig.displayColumns || column.entityDisplayConfig.selectedColumns;
const separator = column.entityDisplayConfig.separator;
if (displayColumns && displayColumns.length > 0) {
// 선택된 컬럼들의 값을 구분자로 조합
const values = displayColumns
.map((colName) => {
const cellValue = rowData[colName];
if (cellValue === null || cellValue === undefined) return "";
return String(cellValue);
})
.filter((v) => v !== ""); // 빈 값 제외
return values.join(separator || " - ");
}
}
const meta = columnMeta[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 "";
const mapping = categoryMappings[column.columnName];
const categoryData = mapping?.[String(value)];
// console.log(`🎨 [카테고리 배지] ${column.columnName}:`, {
// value,
// stringValue: String(value),
// mapping,
// categoryData,
// hasMapping: !!mapping,
// hasCategoryData: !!categoryData,
// allCategoryMappings: categoryMappings, // 전체 매핑 확인
// categoryMappingsKeys: Object.keys(categoryMappings),
// });
// 매핑 데이터가 있으면 라벨과 색상 사용, 없으면 코드값과 기본색상
const displayLabel = categoryData?.label || String(value);
const displayColor = categoryData?.color || "#64748b"; // 기본 slate 색상
const { Badge } = require("@/components/ui/badge");
return (
<Badge
style={{
backgroundColor: displayColor,
borderColor: displayColor,
}}
className="text-white"
>
{displayLabel}
</Badge>
);
}
// 코드 타입: 코드 값 → 코드명 변환
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)) {
return numValue.toLocaleString("ko-KR");
}
}
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)) {
return numValue.toLocaleString("ko-KR");
}
}
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 "number":
return typeof value === "number" ? value.toLocaleString() : value;
case "currency":
return typeof value === "number" ? `${value.toLocaleString()}` : value;
case "boolean":
return value ? "예" : "아니오";
default:
return String(value);
}
},
[columnMeta, optimizedConvertCode, categoryMappings],
);
// ========================================
// useEffect 훅
// ========================================
// 필터 설정 localStorage 키 생성
const filterSettingKey = useMemo(() => {
if (!tableConfig.selectedTable) return null;
return `tableList_filterSettings_${tableConfig.selectedTable}`;
}, [tableConfig.selectedTable]);
// 그룹 설정 localStorage 키 생성
const groupSettingKey = useMemo(() => {
if (!tableConfig.selectedTable) return null;
return `tableList_groupSettings_${tableConfig.selectedTable}`;
}, [tableConfig.selectedTable]);
// 저장된 필터 설정 불러오기
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 || data.length === 0) return [];
const grouped = new Map<string, any[]>();
data.forEach((item) => {
// 그룹 키 생성: "통화:KRW > 단위:EA"
const keyParts = groupByColumns.map((col) => {
const value = item[col];
const label = columnLabels[col] || col;
return `${label}:${value !== null && value !== undefined ? value : "-"}`;
});
const groupKey = keyParts.join(" > ");
if (!grouped.has(groupKey)) {
grouped.set(groupKey, []);
}
grouped.get(groupKey)!.push(item);
});
return Array.from(grouped.entries()).map(([groupKey, items]) => {
const groupValues: Record<string, any> = {};
groupByColumns.forEach((col) => {
groupValues[col] = items[0]?.[col];
});
return {
groupKey,
groupValues,
items,
count: items.length,
};
});
}, [data, groupByColumns, columnLabels]);
// 저장된 그룹 설정 불러오기
useEffect(() => {
if (!groupSettingKey || visibleColumns.length === 0) return;
try {
const saved = localStorage.getItem(groupSettingKey);
if (saved) {
const savedGroups = JSON.parse(saved);
setGroupByColumns(savedGroups);
}
} catch (error) {
console.error("그룹 설정 불러오기 실패:", error);
}
}, [groupSettingKey, visibleColumns]);
useEffect(() => {
fetchColumnLabels();
fetchTableLabel();
}, [tableConfig.selectedTable, fetchColumnLabels, fetchTableLabel]);
useEffect(() => {
if (!isDesignMode && tableConfig.selectedTable) {
fetchTableDataDebounced();
}
}, [
tableConfig.selectedTable,
currentPage,
localPageSize,
sortColumn,
sortDirection,
searchTerm,
searchValues, // 필터 값 변경 시에도 데이터 새로고침
refreshKey,
refreshTrigger, // 강제 새로고침 트리거
isDesignMode,
fetchTableDataDebounced,
]);
useEffect(() => {
if (tableConfig.refreshInterval && !isDesignMode) {
const interval = setInterval(() => {
fetchTableDataDebounced();
}, tableConfig.refreshInterval * 1000);
return () => clearInterval(interval);
}
}, [tableConfig.refreshInterval, isDesignMode]);
// 초기 컬럼 너비 측정 (한 번만)
useEffect(() => {
if (!hasInitializedWidths.current && visibleColumns.length > 0) {
// 약간의 지연을 두고 DOM이 완전히 렌더링된 후 측정
const timer = setTimeout(() => {
const newWidths: Record<string, number> = {};
let hasAnyWidth = false;
visibleColumns.forEach((column) => {
// 체크박스 컬럼은 제외 (고정 48px)
if (column.columnName === "__checkbox__") return;
const thElement = columnRefs.current[column.columnName];
if (thElement) {
const measuredWidth = thElement.offsetWidth;
if (measuredWidth > 0) {
newWidths[column.columnName] = measuredWidth;
hasAnyWidth = true;
}
}
});
if (hasAnyWidth) {
setColumnWidths(newWidths);
hasInitializedWidths.current = true;
}
}, 100);
return () => clearTimeout(timer);
}
}, [visibleColumns]);
// ========================================
// 페이지네이션 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(className, isDesignMode && "cursor-move"),
style: componentStyle,
};
// 카드 모드
if (tableConfig.displayMode === "card" && !isDesignMode) {
return (
<div {...domProps}>
<CardModeRenderer
data={data}
loading={loading}
error={error}
cardConfig={tableConfig.cardConfig}
onRowClick={handleRowClick}
/>
{paginationJSX}
</div>
);
}
// SingleTableWithSticky 모드
if (tableConfig.stickyHeader && !isDesignMode) {
return (
<div {...domProps}>
{tableConfig.filter?.enabled && (
<div className="border-border border-b px-4 py-2 sm:px-6 sm:py-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:gap-3">
<div className="flex-1">
<AdvancedSearchFilters
filters={activeFilters}
searchValues={searchValues}
onSearchValueChange={handleSearchValueChange}
onSearch={handleAdvancedSearch}
onClearFilters={handleClearAdvancedFilters}
/>
</div>
<div className="flex items-center gap-2">
{/* 전체 개수 */}
<div className="hidden sm:block text-sm text-muted-foreground whitespace-nowrap">
<span className="font-semibold text-foreground">{totalItems.toLocaleString()}</span>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setIsTableOptionsOpen(true)}
className="w-full flex-shrink-0 sm:mt-1 sm:w-auto"
>
<TableIcon className="mr-2 h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setIsFilterSettingOpen(true)}
className="w-full flex-shrink-0 sm:mt-1 sm:w-auto"
>
<Settings className="mr-2 h-4 w-4" />
</Button>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
>
<Layers className="mr-2 h-4 w-4" />
{groupByColumns.length > 0 && (
<span className="ml-2 rounded-full bg-primary px-2 py-0.5 text-[10px] font-semibold text-primary-foreground">
{groupByColumns.length}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end">
<div className="space-y-3 p-4">
<div className="space-y-1">
<h4 className="text-sm font-semibold"> </h4>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 컬럼 목록 */}
<div className="max-h-[300px] space-y-2 overflow-y-auto">
{visibleColumns
.filter((col) => col.columnName !== "__checkbox__")
.map((col) => (
<div
key={col.columnName}
className="flex items-center gap-3 rounded p-2 hover:bg-muted/50"
>
<Checkbox
id={`group-dropdown-${col.columnName}`}
checked={groupByColumns.includes(col.columnName)}
onCheckedChange={() => toggleGroupColumn(col.columnName)}
/>
<Label
htmlFor={`group-dropdown-${col.columnName}`}
className="flex-1 cursor-pointer text-xs font-normal"
>
{columnLabels[col.columnName] || col.displayName || col.columnName}
</Label>
</div>
))}
</div>
{/* 선택된 그룹 안내 */}
{groupByColumns.length > 0 && (
<div className="rounded bg-muted/30 p-2 text-xs text-muted-foreground">
<span className="font-semibold text-foreground">
{groupByColumns.map((col) => columnLabels[col] || col).join(" → ")}
</span>
</div>
)}
{/* 초기화 버튼 */}
{groupByColumns.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => {
setGroupByColumns([]);
if (groupSettingKey) {
localStorage.removeItem(groupSettingKey);
}
toast.success("그룹 설정이 초기화되었습니다");
}}
className="w-full text-xs"
>
</Button>
)}
</div>
</PopoverContent>
</Popover>
</div>
</div>
</div>
)}
{/* 그룹 표시 배지 */}
{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={{ marginTop: `${tableConfig.filter?.bottomSpacing ?? 8}px`, 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}>
{/* 필터 */}
{tableConfig.filter?.enabled && (
<div className="border-border flex-shrink-0 border-b px-4 py-3 sm:px-6 sm:py-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
<div className="flex-1">
<AdvancedSearchFilters
filters={activeFilters}
searchValues={searchValues}
onSearchValueChange={handleSearchValueChange}
onSearch={handleAdvancedSearch}
onClearFilters={handleClearAdvancedFilters}
/>
</div>
<div className="flex items-center gap-2">
{/* 전체 개수 */}
<div className="hidden sm:block text-sm text-muted-foreground whitespace-nowrap">
<span className="font-semibold text-foreground">{totalItems.toLocaleString()}</span>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setIsTableOptionsOpen(true)}
className="w-full flex-shrink-0 sm:mt-1 sm:w-auto"
>
<TableIcon className="mr-2 h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setIsFilterSettingOpen(true)}
className="w-full flex-shrink-0 sm:mt-1 sm:w-auto"
>
<Settings className="mr-2 h-4 w-4" />
</Button>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
>
<Layers className="mr-2 h-4 w-4" />
{groupByColumns.length > 0 && (
<span className="ml-2 rounded-full bg-primary px-2 py-0.5 text-[10px] font-semibold text-primary-foreground">
{groupByColumns.length}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end">
<div className="space-y-3 p-4">
<div className="space-y-1">
<h4 className="text-sm font-semibold"> </h4>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 컬럼 목록 */}
<div className="max-h-[300px] space-y-2 overflow-y-auto">
{visibleColumns
.filter((col) => col.columnName !== "__checkbox__")
.map((col) => (
<div
key={col.columnName}
className="flex items-center gap-3 rounded p-2 hover:bg-muted/50"
>
<Checkbox
id={`group-dropdown-2-${col.columnName}`}
checked={groupByColumns.includes(col.columnName)}
onCheckedChange={() => toggleGroupColumn(col.columnName)}
/>
<Label
htmlFor={`group-dropdown-2-${col.columnName}`}
className="flex-1 cursor-pointer text-xs font-normal"
>
{columnLabels[col.columnName] || col.displayName || col.columnName}
</Label>
</div>
))}
</div>
{/* 선택된 그룹 안내 */}
{groupByColumns.length > 0 && (
<div className="rounded bg-muted/30 p-2 text-xs text-muted-foreground">
<span className="font-semibold text-foreground">
{groupByColumns.map((col) => columnLabels[col] || col).join(" → ")}
</span>
</div>
)}
{/* 초기화 버튼 */}
{groupByColumns.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => {
setGroupByColumns([]);
if (groupSettingKey) {
localStorage.removeItem(groupSettingKey);
}
toast.success("그룹 설정이 초기화되었습니다");
}}
className="w-full text-xs"
>
</Button>
)}
</div>
</PopoverContent>
</Popover>
</div>
</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
className="flex w-full max-w-full flex-1 flex-col overflow-hidden"
style={{ marginTop: `${tableConfig.filter?.bottomSpacing ?? 8}px` }}
>
{/* 스크롤 영역 */}
<div
className="bg-background flex-1 w-full max-w-full overflow-x-auto overflow-y-auto"
style={{ position: "relative" }}
>
{/* 테이블 */}
<table
className={cn("table-mobile-fixed w-full max-w-full", !showGridLines && "hide-grid")}
style={{
borderCollapse: "collapse",
width: "100%",
tableLayout: "fixed",
}}
>
{/* 헤더 (sticky) */}
<thead
className="sticky z-50"
style={{
position: "sticky",
top: "-2px",
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) => ({ ...prev, [column.columnName]: finalWidth }));
}
// 텍스트 선택 복원
document.body.style.userSelect = "";
document.body.style.cursor = "";
// 약간의 지연 후 리사이즈 플래그 해제 (클릭 이벤트가 먼저 처리되지 않도록)
setTimeout(() => {
isResizing.current = false;
}, 100);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}}
/>
)}
</th>
);
})}
</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>
);
})
) : (
// 일반 렌더링 (그룹 없음)
data.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} />;
};