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

2104 lines
78 KiB
TypeScript
Raw Normal View History

2025-09-15 11:43:59 +09:00
"use client";
2025-09-29 17:29:58 +09:00
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
2025-09-16 16:53:03 +09:00
import { TableListConfig, ColumnConfig } from "./types";
import { WebType } from "@/types/common";
2025-09-15 11:43:59 +09:00
import { tableTypeApi } from "@/lib/api/screen";
2025-09-16 15:13:00 +09:00
import { entityJoinApi } from "@/lib/api/entityJoin";
import { codeCache } from "@/lib/caching/codeCache";
2025-10-23 16:50:41 +09:00
import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization";
import { Button } from "@/components/ui/button";
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
RefreshCw,
ArrowUp,
ArrowDown,
TableIcon,
Settings,
X,
Layers,
ChevronDown,
2025-10-23 16:50:41 +09:00
} from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
2025-11-05 10:23:00 +09:00
import { tableDisplayStore } from "@/stores/tableDisplayStore";
2025-10-23 16:50:41 +09:00
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { AdvancedSearchFilters } from "@/components/screen/filters/AdvancedSearchFilters";
import { SingleTableWithSticky } from "./SingleTableWithSticky";
import { CardModeRenderer } from "./CardModeRenderer";
2025-11-05 16:36:32 +09:00
import { TableOptionsModal } from "@/components/common/TableOptionsModal";
2025-10-23 16:50:41 +09:00
// ========================================
// 인터페이스
// ========================================
// 그룹화된 데이터 인터페이스
interface GroupedData {
groupKey: string;
groupValues: Record<string, any>;
items: any[];
count: number;
}
2025-10-23 16:50:41 +09:00
// ========================================
// 캐시 및 유틸리티
// ========================================
2025-09-29 17:24:06 +09:00
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);
}
2025-09-29 17:29:58 +09:00
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);
});
};
};
2025-10-23 16:50:41 +09:00
// ========================================
// Props 인터페이스
// ========================================
2025-09-15 11:43:59 +09:00
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 (컬럼 순서 저장용)
2025-11-05 10:23:00 +09:00
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[], tableDisplayData?: any[]) => void;
onConfigChange?: (config: any) => void;
2025-09-18 18:49:30 +09:00
refreshKey?: number;
2025-09-15 11:43:59 +09:00
}
2025-10-23 16:50:41 +09:00
// ========================================
// 메인 컴포넌트
// ========================================
2025-09-15 11:43:59 +09:00
export const TableListComponent: React.FC<TableListComponentProps> = ({
component,
isDesignMode = false,
isSelected = false,
onClick,
onDragStart,
onDragEnd,
config,
className,
style,
onFormDataChange,
componentConfig,
2025-09-18 18:49:30 +09:00
onSelectedRowsChange,
onConfigChange,
2025-09-18 18:49:30 +09:00
refreshKey,
2025-10-23 16:50:41 +09:00
tableName,
userId,
2025-09-15 11:43:59 +09:00
}) => {
2025-10-23 16:50:41 +09:00
// ========================================
// 설정 및 스타일
// ========================================
2025-09-15 11:43:59 +09:00
const tableConfig = {
...config,
...component.config,
...componentConfig,
} as TableListConfig;
2025-10-23 16:50:41 +09:00
// selectedTable 안전하게 추출 (문자열인지 확인)
let finalSelectedTable =
componentConfig?.selectedTable || component.config?.selectedTable || config?.selectedTable || tableName;
// 디버그 로그 제거 (성능 최적화)
2025-10-23 16:50:41 +09:00
// 객체인 경우 tableName 속성 추출 시도
if (typeof finalSelectedTable === "object" && finalSelectedTable !== null) {
finalSelectedTable = (finalSelectedTable as any).tableName || (finalSelectedTable as any).name || tableName;
}
tableConfig.selectedTable = finalSelectedTable;
// 디버그 로그 제거 (성능 최적화)
2025-10-23 16:50:41 +09:00
const buttonColor = component.style?.labelColor || "#212121";
2025-09-29 17:24:06 +09:00
const buttonTextColor = component.config?.buttonTextColor || "#ffffff";
2025-10-23 16:50:41 +09:00
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%";
}
2025-09-24 10:33:54 +09:00
2025-10-23 16:50:41 +09:00
const componentStyle: React.CSSProperties = {
width: calculatedWidth,
height: isDesignMode ? "auto" : "100%",
minHeight: isDesignMode ? "300px" : "100%",
display: "flex",
flexDirection: "column",
backgroundColor: "hsl(var(--background))",
2025-10-23 16:50:41 +09:00
overflow: "hidden",
...style,
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
width: "100%",
2025-10-23 16:50:41 +09:00
};
// ========================================
2025-09-15 11:43:59 +09:00
// 상태 관리
2025-10-23 16:50:41 +09:00
// ========================================
2025-09-18 15:14:14 +09:00
const [data, setData] = useState<Record<string, any>[]>([]);
2025-09-15 11:43:59 +09:00
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("");
2025-09-15 11:43:59 +09:00
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>("");
2025-10-23 16:50:41 +09:00
const [localPageSize, setLocalPageSize] = useState<number>(tableConfig.pagination?.pageSize || 20);
const [displayColumns, setDisplayColumns] = useState<ColumnConfig[]>([]);
2025-09-24 10:33:54 +09:00
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 }>>>({});
2025-09-23 14:26:18 +09:00
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>>({});
2025-10-23 16:50:41 +09:00
const [isAllSelected, setIsAllSelected] = useState(false);
const hasInitializedWidths = useRef(false);
const isResizing = useRef(false);
2025-10-23 16:50:41 +09:00
// 필터 설정 관련 상태
const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false);
const [visibleFilterColumns, setVisibleFilterColumns] = useState<Set<string>>(new Set());
2025-09-18 18:49:30 +09:00
// 그룹 설정 관련 상태
const [isGroupSettingOpen, setIsGroupSettingOpen] = useState(false);
const [groupByColumns, setGroupByColumns] = useState<string[]>([]);
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
2025-11-05 16:36:32 +09:00
// 사용자 옵션 모달 관련 상태
const [isTableOptionsOpen, setIsTableOptionsOpen] = useState(false);
const [showGridLines, setShowGridLines] = useState(true);
const [viewMode, setViewMode] = useState<"table" | "card" | "grouped-card">("table");
const [frozenColumns, setFrozenColumns] = useState<string[]>([]);
2025-11-05 10:23:00 +09:00
// 🆕 초기 로드 시 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);
setColumnOrder(parsedOrder);
// 부모 컴포넌트에 초기 컬럼 순서 전달
if (onSelectedRowsChange && parsedOrder.length > 0) {
// 초기 데이터도 함께 전달 (컬럼 순서대로 재정렬)
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) {
tableDisplayStore.setTableData(
tableConfig.selectedTable,
initialData,
parsedOrder.filter(col => col !== '__checkbox__'),
sortColumn,
sortDirection
);
}
onSelectedRowsChange([], [], sortColumn, sortDirection, parsedOrder, initialData);
}
} catch (error) {
console.error("❌ 컬럼 순서 파싱 실패:", error);
}
}
}, [tableConfig.selectedTable, userId, data.length]); // data.length 추가 (데이터 로드 후 실행)
2025-09-17 13:49:00 +09:00
const { optimizedConvertCode } = useEntityJoinOptimization(columnMeta, {
2025-09-16 16:53:03 +09:00
enableBatchLoading: true,
preloadCommonCodes: true,
maxBatchSize: 5,
});
2025-09-15 11:43:59 +09:00
2025-10-23 16:50:41 +09:00
// ========================================
// 컬럼 라벨 가져오기
// ========================================
2025-09-15 11:43:59 +09:00
const fetchColumnLabels = useCallback(async () => {
2025-09-15 11:43:59 +09:00
if (!tableConfig.selectedTable) return;
2025-10-23 16:50:41 +09:00
try {
const cacheKey = `columns_${tableConfig.selectedTable}`;
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 }> = {};
cached.columns.forEach((col: any) => {
labels[col.columnName] = col.displayName || col.comment || col.columnName;
meta[col.columnName] = {
webType: col.webType,
codeCategory: col.codeCategory,
};
});
2025-10-23 16:50:41 +09:00
setColumnLabels(labels);
setColumnMeta(meta);
2025-09-29 17:24:06 +09:00
return;
}
2025-10-23 16:50:41 +09:00
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;
});
2025-09-29 17:24:06 +09:00
2025-10-23 16:50:41 +09:00
tableColumnCache.set(cacheKey, {
columns,
inputTypes,
2025-10-23 16:50:41 +09:00
timestamp: Date.now(),
});
2025-09-29 17:24:06 +09:00
2025-10-23 16:50:41 +09:00
const labels: Record<string, string> = {};
const meta: Record<string, { webType?: string; codeCategory?: string; inputType?: string }> = {};
2025-09-29 17:24:06 +09:00
2025-10-23 16:50:41 +09:00
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],
2025-10-23 16:50:41 +09:00
};
});
2025-09-29 17:24:06 +09:00
2025-10-23 16:50:41 +09:00
setColumnLabels(labels);
setColumnMeta(meta);
} catch (error) {
console.error("컬럼 라벨 가져오기 실패:", error);
}
}, [tableConfig.selectedTable]);
2025-09-15 11:43:59 +09:00
2025-10-23 16:50:41 +09:00
// ========================================
// 테이블 라벨 가져오기
// ========================================
const fetchTableLabel = useCallback(async () => {
2025-09-15 11:43:59 +09:00
if (!tableConfig.selectedTable) return;
2025-10-23 16:50:41 +09:00
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);
2025-09-29 17:24:06 +09:00
return;
2025-09-15 11:43:59 +09:00
}
2025-09-29 17:24:06 +09:00
2025-10-23 16:50:41 +09:00
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);
2025-09-15 11:43:59 +09:00
}
}, [tableConfig.selectedTable]);
2025-09-15 11:43:59 +09:00
// ========================================
// 카테고리 값 매핑 로드
// ========================================
useEffect(() => {
const loadCategoryMappings = async () => {
if (!tableConfig.selectedTable) return;
// 로딩 중에는 매핑 로드하지 않음 (데이터 로드 완료 후에만 실행)
if (loading) return;
// columnMeta가 비어있으면 대기
const columnMetaKeys = Object.keys(columnMeta || {});
if (columnMetaKeys.length === 0) {
console.log("⏳ [TableList] columnMeta 로딩 대기 중...");
return;
}
try {
const categoryColumns = Object.entries(columnMeta)
.filter(([_, meta]) => meta.inputType === "category" || meta.webType === "category")
.map(([columnName, _]) => columnName);
if (categoryColumns.length === 0) {
console.log("⚠️ [TableList] 카테고리 컬럼 없음:", {
table: tableConfig.selectedTable,
columnMetaKeys,
columnMeta,
});
return;
}
console.log("🔄 [TableList] 카테고리 매핑 로드 시작:", {
table: tableConfig.selectedTable,
categoryColumns,
dataLength: data.length,
loading,
});
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
for (const columnName of categoryColumns) {
try {
const apiClient = (await import("@/lib/api/client")).apiClient;
const response = await apiClient.get(
`/table-categories/${tableConfig.selectedTable}/${columnName}/values`
);
if (response.data.success && response.data.data) {
const mapping: Record<string, { label: string; color?: string }> = {};
response.data.data.forEach((item: any) => {
mapping[item.valueCode] = {
label: item.valueLabel,
color: item.color,
};
});
mappings[columnName] = mapping;
console.log(`✅ [TableList] 카테고리 매핑 로드 [${columnName}]:`, mapping);
}
} catch (error) {
console.error(`❌ [TableList] 카테고리 값 로드 실패 [${columnName}]:`, error);
}
}
console.log("📊 [TableList] 전체 카테고리 매핑:", mappings);
setCategoryMappings(mappings);
} catch (error) {
console.error("TableListComponent 카테고리 매핑 로드 실패:", error);
}
};
loadCategoryMappings();
}, [tableConfig.selectedTable, columnMeta, loading]); // loading이 false가 될 때마다 갱신!
2025-10-23 16:50:41 +09:00
// ========================================
// 데이터 가져오기
// ========================================
const fetchTableDataInternal = useCallback(async () => {
2025-10-23 16:50:41 +09:00
if (!tableConfig.selectedTable || isDesignMode) {
2025-09-15 11:43:59 +09:00
setData([]);
2025-10-23 16:50:41 +09:00
setTotalPages(0);
setTotalItems(0);
2025-09-15 11:43:59 +09:00
return;
}
// 테이블명 확인 로그 (개발 시에만)
// console.log("🔍 fetchTableDataInternal - selectedTable:", tableConfig.selectedTable);
// console.log("🔍 selectedTable 타입:", typeof tableConfig.selectedTable);
// console.log("🔍 전체 tableConfig:", tableConfig);
2025-10-23 16:50:41 +09:00
2025-09-15 11:43:59 +09:00
setLoading(true);
setError(null);
try {
2025-10-23 16:50:41 +09:00
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,
}));
const hasEntityJoins = entityJoinColumns.length > 0;
let response;
if (hasEntityJoins) {
response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
page,
size: pageSize,
sortBy,
sortOrder,
search: filters,
enableEntityJoin: true,
additionalJoinColumns: entityJoinColumns,
});
} else {
response = await tableTypeApi.getTableData(tableConfig.selectedTable, {
page,
size: pageSize,
sortBy,
sortOrder,
search: filters,
});
2025-09-15 11:43:59 +09:00
}
2025-10-23 16:50:41 +09:00
setData(response.data || []);
setTotalPages(response.totalPages || 0);
setTotalItems(response.total || 0);
setError(null);
} catch (err: any) {
console.error("데이터 가져오기 실패:", err);
2025-09-15 11:43:59 +09:00
setData([]);
2025-10-23 16:50:41 +09:00
setTotalPages(0);
setTotalItems(0);
setError(err.message || "데이터를 불러오지 못했습니다.");
2025-09-15 11:43:59 +09:00
} finally {
setLoading(false);
}
}, [
tableConfig.selectedTable,
2025-10-23 16:50:41 +09:00
tableConfig.pagination?.currentPage,
tableConfig.columns,
currentPage,
localPageSize,
sortColumn,
sortDirection,
2025-10-23 16:50:41 +09:00
searchTerm,
searchValues,
2025-10-23 16:50:41 +09:00
isDesignMode,
]);
const fetchTableDataDebounced = useCallback(
2025-10-23 16:50:41 +09:00
(...args: Parameters<typeof fetchTableDataInternal>) => {
const key = `fetchData_${tableConfig.selectedTable}_${currentPage}_${sortColumn}_${sortDirection}`;
return debouncedApiCall(key, fetchTableDataInternal, 300)(...args);
},
[fetchTableDataInternal, tableConfig.selectedTable, currentPage, sortColumn, sortDirection],
);
2025-09-15 11:43:59 +09:00
2025-10-23 16:50:41 +09:00
// ========================================
// 이벤트 핸들러
// ========================================
2025-09-15 11:43:59 +09:00
const handlePageChange = (newPage: number) => {
2025-10-23 16:50:41 +09:00
if (newPage < 1 || newPage > totalPages) return;
2025-09-15 11:43:59 +09:00
setCurrentPage(newPage);
2025-10-23 16:50:41 +09:00
if (tableConfig.pagination) {
tableConfig.pagination.currentPage = newPage;
}
if (onConfigChange) {
onConfigChange({ ...tableConfig, pagination: { ...tableConfig.pagination, currentPage: newPage } });
}
2025-09-15 11:43:59 +09:00
};
const handleSort = (column: string) => {
let newSortColumn = column;
let newSortDirection: "asc" | "desc" = "asc";
2025-09-15 11:43:59 +09:00
if (sortColumn === column) {
newSortDirection = sortDirection === "asc" ? "desc" : "asc";
setSortDirection(newSortDirection);
2025-09-15 11:43:59 +09:00
} else {
setSortColumn(column);
setSortDirection("asc");
newSortColumn = column;
newSortDirection = "asc";
}
// 정렬 변경 시 선택 정보와 함께 정렬 정보도 전달
if (onSelectedRowsChange) {
const selectedRowsData = data.filter((row, index) => selectedRows.has(getRowKey(row, index)));
2025-11-05 10:23:00 +09:00
// 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;
2025-11-05 16:36:32 +09:00
// 숫자 비교 (문자열이어도 숫자로 변환 가능하면 숫자로 비교)
2025-11-05 10:23:00 +09:00
const aNum = Number(aVal);
const bNum = Number(bVal);
2025-11-05 16:36:32 +09:00
// 둘 다 유효한 숫자이고, 원본 값이 빈 문자열이 아닌 경우
if (!isNaN(aNum) && !isNaN(bNum) && aVal !== "" && bVal !== "") {
2025-11-05 10:23:00 +09:00
return newSortDirection === "desc" ? bNum - aNum : aNum - bNum;
}
2025-11-05 16:36:32 +09:00
// 문자열 비교 (대소문자 구분 없이, 숫자 포함 문자열도 자연스럽게 정렬)
const aStr = String(aVal).toLowerCase();
const bStr = String(bVal).toLowerCase();
// 자연스러운 정렬 (숫자 포함 문자열)
const comparison = aStr.localeCompare(bStr, undefined, { numeric: true, sensitivity: 'base' });
2025-11-05 10:23:00 +09:00
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;
});
onSelectedRowsChange(
Array.from(selectedRows),
selectedRowsData,
newSortColumn,
newSortDirection,
2025-11-05 10:23:00 +09:00
columnOrder.length > 0 ? columnOrder : undefined,
reorderedData
);
2025-11-05 10:23:00 +09:00
// 전역 저장소에 정렬된 데이터 저장
if (tableConfig.selectedTable) {
const cleanColumnOrder = (columnOrder.length > 0 ? columnOrder : visibleColumns.map(c => c.columnName)).filter(col => col !== '__checkbox__');
tableDisplayStore.setTableData(
tableConfig.selectedTable,
reorderedData,
cleanColumnOrder,
newSortColumn,
newSortDirection
);
}
2025-09-15 11:43:59 +09:00
}
};
2025-09-23 14:26:18 +09:00
const handleSearchValueChange = (columnName: string, value: any) => {
2025-10-23 16:50:41 +09:00
setSearchValues((prev) => ({ ...prev, [columnName]: value }));
2025-09-23 14:26:18 +09:00
};
const handleAdvancedSearch = () => {
setCurrentPage(1);
2025-09-29 17:29:58 +09:00
fetchTableDataDebounced();
2025-09-23 14:26:18 +09:00
};
const handleClearAdvancedFilters = () => {
setSearchValues({});
setCurrentPage(1);
2025-09-29 17:29:58 +09:00
fetchTableDataDebounced();
2025-09-15 11:43:59 +09:00
};
const handleRefresh = () => {
2025-09-29 17:29:58 +09:00
fetchTableDataDebounced();
2025-09-15 11:43:59 +09:00
};
2025-09-18 18:49:30 +09:00
const getRowKey = (row: any, index: number) => {
2025-10-23 16:50:41 +09:00
return row.id || row.uuid || `row-${index}`;
2025-09-18 18:49:30 +09:00
};
const handleRowSelection = (rowKey: string, checked: boolean) => {
const newSelectedRows = new Set(selectedRows);
if (checked) {
newSelectedRows.add(rowKey);
} else {
newSelectedRows.delete(rowKey);
}
setSelectedRows(newSelectedRows);
2025-10-23 16:50:41 +09:00
const selectedRowsData = data.filter((row, index) => newSelectedRows.has(getRowKey(row, index)));
if (onSelectedRowsChange) {
onSelectedRowsChange(Array.from(newSelectedRows), selectedRowsData, sortColumn || undefined, sortDirection);
2025-09-18 18:49:30 +09:00
}
2025-10-23 16:50:41 +09:00
if (onFormDataChange) {
onFormDataChange({
selectedRows: Array.from(newSelectedRows),
selectedRowsData,
});
2025-10-23 16:50:41 +09:00
}
const allRowsSelected = data.every((row, index) => newSelectedRows.has(getRowKey(row, index)));
setIsAllSelected(allRowsSelected && data.length > 0);
2025-09-18 18:49:30 +09:00
};
const handleSelectAll = (checked: boolean) => {
if (checked) {
const allKeys = data.map((row, index) => getRowKey(row, index));
2025-10-23 16:50:41 +09:00
const newSelectedRows = new Set(allKeys);
setSelectedRows(newSelectedRows);
2025-09-18 18:49:30 +09:00
setIsAllSelected(true);
2025-10-23 16:50:41 +09:00
if (onSelectedRowsChange) {
onSelectedRowsChange(Array.from(newSelectedRows), data, sortColumn || undefined, sortDirection);
2025-10-23 16:50:41 +09:00
}
if (onFormDataChange) {
onFormDataChange({
selectedRows: Array.from(newSelectedRows),
selectedRowsData: data,
});
2025-09-18 18:49:30 +09:00
}
} else {
setSelectedRows(new Set());
setIsAllSelected(false);
2025-10-23 16:50:41 +09:00
if (onSelectedRowsChange) {
onSelectedRowsChange([], [], sortColumn || undefined, sortDirection);
2025-09-18 18:49:30 +09:00
}
2025-09-15 11:43:59 +09:00
if (onFormDataChange) {
2025-10-23 16:50:41 +09:00
onFormDataChange({ selectedRows: [], selectedRowsData: [] });
2025-09-15 11:43:59 +09:00
}
}
2025-10-23 16:50:41 +09:00
};
2025-09-18 18:49:30 +09:00
2025-11-05 16:36:32 +09:00
const handleRowClick = (row: any, index: number, e: React.MouseEvent) => {
// 체크박스 클릭은 무시 (이미 handleRowSelection에서 처리됨)
const target = e.target as HTMLElement;
if (target.closest('input[type="checkbox"]')) {
return;
}
2025-11-05 16:36:32 +09:00
// 행 선택/해제 토글
const rowKey = getRowKey(row, index);
const isCurrentlySelected = selectedRows.has(rowKey);
handleRowSelection(rowKey, !isCurrentlySelected);
};
2025-11-05 16:36:32 +09:00
// 컬럼 드래그앤드롭 기능 제거됨 (테이블 옵션 모달에서 컬럼 순서 변경 가능)
2025-10-23 16:50:41 +09:00
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.();
};
2025-09-29 17:24:06 +09:00
2025-10-23 16:50:41 +09:00
// ========================================
// 컬럼 관련
// ========================================
2025-09-15 11:43:59 +09:00
const visibleColumns = useMemo(() => {
2025-10-23 16:50:41 +09:00
let cols = (tableConfig.columns || []).filter((col) => col.visible !== false);
2025-09-18 18:49:30 +09:00
2025-10-23 16:50:41 +09:00
if (tableConfig.checkbox?.enabled) {
const checkboxCol: ColumnConfig = {
2025-09-18 18:49:30 +09:00
columnName: "__checkbox__",
displayName: "",
visible: true,
sortable: false,
searchable: false,
width: 50,
align: "center",
2025-10-23 16:50:41 +09:00
order: -1,
2025-09-18 18:49:30 +09:00
};
2025-10-23 16:50:41 +09:00
if (tableConfig.checkbox.position === "right") {
cols = [...cols, checkboxCol];
2025-09-18 18:49:30 +09:00
} else {
2025-10-23 16:50:41 +09:00
cols = [checkboxCol, ...cols];
2025-09-18 18:49:30 +09:00
}
2025-09-16 15:13:00 +09:00
}
2025-09-18 18:49:30 +09:00
// 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));
return [...orderedCols, ...remainingCols];
}
2025-10-23 16:50:41 +09:00
return cols.sort((a, b) => (a.order || 0) - (b.order || 0));
}, [tableConfig.columns, tableConfig.checkbox, columnOrder]);
2025-09-18 15:14:14 +09:00
2025-11-05 10:23:00 +09:00
// 🆕 visibleColumns가 변경될 때마다 현재 컬럼 순서를 부모에게 전달
const lastColumnOrderRef = useRef<string>("");
useEffect(() => {
if (!onSelectedRowsChange) {
return;
}
if (visibleColumns.length === 0) {
return;
}
const currentColumnOrder = visibleColumns
.map(col => col.columnName)
.filter(name => name !== "__checkbox__"); // 체크박스 컬럼 제외
// 컬럼 순서가 실제로 변경되었을 때만 전달 (무한 루프 방지)
const columnOrderString = currentColumnOrder.join(",");
if (columnOrderString === lastColumnOrderRef.current) {
return;
}
lastColumnOrderRef.current = columnOrderString;
// 선택된 행 데이터 가져오기
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(",")]); // 의존성 단순화
2025-09-18 15:14:14 +09:00
const getColumnWidth = (column: ColumnConfig) => {
2025-10-23 16:50:41 +09:00
if (column.columnName === "__checkbox__") return 50;
2025-09-18 15:14:14 +09:00
if (column.width) return column.width;
2025-10-23 16:50:41 +09:00
switch (column.format) {
case "date":
return 120;
case "number":
case "currency":
return 100;
case "boolean":
return 80;
default:
return 150;
2025-09-18 18:49:30 +09:00
}
2025-09-18 15:14:14 +09:00
};
2025-09-18 18:49:30 +09:00
const renderCheckboxHeader = () => {
2025-10-23 16:50:41 +09:00
if (!tableConfig.checkbox?.selectAll) return null;
2025-09-18 18:49:30 +09:00
2025-10-23 16:50:41 +09:00
return <Checkbox checked={isAllSelected} onCheckedChange={handleSelectAll} aria-label="전체 선택" />;
2025-09-18 18:49:30 +09:00
};
const renderCheckboxCell = (row: any, index: number) => {
const rowKey = getRowKey(row, index);
2025-10-23 16:50:41 +09:00
const isChecked = selectedRows.has(rowKey);
2025-09-18 18:49:30 +09:00
return (
<Checkbox
2025-10-23 16:50:41 +09:00
checked={isChecked}
2025-09-18 18:49:30 +09:00
onCheckedChange={(checked) => handleRowSelection(rowKey, checked as boolean)}
aria-label={`${index + 1} 선택`}
/>
);
};
2025-10-23 16:50:41 +09:00
const formatCellValue = useCallback(
2025-10-28 18:41:45 +09:00
(value: any, column: ColumnConfig, rowData?: Record<string, any>) => {
2025-10-23 16:50:41 +09:00
if (value === null || value === undefined) return "-";
2025-10-28 18:41:45 +09:00
// 🎯 엔티티 컬럼 표시 설정이 있는 경우
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 || " - ");
}
}
2025-10-23 16:50:41 +09:00
const meta = columnMeta[column.columnName];
// inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선)
const inputType = meta?.inputType || column.inputType;
// 카테고리 타입: 배지로 표시
if (inputType === "category") {
if (!value) return "";
const mapping = categoryMappings[column.columnName];
const categoryData = mapping?.[String(value)];
// 매핑 데이터가 있으면 라벨과 색상 사용, 없으면 코드값과 기본색상
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);
}
// 숫자 타입 포맷팅
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);
}
2025-10-23 16:50:41 +09:00
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);
2025-10-23 16:50:41 +09:00
case "date":
if (value) {
try {
const date = new Date(value);
return date.toLocaleDateString("ko-KR");
} catch {
return value;
}
}
return "-";
2025-09-16 16:53:03 +09:00
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);
}
2025-10-23 16:50:41 +09:00
},
[columnMeta, categoryMappings, optimizedConvertCode],
2025-10-23 16:50:41 +09:00
);
2025-09-15 11:43:59 +09:00
2025-10-23 16:50:41 +09:00
// ========================================
// useEffect 훅
// ========================================
2025-09-15 11:43:59 +09:00
2025-10-23 16:50:41 +09:00
// 필터 설정 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]);
2025-10-23 16:50:41 +09:00
// 저장된 필터 설정 불러오기
useEffect(() => {
if (!filterSettingKey || visibleColumns.length === 0) return;
2025-10-23 16:50:41 +09:00
try {
const saved = localStorage.getItem(filterSettingKey);
if (saved) {
const savedFilters = JSON.parse(saved);
setVisibleFilterColumns(new Set(savedFilters));
} else {
// 초기값: 빈 Set (아무것도 선택 안 함)
setVisibleFilterColumns(new Set());
2025-10-23 16:50:41 +09:00
}
} catch (error) {
console.error("필터 설정 불러오기 실패:", error);
setVisibleFilterColumns(new Set());
2025-09-15 11:43:59 +09:00
}
}, [filterSettingKey, visibleColumns]);
2025-09-15 11:43:59 +09:00
2025-10-23 16:50:41 +09:00
// 필터 설정 저장
const saveFilterSettings = useCallback(() => {
if (!filterSettingKey) return;
2025-09-29 17:24:06 +09:00
2025-10-23 16:50:41 +09:00
try {
localStorage.setItem(filterSettingKey, JSON.stringify(Array.from(visibleFilterColumns)));
setIsFilterSettingOpen(false);
toast.success("검색 필터 설정이 저장되었습니다");
// 검색 값 초기화
setSearchValues({});
2025-10-23 16:50:41 +09:00
} catch (error) {
console.error("필터 설정 저장 실패:", error);
toast.error("설정 저장에 실패했습니다");
2025-10-23 16:50:41 +09:00
}
}, [filterSettingKey, visibleFilterColumns]);
// 필터 컬럼 토글
2025-10-23 16:50:41 +09:00
const toggleFilterVisibility = useCallback((columnName: string) => {
setVisibleFilterColumns((prev) => {
const newSet = new Set(prev);
if (newSet.has(columnName)) {
newSet.delete(columnName);
} else {
newSet.add(columnName);
}
2025-10-23 16:50:41 +09:00
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]);
// 표시할 필터 목록 (선택된 컬럼만)
2025-10-23 16:50:41 +09:00
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]);
// 그룹 설정 저장
const saveGroupSettings = useCallback(() => {
if (!groupSettingKey) return;
try {
localStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns));
setIsGroupSettingOpen(false);
toast.success("그룹 설정이 저장되었습니다");
} catch (error) {
console.error("그룹 설정 저장 실패:", error);
toast.error("설정 저장에 실패했습니다");
}
}, [groupSettingKey, groupByColumns]);
// 그룹 컬럼 토글
const toggleGroupColumn = useCallback((columnName: string) => {
setGroupByColumns((prev) => {
if (prev.includes(columnName)) {
return prev.filter((col) => col !== columnName);
} else {
return [...prev, columnName];
}
});
}, []);
2025-11-05 16:36:32 +09:00
// 사용자 옵션 저장 핸들러
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]);
2025-10-23 16:50:41 +09:00
useEffect(() => {
fetchColumnLabels();
fetchTableLabel();
}, [tableConfig.selectedTable, fetchColumnLabels, fetchTableLabel]);
2025-09-15 11:43:59 +09:00
2025-10-23 16:50:41 +09:00
useEffect(() => {
if (!isDesignMode && tableConfig.selectedTable) {
fetchTableDataDebounced();
}
}, [
tableConfig.selectedTable,
currentPage,
localPageSize,
sortColumn,
sortDirection,
searchTerm,
refreshKey,
isDesignMode,
fetchTableDataDebounced,
2025-10-23 16:50:41 +09:00
]);
2025-09-15 11:43:59 +09:00
2025-10-23 16:50:41 +09:00
useEffect(() => {
if (tableConfig.refreshInterval && !isDesignMode) {
const interval = setInterval(() => {
fetchTableDataDebounced();
}, tableConfig.refreshInterval * 1000);
2025-09-15 11:43:59 +09:00
2025-10-23 16:50:41 +09:00
return () => clearInterval(interval);
}
}, [tableConfig.refreshInterval, isDesignMode]);
2025-09-18 18:49:30 +09:00
// 초기 컬럼 너비 측정 (한 번만)
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]);
2025-10-23 16:50:41 +09:00
// ========================================
// 페이지네이션 JSX
// ========================================
const paginationJSX = useMemo(() => {
if (!tableConfig.pagination?.enabled || isDesignMode) return null;
2025-09-23 14:26:18 +09:00
2025-10-23 16:50:41 +09:00
return (
2025-09-29 17:24:06 +09:00
<div
className="w-full h-14 flex items-center justify-center relative border-t-2 border-border bg-background px-4 flex-shrink-0 sm:h-[60px] sm:px-6"
>
2025-10-23 16:50:41 +09:00
{/* 중앙 페이지네이션 컨트롤 */}
<div
className="flex items-center gap-2 sm:gap-4"
2025-10-23 16:50:41 +09:00
>
<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"
2025-10-23 16:50:41 +09:00
>
<ChevronsLeft className="h-3 w-3 sm:h-4 sm:w-4" />
2025-10-23 16:50:41 +09:00
</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"
2025-10-23 16:50:41 +09:00
>
<ChevronLeft className="h-3 w-3 sm:h-4 sm:w-4" />
2025-10-23 16:50:41 +09:00
</Button>
<span className="text-xs font-medium text-foreground min-w-[60px] text-center sm:text-sm sm:min-w-[80px]">
2025-10-23 16:50:41 +09:00
{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"
2025-10-23 16:50:41 +09:00
>
<ChevronRight className="h-3 w-3 sm:h-4 sm:w-4" />
2025-10-23 16:50:41 +09:00
</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"
2025-10-23 16:50:41 +09:00
>
<ChevronsRight className="h-3 w-3 sm:h-4 sm:w-4" />
2025-10-23 16:50:41 +09:00
</Button>
<span className="text-[10px] text-muted-foreground ml-2 sm:text-xs sm:ml-4">
2025-10-23 16:50:41 +09:00
{totalItems.toLocaleString()}
</span>
</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"
2025-10-23 16:50:41 +09:00
>
<RefreshCw className={cn("h-3 w-3", loading && "animate-spin")} />
2025-10-23 16:50:41 +09:00
</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="px-4 py-3 border-b border-border 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">
2025-10-23 16:50:41 +09:00
<AdvancedSearchFilters
filters={activeFilters}
searchValues={searchValues}
onSearchValueChange={handleSearchValueChange}
onSearch={handleAdvancedSearch}
onClear={handleClearAdvancedFilters}
/>
</div>
<div className="flex gap-2">
2025-11-05 16:36:32 +09:00
<Button
variant="outline"
size="sm"
onClick={() => setIsTableOptionsOpen(true)}
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
>
<TableIcon className="mr-2 h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setIsFilterSettingOpen(true)}
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
>
<Settings className="mr-2 h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setIsGroupSettingOpen(true)}
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
>
<Layers className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
</div>
)}
{/* 그룹 표시 배지 */}
{groupByColumns.length > 0 && (
<div className="px-4 py-2 border-b border-border bg-muted/30 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="그룹 해제"
2025-10-23 16:50:41 +09:00
>
<X className="h-4 w-4" />
</button>
2025-09-15 11:43:59 +09:00
</div>
</div>
2025-10-23 16:50:41 +09:00
)}
2025-11-04 17:48:22 +09:00
<div style={{ marginTop: `${tableConfig.filter?.bottomSpacing ?? 40}px`, flex: 1, overflow: "hidden" }}>
2025-10-23 16:50:41 +09:00
<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}
2025-10-23 16:50:41 +09:00
columnLabels={columnLabels}
renderCheckboxHeader={renderCheckboxHeader}
renderCheckboxCell={renderCheckboxCell}
2025-10-28 18:41:45 +09:00
formatCellValue={(value: any, format?: string, columnName?: string, rowData?: Record<string, any>) => {
2025-10-23 16:50:41 +09:00
const column = visibleColumns.find((c) => c.columnName === columnName);
2025-10-28 18:41:45 +09:00
return column ? formatCellValue(value, column, rowData) : String(value);
2025-10-23 16:50:41 +09:00
}}
getColumnWidth={getColumnWidth}
containerWidth={calculatedWidth}
/>
</div>
{paginationJSX}
</div>
);
}
// 일반 테이블 모드 (네이티브 HTML 테이블)
return (
<>
<div {...domProps}>
{/* 필터 */}
{tableConfig.filter?.enabled && (
<div className="px-4 py-3 border-b border-border flex-shrink-0 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">
2025-10-23 16:50:41 +09:00
<AdvancedSearchFilters
filters={activeFilters}
searchValues={searchValues}
onSearchValueChange={handleSearchValueChange}
onSearch={handleAdvancedSearch}
onClear={handleClearAdvancedFilters}
/>
</div>
<div className="flex gap-2">
2025-11-05 16:36:32 +09:00
<Button
variant="outline"
size="sm"
onClick={() => setIsTableOptionsOpen(true)}
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
>
<TableIcon className="mr-2 h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setIsFilterSettingOpen(true)}
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
>
<Settings className="mr-2 h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setIsGroupSettingOpen(true)}
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
>
<Layers className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
</div>
)}
{/* 그룹 표시 배지 */}
{groupByColumns.length > 0 && (
<div className="px-4 py-2 border-b border-border bg-muted/30 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="그룹 해제"
2025-10-23 16:50:41 +09:00
>
<X className="h-4 w-4" />
</button>
2025-10-23 16:50:41 +09:00
</div>
</div>
2025-10-23 16:50:41 +09:00
)}
{/* 테이블 컨테이너 */}
2025-11-04 17:48:22 +09:00
<div
className="flex-1 flex flex-col overflow-hidden w-full max-w-full"
style={{ marginTop: `${tableConfig.filter?.bottomSpacing ?? 40}px` }}
>
2025-10-23 16:50:41 +09:00
{/* 스크롤 영역 */}
<div
className="w-full max-w-full flex-1 overflow-y-auto overflow-x-auto bg-background"
2025-10-23 16:50:41 +09:00
>
{/* 테이블 */}
<table
2025-11-05 16:36:32 +09:00
className={cn(
"w-full max-w-full table-mobile-fixed",
!showGridLines && "hide-grid"
)}
2025-09-29 17:24:06 +09:00
style={{
2025-10-23 16:50:41 +09:00
borderCollapse: "collapse",
width: "100%",
tableLayout: "fixed",
}}
>
2025-10-23 16:50:41 +09:00
{/* 헤더 (sticky) */}
<thead
className="sticky top-0 z-10"
2025-09-29 17:24:06 +09:00
>
<tr className="h-10 border-b-2 border-primary/20 bg-muted sm:h-12">
{visibleColumns.map((column, columnIndex) => {
const columnWidth = columnWidths[column.columnName];
2025-11-05 16:36:32 +09:00
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(
2025-11-05 16:36:32 +09:00
"relative h-8 text-xs font-bold text-foreground/90 overflow-hidden 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__") && "cursor-pointer hover:bg-muted/70 transition-colors",
2025-11-05 16:36:32 +09:00
isFrozen && "sticky z-20 bg-muted/80 backdrop-blur-sm 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,
2025-11-05 16:36:32 +09:00
userSelect: 'none',
...(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 right-0 top-0 h-full w-2 cursor-col-resize hover:bg-blue-500 z-20"
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>
);
})}
2025-10-23 16:50:41 +09:00
</tr>
</thead>
{/* 바디 (스크롤) */}
<tbody>
{loading ? (
<tr>
<td colSpan={visibleColumns.length} className="p-12 text-center">
<div className="flex flex-col items-center gap-3">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
<div className="text-sm font-medium text-muted-foreground"> ...</div>
2025-10-23 16:50:41 +09:00
</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-sm font-medium text-destructive"> </div>
<div className="text-xs text-muted-foreground">{error}</div>
2025-10-23 16:50:41 +09:00
</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="h-12 w-12 text-muted-foreground/50" />
<div className="text-sm font-medium text-muted-foreground"> </div>
<div className="text-xs text-muted-foreground">
2025-10-23 16:50:41 +09:00
2025-09-29 17:24:06 +09:00
</div>
</div>
2025-10-23 16:50:41 +09:00
</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 border-b border-border sticky top-[48px] z-[5]"
style={{ top: "48px" }}
>
<div
className="flex items-center gap-3 p-3 cursor-pointer hover:bg-muted/80"
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="font-medium text-sm flex-1">{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(
2025-11-05 16:36:32 +09:00
"h-10 border-b transition-colors bg-background hover:bg-muted/50 cursor-pointer sm:h-12"
)}
2025-11-05 16:36:32 +09:00
onClick={(e) => handleRowClick(row, index, e)}
>
2025-11-05 16:36:32 +09:00
{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";
2025-11-05 16:36:32 +09:00
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(
2025-11-05 16:36:32 +09:00
"h-10 text-xs text-foreground overflow-hidden text-ellipsis whitespace-nowrap sm:h-12 sm:text-sm",
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-2",
isFrozen && "sticky z-10 bg-background 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,
2025-11-05 16:36:32 +09:00
...(isFrozen && { left: `${leftPosition}px` })
}}
>
{column.columnName === "__checkbox__"
? renderCheckboxCell(row, index)
: formatCellValue(cellValue, column, row)}
</td>
);
})}
</tr>
))}
</React.Fragment>
);
})
2025-09-29 17:24:06 +09:00
) : (
// 일반 렌더링 (그룹 없음)
2025-09-29 17:24:06 +09:00
data.map((row, index) => (
2025-10-23 16:50:41 +09:00
<tr
2025-09-29 17:24:06 +09:00
key={index}
className={cn(
2025-11-05 16:36:32 +09:00
"h-10 border-b transition-colors bg-background hover:bg-muted/50 cursor-pointer sm:h-12"
)}
2025-11-05 16:36:32 +09:00
onClick={(e) => handleRowClick(row, index, e)}
2025-09-29 17:24:06 +09:00
>
2025-11-05 16:36:32 +09:00
{visibleColumns.map((column, colIndex) => {
2025-10-23 16:50:41 +09:00
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";
2025-11-05 16:36:32 +09:00
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;
}
}
2025-10-23 16:50:41 +09:00
return (
<td
key={column.columnName}
className={cn(
2025-11-05 16:36:32 +09:00
"h-10 text-xs text-foreground overflow-hidden text-ellipsis whitespace-nowrap sm:h-12 sm:text-sm",
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-2",
isFrozen && "sticky z-10 bg-background shadow-[2px_0_4px_rgba(0,0,0,0.05)]"
)}
2025-10-23 16:50:41 +09:00
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,
2025-11-05 16:36:32 +09:00
...(isFrozen && { left: `${leftPosition}px` })
2025-10-23 16:50:41 +09:00
}}
>
{column.columnName === "__checkbox__"
? renderCheckboxCell(row, index)
2025-10-28 18:41:45 +09:00
: formatCellValue(cellValue, column, row)}
2025-10-23 16:50:41 +09:00
</td>
);
})}
</tr>
2025-09-29 17:24:06 +09:00
))
)}
2025-10-23 16:50:41 +09:00
</tbody>
</table>
</div>
2025-10-23 16:50:41 +09:00
</div>
2025-09-15 11:43:59 +09:00
2025-10-23 16:50:41 +09:00
{/* 페이지네이션 */}
{paginationJSX}
</div>
2025-09-15 11:43:59 +09:00
2025-10-23 16:50:41 +09:00
{/* 필터 설정 다이얼로그 */}
<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">
. .
2025-10-23 16:50:41 +09:00
</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>
2025-09-15 11:43:59 +09:00
</div>
2025-10-23 16:50:41 +09:00
<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>
{/* 그룹 설정 다이얼로그 */}
<Dialog open={isGroupSettingOpen} onOpenChange={setIsGroupSettingOpen}>
<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="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={`group-${col.columnName}`}
checked={groupByColumns.includes(col.columnName)}
onCheckedChange={() => toggleGroupColumn(col.columnName)}
/>
<Label
htmlFor={`group-${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-xs">
{groupByColumns.length === 0 ? (
<span> </span>
) : (
<span>
:{" "}
<span className="text-primary font-semibold">
{groupByColumns.map((col) => columnLabels[col] || col).join(" → ")}
</span>
</span>
)}
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setIsGroupSettingOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button onClick={saveGroupSettings} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
2025-11-05 16:36:32 +09:00
{/* 테이블 옵션 모달 */}
<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}
/>
2025-10-23 16:50:41 +09:00
</>
2025-09-15 11:43:59 +09:00
);
};
export const TableListWrapper: React.FC<TableListComponentProps> = (props) => {
return <TableListComponent {...props} />;
};