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";
|
2025-09-24 18:07:36 +09:00
|
|
|
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";
|
2025-09-18 19:15:13 +09:00
|
|
|
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,
|
|
|
|
|
} from "lucide-react";
|
|
|
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
|
|
|
import { cn } from "@/lib/utils";
|
2025-11-03 14:00:45 +09:00
|
|
|
import { toast } from "sonner";
|
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-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;
|
2025-09-18 18:49:30 +09:00
|
|
|
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void;
|
2025-09-24 18:07:36 +09:00
|
|
|
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,
|
2025-09-24 18:07:36 +09:00
|
|
|
onConfigChange,
|
2025-09-18 18:49:30 +09:00
|
|
|
refreshKey,
|
2025-10-23 16:50:41 +09:00
|
|
|
tableName,
|
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-17 15:31:23 +09:00
|
|
|
console.log("🔍 TableListComponent 초기화:", {
|
|
|
|
|
componentConfigSelectedTable: componentConfig?.selectedTable,
|
2025-10-23 16:50:41 +09:00
|
|
|
componentConfigSelectedTableType: typeof componentConfig?.selectedTable,
|
2025-10-17 15:31:23 +09:00
|
|
|
componentConfigSelectedTable2: component.config?.selectedTable,
|
2025-10-23 16:50:41 +09:00
|
|
|
componentConfigSelectedTable2Type: typeof component.config?.selectedTable,
|
2025-10-17 15:31:23 +09:00
|
|
|
configSelectedTable: config?.selectedTable,
|
2025-10-23 16:50:41 +09:00
|
|
|
configSelectedTableType: typeof config?.selectedTable,
|
2025-10-17 15:31:23 +09:00
|
|
|
screenTableName: tableName,
|
2025-10-23 16:50:41 +09:00
|
|
|
screenTableNameType: typeof tableName,
|
|
|
|
|
finalSelectedTable,
|
|
|
|
|
finalSelectedTableType: typeof finalSelectedTable,
|
2025-10-17 15:31:23 +09:00
|
|
|
});
|
|
|
|
|
|
2025-10-23 16:50:41 +09:00
|
|
|
// 객체인 경우 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;
|
|
|
|
|
|
|
|
|
|
console.log(
|
|
|
|
|
"✅ 최종 tableConfig.selectedTable:",
|
|
|
|
|
tableConfig.selectedTable,
|
|
|
|
|
"타입:",
|
|
|
|
|
typeof tableConfig.selectedTable,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const buttonColor = component.style?.labelColor || "#212121";
|
2025-09-29 17:24:06 +09:00
|
|
|
const buttonTextColor = component.config?.buttonTextColor || "#ffffff";
|
2025-09-24 18:07:36 +09:00
|
|
|
|
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",
|
2025-10-30 15:39:39 +09:00
|
|
|
backgroundColor: "hsl(var(--background))",
|
2025-10-23 16:50:41 +09:00
|
|
|
overflow: "hidden",
|
|
|
|
|
...style,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ========================================
|
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);
|
2025-09-24 18:07:36 +09:00
|
|
|
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>>({});
|
2025-10-23 16:50:41 +09:00
|
|
|
const [columnMeta, setColumnMeta] = useState<Record<string, { webType?: string; codeCategory?: string }>>({});
|
2025-09-23 14:26:18 +09:00
|
|
|
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
|
2025-09-24 18:07:36 +09:00
|
|
|
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
|
|
|
|
const [isDragging, setIsDragging] = useState(false);
|
2025-10-23 16:50:41 +09:00
|
|
|
const [draggedRowIndex, setDraggedRowIndex] = useState<number | null>(null);
|
2025-11-03 10:54:23 +09:00
|
|
|
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
2025-11-03 11:57:01 +09:00
|
|
|
const columnRefs = useRef<Record<string, HTMLTableCellElement | null>>({});
|
2025-10-23 16:50:41 +09:00
|
|
|
const [isAllSelected, setIsAllSelected] = useState(false);
|
2025-11-03 12:18:50 +09:00
|
|
|
const hasInitializedWidths = useRef(false);
|
2025-11-03 13:30:44 +09:00
|
|
|
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
|
|
|
|
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 = async () => {
|
|
|
|
|
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-09-16 15:52:37 +09:00
|
|
|
|
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);
|
2025-11-03 10:14:32 +09:00
|
|
|
|
|
|
|
|
// 컬럼 입력 타입 정보 가져오기
|
|
|
|
|
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,
|
2025-11-03 10:14:32 +09:00
|
|
|
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> = {};
|
2025-11-03 10:14:32 +09:00
|
|
|
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,
|
2025-11-03 10:14:32 +09:00
|
|
|
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);
|
|
|
|
|
}
|
2025-09-15 11:43:59 +09:00
|
|
|
};
|
|
|
|
|
|
2025-10-23 16:50:41 +09:00
|
|
|
// ========================================
|
|
|
|
|
// 테이블 라벨 가져오기
|
|
|
|
|
// ========================================
|
2025-09-16 15:52:37 +09:00
|
|
|
|
2025-09-15 11:43:59 +09:00
|
|
|
const fetchTableLabel = async () => {
|
|
|
|
|
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
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-10-23 16:50:41 +09:00
|
|
|
// ========================================
|
|
|
|
|
// 데이터 가져오기
|
|
|
|
|
// ========================================
|
|
|
|
|
|
2025-10-17 15:31:23 +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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-23 16:50:41 +09:00
|
|
|
// 테이블명 확인 로그
|
|
|
|
|
console.log("🔍 fetchTableDataInternal - selectedTable:", tableConfig.selectedTable);
|
|
|
|
|
console.log("🔍 selectedTable 타입:", typeof tableConfig.selectedTable);
|
|
|
|
|
console.log("🔍 전체 tableConfig:", tableConfig);
|
|
|
|
|
|
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-10-17 15:31:23 +09:00
|
|
|
});
|
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);
|
|
|
|
|
}
|
2025-10-17 15:31:23 +09:00
|
|
|
}, [
|
|
|
|
|
tableConfig.selectedTable,
|
2025-10-23 16:50:41 +09:00
|
|
|
tableConfig.pagination?.currentPage,
|
2025-10-17 15:31:23 +09:00
|
|
|
tableConfig.columns,
|
|
|
|
|
currentPage,
|
|
|
|
|
localPageSize,
|
|
|
|
|
sortColumn,
|
|
|
|
|
sortDirection,
|
2025-10-23 16:50:41 +09:00
|
|
|
searchTerm,
|
2025-10-17 15:31:23 +09:00
|
|
|
searchValues,
|
2025-10-23 16:50:41 +09:00
|
|
|
isDesignMode,
|
2025-10-17 15:31:23 +09:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
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-10-17 15:31:23 +09:00
|
|
|
);
|
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-24 18:07:36 +09:00
|
|
|
}
|
2025-09-15 11:43:59 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleSort = (column: string) => {
|
|
|
|
|
if (sortColumn === column) {
|
|
|
|
|
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
|
|
|
|
|
} else {
|
|
|
|
|
setSortColumn(column);
|
|
|
|
|
setSortDirection("asc");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
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);
|
2025-09-18 18:49:30 +09:00
|
|
|
}
|
2025-10-23 16:50:41 +09:00
|
|
|
if (onFormDataChange) {
|
|
|
|
|
onFormDataChange({ selectedRows: Array.from(newSelectedRows), selectedRowsData });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
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([], []);
|
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-10-23 16:50:41 +09:00
|
|
|
const handleRowClick = (row: any) => {
|
|
|
|
|
console.log("행 클릭:", row);
|
|
|
|
|
};
|
2025-10-02 14:34:15 +09:00
|
|
|
|
2025-10-23 16:50:41 +09:00
|
|
|
const handleRowDragStart = (e: React.DragEvent, row: any, index: number) => {
|
|
|
|
|
setIsDragging(true);
|
|
|
|
|
setDraggedRowIndex(index);
|
|
|
|
|
e.dataTransfer.effectAllowed = "move";
|
|
|
|
|
e.dataTransfer.setData("application/json", JSON.stringify(row));
|
|
|
|
|
};
|
2025-10-17 15:31:23 +09:00
|
|
|
|
2025-10-23 16:50:41 +09:00
|
|
|
const handleRowDragEnd = (e: React.DragEvent) => {
|
|
|
|
|
setIsDragging(false);
|
|
|
|
|
setDraggedRowIndex(null);
|
|
|
|
|
};
|
2025-10-02 14:34:15 +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-24 18:07:36 +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
|
|
|
|
2025-10-23 16:50:41 +09:00
|
|
|
return cols.sort((a, b) => (a.order || 0) - (b.order || 0));
|
|
|
|
|
}, [tableConfig.columns, tableConfig.checkbox]);
|
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-09-19 02:15:21 +09:00
|
|
|
|
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];
|
|
|
|
|
if (meta?.webType && meta?.codeCategory) {
|
|
|
|
|
const convertedValue = optimizedConvertCode(value, meta.codeCategory);
|
|
|
|
|
if (convertedValue !== value) return convertedValue;
|
2025-09-16 16:53:03 +09:00
|
|
|
}
|
2025-09-16 15:52:37 +09:00
|
|
|
|
2025-11-03 10:14:32 +09:00
|
|
|
// inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선)
|
|
|
|
|
const inputType = meta?.inputType || column.inputType;
|
|
|
|
|
if (inputType === "number" || inputType === "decimal") {
|
2025-11-03 10:09:33 +09:00
|
|
|
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) {
|
2025-11-03 10:14:32 +09:00
|
|
|
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, optimizedConvertCode],
|
|
|
|
|
);
|
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]);
|
|
|
|
|
|
|
|
|
|
// 저장된 필터 설정 불러오기
|
|
|
|
|
useEffect(() => {
|
2025-11-03 13:59:12 +09:00
|
|
|
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 {
|
2025-11-03 13:59:12 +09:00
|
|
|
// 초기값: 빈 Set (아무것도 선택 안 함)
|
|
|
|
|
setVisibleFilterColumns(new Set());
|
2025-10-23 16:50:41 +09:00
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("필터 설정 불러오기 실패:", error);
|
2025-11-03 13:59:12 +09:00
|
|
|
setVisibleFilterColumns(new Set());
|
2025-09-15 11:43:59 +09:00
|
|
|
}
|
2025-11-03 13:59:12 +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);
|
2025-11-03 13:59:12 +09:00
|
|
|
toast.success("검색 필터 설정이 저장되었습니다");
|
|
|
|
|
|
|
|
|
|
// 검색 값 초기화
|
|
|
|
|
setSearchValues({});
|
2025-10-23 16:50:41 +09:00
|
|
|
} catch (error) {
|
|
|
|
|
console.error("필터 설정 저장 실패:", error);
|
2025-11-03 13:59:12 +09:00
|
|
|
toast.error("설정 저장에 실패했습니다");
|
2025-10-23 16:50:41 +09:00
|
|
|
}
|
|
|
|
|
}, [filterSettingKey, visibleFilterColumns]);
|
|
|
|
|
|
2025-11-03 13:59:12 +09:00
|
|
|
// 필터 컬럼 토글
|
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-09-24 18:07:36 +09:00
|
|
|
}
|
2025-10-23 16:50:41 +09:00
|
|
|
return newSet;
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
2025-09-24 18:07:36 +09:00
|
|
|
|
2025-11-03 13:59:12 +09:00
|
|
|
// 전체 선택/해제
|
|
|
|
|
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(() => {
|
2025-11-03 13:59:12 +09:00
|
|
|
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]);
|
2025-09-24 18:07:36 +09:00
|
|
|
|
2025-10-23 16:50:41 +09:00
|
|
|
useEffect(() => {
|
|
|
|
|
fetchColumnLabels();
|
|
|
|
|
fetchTableLabel();
|
|
|
|
|
}, [tableConfig.selectedTable]);
|
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,
|
|
|
|
|
]);
|
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
|
|
|
|
2025-11-03 12:18:50 +09:00
|
|
|
// 초기 컬럼 너비 측정 (한 번만)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!hasInitializedWidths.current && visibleColumns.length > 0) {
|
|
|
|
|
// 약간의 지연을 두고 DOM이 완전히 렌더링된 후 측정
|
|
|
|
|
const timer = setTimeout(() => {
|
|
|
|
|
const newWidths: Record<string, number> = {};
|
|
|
|
|
let hasAnyWidth = false;
|
|
|
|
|
|
|
|
|
|
visibleColumns.forEach((column) => {
|
2025-11-03 12:24:28 +09:00
|
|
|
// 체크박스 컬럼은 제외 (고정 48px)
|
|
|
|
|
if (column.columnName === "__checkbox__") return;
|
|
|
|
|
|
2025-11-03 12:18:50 +09:00
|
|
|
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
|
2025-10-31 10:41:45 +09:00
|
|
|
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-09-24 18:07:36 +09:00
|
|
|
>
|
2025-10-23 16:50:41 +09:00
|
|
|
{/* 중앙 페이지네이션 컨트롤 */}
|
|
|
|
|
<div
|
2025-10-31 10:41:45 +09:00
|
|
|
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}
|
2025-10-31 10:41:45 +09:00
|
|
|
className="h-8 w-8 p-0 sm:h-9 sm:w-auto sm:px-3"
|
2025-10-23 16:50:41 +09:00
|
|
|
>
|
2025-10-31 10:41:45 +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}
|
2025-10-31 10:41:45 +09:00
|
|
|
className="h-8 w-8 p-0 sm:h-9 sm:w-auto sm:px-3"
|
2025-10-23 16:50:41 +09:00
|
|
|
>
|
2025-10-31 10:41:45 +09:00
|
|
|
<ChevronLeft className="h-3 w-3 sm:h-4 sm:w-4" />
|
2025-10-23 16:50:41 +09:00
|
|
|
</Button>
|
|
|
|
|
|
2025-10-31 10:41:45 +09:00
|
|
|
<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}
|
2025-10-31 10:41:45 +09:00
|
|
|
className="h-8 w-8 p-0 sm:h-9 sm:w-auto sm:px-3"
|
2025-10-23 16:50:41 +09:00
|
|
|
>
|
2025-10-31 10:41:45 +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}
|
2025-10-31 10:41:45 +09:00
|
|
|
className="h-8 w-8 p-0 sm:h-9 sm:w-auto sm:px-3"
|
2025-10-23 16:50:41 +09:00
|
|
|
>
|
2025-10-31 10:41:45 +09:00
|
|
|
<ChevronsRight className="h-3 w-3 sm:h-4 sm:w-4" />
|
2025-10-23 16:50:41 +09:00
|
|
|
</Button>
|
|
|
|
|
|
2025-10-31 10:41:45 +09:00
|
|
|
<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}
|
2025-10-31 10:41:45 +09:00
|
|
|
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
|
|
|
>
|
2025-10-31 10:41:45 +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.showHeader && (
|
2025-10-31 10:41:45 +09:00
|
|
|
<div className="px-4 py-3 border-b border-border sm:px-6 sm:py-4">
|
2025-10-31 11:10:09 +09:00
|
|
|
<h2 className="text-base font-semibold text-foreground sm:text-lg">
|
|
|
|
|
{tableConfig.title || tableLabel || finalSelectedTable}
|
|
|
|
|
</h2>
|
2025-09-15 11:43:59 +09:00
|
|
|
</div>
|
2025-10-23 16:50:41 +09:00
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{tableConfig.filter?.enabled && (
|
2025-10-31 10:41:45 +09:00
|
|
|
<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">
|
2025-10-30 15:39:39 +09:00
|
|
|
<div className="flex-1">
|
2025-10-23 16:50:41 +09:00
|
|
|
<AdvancedSearchFilters
|
|
|
|
|
filters={activeFilters}
|
|
|
|
|
searchValues={searchValues}
|
|
|
|
|
onSearchValueChange={handleSearchValueChange}
|
|
|
|
|
onSearch={handleAdvancedSearch}
|
|
|
|
|
onClear={handleClearAdvancedFilters}
|
|
|
|
|
/>
|
2025-09-24 18:07:36 +09:00
|
|
|
</div>
|
2025-10-23 16:50:41 +09:00
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => setIsFilterSettingOpen(true)}
|
2025-10-31 10:41:45 +09:00
|
|
|
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
|
2025-10-23 16:50:41 +09:00
|
|
|
>
|
|
|
|
|
<Settings className="mr-2 h-4 w-4" />
|
|
|
|
|
필터 설정
|
|
|
|
|
</Button>
|
2025-09-15 11:43:59 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-10-23 16:50:41 +09:00
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div style={{ flex: 1, overflow: "hidden" }}>
|
|
|
|
|
<SingleTableWithSticky
|
|
|
|
|
data={data}
|
|
|
|
|
columns={visibleColumns}
|
|
|
|
|
loading={loading}
|
|
|
|
|
error={error}
|
|
|
|
|
sortColumn={sortColumn}
|
|
|
|
|
sortDirection={sortDirection}
|
|
|
|
|
onSort={handleSort}
|
|
|
|
|
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.showHeader && (
|
2025-10-31 10:41:45 +09:00
|
|
|
<div className="px-4 py-3 border-b border-border flex-shrink-0 sm:px-6 sm:py-4">
|
2025-10-31 11:10:09 +09:00
|
|
|
<h2 className="text-base font-semibold text-foreground sm:text-lg">
|
|
|
|
|
{tableConfig.title || tableLabel || finalSelectedTable}
|
|
|
|
|
</h2>
|
2025-09-25 18:54:25 +09:00
|
|
|
</div>
|
2025-10-23 16:50:41 +09:00
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 필터 */}
|
|
|
|
|
{tableConfig.filter?.enabled && (
|
2025-10-31 10:41:45 +09:00
|
|
|
<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>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => setIsFilterSettingOpen(true)}
|
2025-10-31 10:41:45 +09:00
|
|
|
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
|
2025-10-23 16:50:41 +09:00
|
|
|
>
|
|
|
|
|
<Settings className="mr-2 h-4 w-4" />
|
|
|
|
|
필터 설정
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
2025-09-24 18:07:36 +09:00
|
|
|
</div>
|
2025-10-23 16:50:41 +09:00
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 테이블 컨테이너 */}
|
2025-10-31 10:41:45 +09:00
|
|
|
<div className="flex-1 flex flex-col overflow-hidden w-full max-w-full">
|
2025-10-23 16:50:41 +09:00
|
|
|
{/* 스크롤 영역 */}
|
|
|
|
|
<div
|
2025-10-31 10:41:45 +09:00
|
|
|
className="w-full max-w-full h-[400px] overflow-y-scroll overflow-x-auto bg-background sm:h-[500px]"
|
2025-10-23 16:50:41 +09:00
|
|
|
>
|
|
|
|
|
{/* 테이블 */}
|
|
|
|
|
<table
|
2025-10-31 10:41:45 +09:00
|
|
|
className="w-full max-w-full table-mobile-fixed"
|
2025-09-29 17:24:06 +09:00
|
|
|
style={{
|
2025-10-23 16:50:41 +09:00
|
|
|
borderCollapse: "collapse",
|
2025-10-31 10:41:45 +09:00
|
|
|
width: "100%",
|
2025-11-03 12:01:47 +09:00
|
|
|
tableLayout: "fixed",
|
2025-09-24 18:07:36 +09:00
|
|
|
}}
|
|
|
|
|
>
|
2025-10-23 16:50:41 +09:00
|
|
|
{/* 헤더 (sticky) */}
|
|
|
|
|
<thead
|
2025-11-03 13:33:13 +09:00
|
|
|
className="sticky top-0 z-10"
|
2025-09-29 17:24:06 +09:00
|
|
|
>
|
2025-11-03 13:33:13 +09:00
|
|
|
<tr className="h-10 border-b-2 border-primary/20 bg-gradient-to-b from-muted/50 to-muted sm:h-12">
|
2025-11-03 10:54:23 +09:00
|
|
|
{visibleColumns.map((column, columnIndex) => {
|
|
|
|
|
const columnWidth = columnWidths[column.columnName];
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<th
|
|
|
|
|
key={column.columnName}
|
2025-11-03 12:18:50 +09:00
|
|
|
ref={(el) => (columnRefs.current[column.columnName] = el)}
|
2025-11-03 10:54:23 +09:00
|
|
|
className={cn(
|
2025-11-03 13:34:02 +09:00
|
|
|
"relative h-10 text-xs font-bold text-foreground/90 overflow-hidden text-ellipsis whitespace-nowrap select-none sm:h-12 sm:text-sm",
|
2025-11-03 12:28:30 +09:00
|
|
|
column.columnName === "__checkbox__" ? "px-0 py-2" : "px-2 py-2 sm:px-6 sm:py-3",
|
2025-11-03 13:33:13 +09:00
|
|
|
column.sortable && "cursor-pointer hover:bg-muted/70 transition-colors"
|
2025-11-03 10:54:23 +09:00
|
|
|
)}
|
|
|
|
|
style={{
|
2025-11-03 13:25:57 +09:00
|
|
|
textAlign: column.columnName === "__checkbox__" ? "center" : "center",
|
2025-11-03 12:24:28 +09:00
|
|
|
width: column.columnName === "__checkbox__" ? '48px' : (columnWidth ? `${columnWidth}px` : undefined),
|
|
|
|
|
minWidth: column.columnName === "__checkbox__" ? '48px' : undefined,
|
|
|
|
|
maxWidth: column.columnName === "__checkbox__" ? '48px' : undefined,
|
2025-11-03 10:54:23 +09:00
|
|
|
userSelect: 'none'
|
|
|
|
|
}}
|
2025-11-03 13:30:44 +09:00
|
|
|
onClick={() => {
|
|
|
|
|
if (isResizing.current) return;
|
|
|
|
|
if (column.sortable) handleSort(column.columnName);
|
|
|
|
|
}}
|
2025-11-03 10:54:23 +09:00
|
|
|
>
|
|
|
|
|
{column.columnName === "__checkbox__" ? (
|
|
|
|
|
renderCheckboxHeader()
|
|
|
|
|
) : (
|
|
|
|
|
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
|
|
|
|
<span>{columnLabels[column.columnName] || column.displayName}</span>
|
|
|
|
|
{column.sortable && sortColumn === column.columnName && (
|
|
|
|
|
<span>{sortDirection === "asc" ? "↑" : "↓"}</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-11-03 12:24:28 +09:00
|
|
|
{/* 리사이즈 핸들 (체크박스 제외) */}
|
|
|
|
|
{columnIndex < visibleColumns.length - 1 && column.columnName !== "__checkbox__" && (
|
2025-11-03 10:54:23 +09:00
|
|
|
<div
|
2025-11-03 11:51:48 +09:00
|
|
|
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' }}
|
2025-11-03 10:54:23 +09:00
|
|
|
onClick={(e) => e.stopPropagation()} // 정렬 클릭 방지
|
|
|
|
|
onMouseDown={(e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
2025-11-03 11:57:01 +09:00
|
|
|
const thElement = columnRefs.current[column.columnName];
|
2025-11-03 12:06:57 +09:00
|
|
|
if (!thElement) return;
|
2025-11-03 11:57:01 +09:00
|
|
|
|
2025-11-03 13:30:44 +09:00
|
|
|
isResizing.current = true;
|
|
|
|
|
|
2025-11-03 10:54:23 +09:00
|
|
|
const startX = e.clientX;
|
2025-11-03 11:57:01 +09:00
|
|
|
const startWidth = columnWidth || thElement.offsetWidth;
|
2025-11-03 10:54:23 +09:00
|
|
|
|
|
|
|
|
// 드래그 중 텍스트 선택 방지
|
|
|
|
|
document.body.style.userSelect = 'none';
|
|
|
|
|
document.body.style.cursor = 'col-resize';
|
|
|
|
|
|
|
|
|
|
const handleMouseMove = (moveEvent: MouseEvent) => {
|
|
|
|
|
moveEvent.preventDefault();
|
2025-11-03 11:55:45 +09:00
|
|
|
|
2025-11-03 11:57:01 +09:00
|
|
|
const diff = moveEvent.clientX - startX;
|
|
|
|
|
const newWidth = Math.max(80, startWidth + diff);
|
2025-11-03 11:55:45 +09:00
|
|
|
|
2025-11-03 11:57:01 +09:00
|
|
|
// 직접 DOM 스타일 변경 (리렌더링 없음)
|
|
|
|
|
if (thElement) {
|
|
|
|
|
thElement.style.width = `${newWidth}px`;
|
|
|
|
|
}
|
2025-11-03 10:54:23 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleMouseUp = () => {
|
2025-11-03 11:57:01 +09:00
|
|
|
// 최종 너비를 state에 저장
|
|
|
|
|
if (thElement) {
|
2025-11-03 12:06:57 +09:00
|
|
|
const finalWidth = Math.max(80, thElement.offsetWidth);
|
2025-11-03 11:57:01 +09:00
|
|
|
setColumnWidths(prev => ({ ...prev, [column.columnName]: finalWidth }));
|
2025-11-03 11:55:45 +09:00
|
|
|
}
|
|
|
|
|
|
2025-11-03 10:54:23 +09:00
|
|
|
// 텍스트 선택 복원
|
|
|
|
|
document.body.style.userSelect = '';
|
|
|
|
|
document.body.style.cursor = '';
|
|
|
|
|
|
2025-11-03 13:30:44 +09:00
|
|
|
// 약간의 지연 후 리사이즈 플래그 해제 (클릭 이벤트가 먼저 처리되지 않도록)
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
isResizing.current = false;
|
|
|
|
|
}, 100);
|
|
|
|
|
|
2025-11-03 10:54:23 +09:00
|
|
|
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>
|
2025-10-30 15:39:39 +09:00
|
|
|
<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>
|
2025-10-30 15:39:39 +09:00
|
|
|
<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>
|
2025-10-30 15:39:39 +09:00
|
|
|
<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>
|
2025-09-24 18:07:36 +09:00
|
|
|
</div>
|
2025-10-23 16:50:41 +09:00
|
|
|
</td>
|
|
|
|
|
</tr>
|
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}
|
|
|
|
|
draggable={!isDesignMode}
|
|
|
|
|
onDragStart={(e) => handleRowDragStart(e, row, index)}
|
|
|
|
|
onDragEnd={handleRowDragEnd}
|
2025-10-30 15:39:39 +09:00
|
|
|
className={cn(
|
2025-10-31 10:41:45 +09:00
|
|
|
"h-14 border-b transition-colors bg-background hover:bg-muted/50 cursor-pointer sm:h-16"
|
2025-10-30 15:39:39 +09:00
|
|
|
)}
|
2025-09-29 17:24:06 +09:00
|
|
|
onClick={() => handleRowClick(row)}
|
|
|
|
|
>
|
2025-10-23 16:50:41 +09:00
|
|
|
{visibleColumns.map((column) => {
|
|
|
|
|
const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName;
|
|
|
|
|
const cellValue = row[mappedColumnName];
|
|
|
|
|
|
2025-11-03 13:25:57 +09:00
|
|
|
const meta = columnMeta[column.columnName];
|
|
|
|
|
const inputType = meta?.inputType || column.inputType;
|
|
|
|
|
const isNumeric = inputType === "number" || inputType === "decimal";
|
|
|
|
|
|
2025-10-23 16:50:41 +09:00
|
|
|
return (
|
|
|
|
|
<td
|
|
|
|
|
key={column.columnName}
|
2025-10-30 15:39:39 +09:00
|
|
|
className={cn(
|
2025-11-03 13:34:02 +09:00
|
|
|
"h-14 text-xs text-foreground overflow-hidden text-ellipsis whitespace-nowrap sm:h-16 sm:text-sm",
|
2025-11-03 12:28:30 +09:00
|
|
|
column.columnName === "__checkbox__" ? "px-0 py-2" : "px-2 py-2 sm:px-6 sm:py-3"
|
2025-10-30 15:39:39 +09:00
|
|
|
)}
|
2025-10-23 16:50:41 +09:00
|
|
|
style={{
|
2025-11-03 13:25:57 +09:00
|
|
|
textAlign: column.columnName === "__checkbox__" ? "center" : (isNumeric ? "right" : (column.align || "left")),
|
2025-11-03 12:24:28 +09:00
|
|
|
width: column.columnName === "__checkbox__" ? '48px' : `${100 / visibleColumns.length}%`,
|
|
|
|
|
minWidth: column.columnName === "__checkbox__" ? '48px' : undefined,
|
|
|
|
|
maxWidth: column.columnName === "__checkbox__" ? '48px' : undefined,
|
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>
|
2025-09-24 18:07:36 +09:00
|
|
|
</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-11-03 13:59:12 +09:00
|
|
|
검색 필터로 사용할 컬럼을 선택하세요. 선택한 컬럼의 검색 입력 필드가 표시됩니다.
|
2025-10-23 16:50:41 +09:00
|
|
|
</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
2025-11-03 13:59:12 +09:00
|
|
|
<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>
|
|
|
|
|
</>
|
2025-09-15 11:43:59 +09:00
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const TableListWrapper: React.FC<TableListComponentProps> = (props) => {
|
|
|
|
|
return <TableListComponent {...props} />;
|
|
|
|
|
};
|