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

1290 lines
46 KiB
TypeScript

"use client";
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { TableListConfig, ColumnConfig } from "./types";
import { WebType } from "@/types/common";
import { tableTypeApi } from "@/lib/api/screen";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { codeCache } from "@/lib/caching/codeCache";
import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization";
import { 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";
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";
// ========================================
// 캐시 및 유틸리티
// ========================================
const tableColumnCache = new Map<string, { columns: any[]; timestamp: number }>();
const tableInfoCache = new Map<string, { tables: any[]; timestamp: number }>();
const TABLE_CACHE_TTL = 5 * 60 * 1000; // 5분
const cleanupTableCache = () => {
const now = Date.now();
for (const [key, entry] of tableColumnCache.entries()) {
if (now - entry.timestamp > TABLE_CACHE_TTL) {
tableColumnCache.delete(key);
}
}
for (const [key, entry] of tableInfoCache.entries()) {
if (now - entry.timestamp > TABLE_CACHE_TTL) {
tableInfoCache.delete(key);
}
}
};
if (typeof window !== "undefined") {
setInterval(cleanupTableCache, 10 * 60 * 1000);
}
const debounceTimers = new Map<string, NodeJS.Timeout>();
const activeRequests = new Map<string, Promise<any>>();
const debouncedApiCall = <T extends any[], R>(key: string, fn: (...args: T) => Promise<R>, delay: number = 300) => {
return (...args: T): Promise<R> => {
const activeRequest = activeRequests.get(key);
if (activeRequest) {
return activeRequest as Promise<R>;
}
return new Promise((resolve, reject) => {
const existingTimer = debounceTimers.get(key);
if (existingTimer) {
clearTimeout(existingTimer);
}
const timer = setTimeout(async () => {
try {
const requestPromise = fn(...args);
activeRequests.set(key, requestPromise);
const result = await requestPromise;
resolve(result);
} catch (error) {
reject(error);
} finally {
debounceTimers.delete(key);
activeRequests.delete(key);
}
}, delay);
debounceTimers.set(key, timer);
});
};
};
// ========================================
// Props 인터페이스
// ========================================
export interface TableListComponentProps {
component: any;
isDesignMode?: boolean;
isSelected?: boolean;
isInteractive?: boolean;
onClick?: () => void;
onDragStart?: (e: React.DragEvent) => void;
onDragEnd?: (e: React.DragEvent) => void;
className?: string;
style?: React.CSSProperties;
formData?: Record<string, any>;
onFormDataChange?: (data: any) => void;
config?: TableListConfig;
size?: { width: number; height: number };
position?: { x: number; y: number; z?: number };
componentConfig?: any;
selectedScreen?: any;
onZoneComponentDrop?: any;
onZoneClick?: any;
tableName?: string;
onRefresh?: () => void;
onClose?: () => void;
screenId?: string;
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void;
onConfigChange?: (config: any) => void;
refreshKey?: number;
}
// ========================================
// 메인 컴포넌트
// ========================================
export const TableListComponent: React.FC<TableListComponentProps> = ({
component,
isDesignMode = false,
isSelected = false,
onClick,
onDragStart,
onDragEnd,
config,
className,
style,
onFormDataChange,
componentConfig,
onSelectedRowsChange,
onConfigChange,
refreshKey,
tableName,
}) => {
// ========================================
// 설정 및 스타일
// ========================================
const tableConfig = {
...config,
...component.config,
...componentConfig,
} as TableListConfig;
// selectedTable 안전하게 추출 (문자열인지 확인)
let finalSelectedTable =
componentConfig?.selectedTable || component.config?.selectedTable || config?.selectedTable || tableName;
console.log("🔍 TableListComponent 초기화:", {
componentConfigSelectedTable: componentConfig?.selectedTable,
componentConfigSelectedTableType: typeof componentConfig?.selectedTable,
componentConfigSelectedTable2: component.config?.selectedTable,
componentConfigSelectedTable2Type: typeof component.config?.selectedTable,
configSelectedTable: config?.selectedTable,
configSelectedTableType: typeof config?.selectedTable,
screenTableName: tableName,
screenTableNameType: typeof tableName,
finalSelectedTable,
finalSelectedTableType: typeof finalSelectedTable,
});
// 객체인 경우 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";
const buttonTextColor = component.config?.buttonTextColor || "#ffffff";
const gridColumns = component.gridColumns || 1;
let calculatedWidth: string;
if (isDesignMode) {
if (gridColumns === 1) {
calculatedWidth = "400px";
} else if (gridColumns === 2) {
calculatedWidth = "800px";
} else {
calculatedWidth = "100%";
}
} else {
calculatedWidth = "100%";
}
const componentStyle: React.CSSProperties = {
width: calculatedWidth,
height: isDesignMode ? "auto" : "100%",
minHeight: isDesignMode ? "300px" : "100%",
display: "flex",
flexDirection: "column",
backgroundColor: "hsl(var(--background))",
overflow: "hidden",
...style,
};
// ========================================
// 상태 관리
// ========================================
const [data, setData] = useState<Record<string, any>[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [totalItems, setTotalItems] = useState(0);
const [searchTerm, setSearchTerm] = useState("");
const [sortColumn, setSortColumn] = useState<string | null>(null);
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({});
const [tableLabel, setTableLabel] = useState<string>("");
const [localPageSize, setLocalPageSize] = useState<number>(tableConfig.pagination?.pageSize || 20);
const [displayColumns, setDisplayColumns] = useState<ColumnConfig[]>([]);
const [joinColumnMapping, setJoinColumnMapping] = useState<Record<string, string>>({});
const [columnMeta, setColumnMeta] = useState<Record<string, { webType?: string; codeCategory?: string }>>({});
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
const [isDragging, setIsDragging] = useState(false);
const [draggedRowIndex, setDraggedRowIndex] = useState<number | null>(null);
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
const columnRefs = useRef<Record<string, HTMLTableCellElement | null>>({});
const [isAllSelected, setIsAllSelected] = useState(false);
const hasInitializedWidths = useRef(false);
const isResizing = useRef(false);
// 필터 설정 관련 상태
const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false);
const [visibleFilterColumns, setVisibleFilterColumns] = useState<Set<string>>(new Set());
const { optimizedConvertCode } = useEntityJoinOptimization(columnMeta, {
enableBatchLoading: true,
preloadCommonCodes: true,
maxBatchSize: 5,
});
// ========================================
// 컬럼 라벨 가져오기
// ========================================
const fetchColumnLabels = async () => {
if (!tableConfig.selectedTable) return;
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,
};
});
setColumnLabels(labels);
setColumnMeta(meta);
return;
}
const columns = await tableTypeApi.getColumns(tableConfig.selectedTable);
// 컬럼 입력 타입 정보 가져오기
const inputTypes = await tableTypeApi.getColumnInputTypes(tableConfig.selectedTable);
const inputTypeMap: Record<string, string> = {};
inputTypes.forEach((col: any) => {
inputTypeMap[col.columnName] = col.inputType;
});
tableColumnCache.set(cacheKey, {
columns,
inputTypes,
timestamp: Date.now(),
});
const labels: Record<string, string> = {};
const meta: Record<string, { webType?: string; codeCategory?: string; inputType?: string }> = {};
columns.forEach((col: any) => {
labels[col.columnName] = col.displayName || col.comment || col.columnName;
meta[col.columnName] = {
webType: col.webType,
codeCategory: col.codeCategory,
inputType: inputTypeMap[col.columnName],
};
});
setColumnLabels(labels);
setColumnMeta(meta);
} catch (error) {
console.error("컬럼 라벨 가져오기 실패:", error);
}
};
// ========================================
// 테이블 라벨 가져오기
// ========================================
const fetchTableLabel = async () => {
if (!tableConfig.selectedTable) return;
try {
const cacheKey = `table_info_${tableConfig.selectedTable}`;
const cached = tableInfoCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < TABLE_CACHE_TTL) {
const tables = cached.tables || [];
const tableInfo = tables.find((t: any) => t.tableName === tableConfig.selectedTable);
const label =
tableInfo?.displayName || (tableInfo as any)?.comment || tableInfo?.description || tableConfig.selectedTable;
setTableLabel(label);
return;
}
const tables = await tableTypeApi.getTables();
tableInfoCache.set(cacheKey, {
tables,
timestamp: Date.now(),
});
const tableInfo = tables.find((t: any) => t.tableName === tableConfig.selectedTable);
const label =
tableInfo?.displayName || (tableInfo as any)?.comment || tableInfo?.description || tableConfig.selectedTable;
setTableLabel(label);
} catch (error) {
console.error("테이블 라벨 가져오기 실패:", error);
}
};
// ========================================
// 데이터 가져오기
// ========================================
const fetchTableDataInternal = useCallback(async () => {
if (!tableConfig.selectedTable || isDesignMode) {
setData([]);
setTotalPages(0);
setTotalItems(0);
return;
}
// 테이블명 확인 로그
console.log("🔍 fetchTableDataInternal - selectedTable:", tableConfig.selectedTable);
console.log("🔍 selectedTable 타입:", typeof tableConfig.selectedTable);
console.log("🔍 전체 tableConfig:", tableConfig);
setLoading(true);
setError(null);
try {
const page = tableConfig.pagination?.currentPage || currentPage;
const pageSize = localPageSize;
const sortBy = sortColumn || undefined;
const sortOrder = sortDirection;
const search = searchTerm || undefined;
const filters = Object.keys(searchValues).length > 0 ? searchValues : undefined;
const entityJoinColumns = (tableConfig.columns || [])
.filter((col) => col.additionalJoinInfo)
.map((col) => ({
sourceTable: col.additionalJoinInfo!.sourceTable,
sourceColumn: col.additionalJoinInfo!.sourceColumn,
joinAlias: col.additionalJoinInfo!.joinAlias,
referenceTable: col.additionalJoinInfo!.referenceTable,
}));
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,
});
}
setData(response.data || []);
setTotalPages(response.totalPages || 0);
setTotalItems(response.total || 0);
setError(null);
} catch (err: any) {
console.error("데이터 가져오기 실패:", err);
setData([]);
setTotalPages(0);
setTotalItems(0);
setError(err.message || "데이터를 불러오지 못했습니다.");
} finally {
setLoading(false);
}
}, [
tableConfig.selectedTable,
tableConfig.pagination?.currentPage,
tableConfig.columns,
currentPage,
localPageSize,
sortColumn,
sortDirection,
searchTerm,
searchValues,
isDesignMode,
]);
const fetchTableDataDebounced = useCallback(
(...args: Parameters<typeof fetchTableDataInternal>) => {
const key = `fetchData_${tableConfig.selectedTable}_${currentPage}_${sortColumn}_${sortDirection}`;
return debouncedApiCall(key, fetchTableDataInternal, 300)(...args);
},
[fetchTableDataInternal, tableConfig.selectedTable, currentPage, sortColumn, sortDirection],
);
// ========================================
// 이벤트 핸들러
// ========================================
const handlePageChange = (newPage: number) => {
if (newPage < 1 || newPage > totalPages) return;
setCurrentPage(newPage);
if (tableConfig.pagination) {
tableConfig.pagination.currentPage = newPage;
}
if (onConfigChange) {
onConfigChange({ ...tableConfig, pagination: { ...tableConfig.pagination, currentPage: newPage } });
}
};
const handleSort = (column: string) => {
if (sortColumn === column) {
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
} else {
setSortColumn(column);
setSortDirection("asc");
}
};
const handleSearchValueChange = (columnName: string, value: any) => {
setSearchValues((prev) => ({ ...prev, [columnName]: value }));
};
const handleAdvancedSearch = () => {
setCurrentPage(1);
fetchTableDataDebounced();
};
const handleClearAdvancedFilters = () => {
setSearchValues({});
setCurrentPage(1);
fetchTableDataDebounced();
};
const handleRefresh = () => {
fetchTableDataDebounced();
};
const getRowKey = (row: any, index: number) => {
return row.id || row.uuid || `row-${index}`;
};
const handleRowSelection = (rowKey: string, checked: boolean) => {
const newSelectedRows = new Set(selectedRows);
if (checked) {
newSelectedRows.add(rowKey);
} else {
newSelectedRows.delete(rowKey);
}
setSelectedRows(newSelectedRows);
const selectedRowsData = data.filter((row, index) => newSelectedRows.has(getRowKey(row, index)));
if (onSelectedRowsChange) {
onSelectedRowsChange(Array.from(newSelectedRows), selectedRowsData);
}
if (onFormDataChange) {
onFormDataChange({ selectedRows: Array.from(newSelectedRows), selectedRowsData });
}
const allRowsSelected = data.every((row, index) => newSelectedRows.has(getRowKey(row, index)));
setIsAllSelected(allRowsSelected && data.length > 0);
};
const handleSelectAll = (checked: boolean) => {
if (checked) {
const allKeys = data.map((row, index) => getRowKey(row, index));
const newSelectedRows = new Set(allKeys);
setSelectedRows(newSelectedRows);
setIsAllSelected(true);
if (onSelectedRowsChange) {
onSelectedRowsChange(Array.from(newSelectedRows), data);
}
if (onFormDataChange) {
onFormDataChange({ selectedRows: Array.from(newSelectedRows), selectedRowsData: data });
}
} else {
setSelectedRows(new Set());
setIsAllSelected(false);
if (onSelectedRowsChange) {
onSelectedRowsChange([], []);
}
if (onFormDataChange) {
onFormDataChange({ selectedRows: [], selectedRowsData: [] });
}
}
};
const handleRowClick = (row: any) => {
console.log("행 클릭:", row);
};
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));
};
const handleRowDragEnd = (e: React.DragEvent) => {
setIsDragging(false);
setDraggedRowIndex(null);
};
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.();
};
// ========================================
// 컬럼 관련
// ========================================
const visibleColumns = useMemo(() => {
let cols = (tableConfig.columns || []).filter((col) => col.visible !== false);
if (tableConfig.checkbox?.enabled) {
const checkboxCol: ColumnConfig = {
columnName: "__checkbox__",
displayName: "",
visible: true,
sortable: false,
searchable: false,
width: 50,
align: "center",
order: -1,
};
if (tableConfig.checkbox.position === "right") {
cols = [...cols, checkboxCol];
} else {
cols = [checkboxCol, ...cols];
}
}
return cols.sort((a, b) => (a.order || 0) - (b.order || 0));
}, [tableConfig.columns, tableConfig.checkbox]);
const getColumnWidth = (column: ColumnConfig) => {
if (column.columnName === "__checkbox__") return 50;
if (column.width) return column.width;
switch (column.format) {
case "date":
return 120;
case "number":
case "currency":
return 100;
case "boolean":
return 80;
default:
return 150;
}
};
const renderCheckboxHeader = () => {
if (!tableConfig.checkbox?.selectAll) return null;
return <Checkbox checked={isAllSelected} onCheckedChange={handleSelectAll} aria-label="전체 선택" />;
};
const renderCheckboxCell = (row: any, index: number) => {
const rowKey = getRowKey(row, index);
const isChecked = selectedRows.has(rowKey);
return (
<Checkbox
checked={isChecked}
onCheckedChange={(checked) => handleRowSelection(rowKey, checked as boolean)}
aria-label={`${index + 1} 선택`}
/>
);
};
const formatCellValue = useCallback(
(value: any, column: ColumnConfig, rowData?: Record<string, any>) => {
if (value === null || value === undefined) return "-";
// 🎯 엔티티 컬럼 표시 설정이 있는 경우
if (column.entityDisplayConfig && rowData) {
// displayColumns 또는 selectedColumns 둘 다 체크
const displayColumns = column.entityDisplayConfig.displayColumns || column.entityDisplayConfig.selectedColumns;
const separator = column.entityDisplayConfig.separator;
if (displayColumns && displayColumns.length > 0) {
// 선택된 컬럼들의 값을 구분자로 조합
const values = displayColumns
.map((colName) => {
const cellValue = rowData[colName];
if (cellValue === null || cellValue === undefined) return "";
return String(cellValue);
})
.filter((v) => v !== ""); // 빈 값 제외
return values.join(separator || " - ");
}
}
const meta = columnMeta[column.columnName];
if (meta?.webType && meta?.codeCategory) {
const convertedValue = optimizedConvertCode(value, meta.codeCategory);
if (convertedValue !== value) return convertedValue;
}
// inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선)
const inputType = meta?.inputType || column.inputType;
if (inputType === "number" || inputType === "decimal") {
if (value !== null && value !== undefined && value !== "") {
const numValue = typeof value === "string" ? parseFloat(value) : value;
if (!isNaN(numValue)) {
return numValue.toLocaleString("ko-KR");
}
}
return String(value);
}
switch (column.format) {
case "number":
if (value !== null && value !== undefined && value !== "") {
const numValue = typeof value === "string" ? parseFloat(value) : value;
if (!isNaN(numValue)) {
return numValue.toLocaleString("ko-KR");
}
}
return String(value);
case "date":
if (value) {
try {
const date = new Date(value);
return date.toLocaleDateString("ko-KR");
} catch {
return value;
}
}
return "-";
case "number":
return typeof value === "number" ? value.toLocaleString() : value;
case "currency":
return typeof value === "number" ? `${value.toLocaleString()}` : value;
case "boolean":
return value ? "예" : "아니오";
default:
return String(value);
}
},
[columnMeta, optimizedConvertCode],
);
// ========================================
// useEffect 훅
// ========================================
// 필터 설정 localStorage 키 생성
const filterSettingKey = useMemo(() => {
if (!tableConfig.selectedTable) return null;
return `tableList_filterSettings_${tableConfig.selectedTable}`;
}, [tableConfig.selectedTable]);
// 저장된 필터 설정 불러오기
useEffect(() => {
if (!filterSettingKey) return;
try {
const saved = localStorage.getItem(filterSettingKey);
if (saved) {
const savedFilters = JSON.parse(saved);
setVisibleFilterColumns(new Set(savedFilters));
} else {
// 초기값: 모든 필터 표시
const allFilters = (tableConfig.filter?.filters || []).map((f) => f.columnName);
setVisibleFilterColumns(new Set(allFilters));
}
} catch (error) {
console.error("필터 설정 불러오기 실패:", error);
// 기본값으로 모든 필터 표시
const allFilters = (tableConfig.filter?.filters || []).map((f) => f.columnName);
setVisibleFilterColumns(new Set(allFilters));
}
}, [filterSettingKey, tableConfig.filter?.filters]);
// 필터 설정 저장
const saveFilterSettings = useCallback(() => {
if (!filterSettingKey) return;
try {
localStorage.setItem(filterSettingKey, JSON.stringify(Array.from(visibleFilterColumns)));
setIsFilterSettingOpen(false);
} catch (error) {
console.error("필터 설정 저장 실패:", error);
}
}, [filterSettingKey, visibleFilterColumns]);
// 필터 토글
const toggleFilterVisibility = useCallback((columnName: string) => {
setVisibleFilterColumns((prev) => {
const newSet = new Set(prev);
if (newSet.has(columnName)) {
newSet.delete(columnName);
} else {
newSet.add(columnName);
}
return newSet;
});
}, []);
// 표시할 필터 목록
const activeFilters = useMemo(() => {
return (tableConfig.filter?.filters || []).filter((f) => visibleFilterColumns.has(f.columnName));
}, [tableConfig.filter?.filters, visibleFilterColumns]);
useEffect(() => {
fetchColumnLabels();
fetchTableLabel();
}, [tableConfig.selectedTable]);
useEffect(() => {
if (!isDesignMode && tableConfig.selectedTable) {
fetchTableDataDebounced();
}
}, [
tableConfig.selectedTable,
currentPage,
localPageSize,
sortColumn,
sortDirection,
searchTerm,
refreshKey,
isDesignMode,
]);
useEffect(() => {
if (tableConfig.refreshInterval && !isDesignMode) {
const interval = setInterval(() => {
fetchTableDataDebounced();
}, tableConfig.refreshInterval * 1000);
return () => clearInterval(interval);
}
}, [tableConfig.refreshInterval, isDesignMode]);
// 초기 컬럼 너비 측정 (한 번만)
useEffect(() => {
if (!hasInitializedWidths.current && visibleColumns.length > 0) {
// 약간의 지연을 두고 DOM이 완전히 렌더링된 후 측정
const timer = setTimeout(() => {
const newWidths: Record<string, number> = {};
let hasAnyWidth = false;
visibleColumns.forEach((column) => {
// 체크박스 컬럼은 제외 (고정 48px)
if (column.columnName === "__checkbox__") return;
const thElement = columnRefs.current[column.columnName];
if (thElement) {
const measuredWidth = thElement.offsetWidth;
if (measuredWidth > 0) {
newWidths[column.columnName] = measuredWidth;
hasAnyWidth = true;
}
}
});
if (hasAnyWidth) {
setColumnWidths(newWidths);
hasInitializedWidths.current = true;
}
}, 100);
return () => clearTimeout(timer);
}
}, [visibleColumns]);
// ========================================
// 페이지네이션 JSX
// ========================================
const paginationJSX = useMemo(() => {
if (!tableConfig.pagination?.enabled || isDesignMode) return null;
return (
<div
className="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"
>
{/* 중앙 페이지네이션 컨트롤 */}
<div
className="flex items-center gap-2 sm:gap-4"
>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(1)}
disabled={currentPage === 1 || loading}
className="h-8 w-8 p-0 sm:h-9 sm:w-auto sm:px-3"
>
<ChevronsLeft className="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1 || loading}
className="h-8 w-8 p-0 sm:h-9 sm:w-auto sm:px-3"
>
<ChevronLeft className="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
<span className="text-xs font-medium text-foreground min-w-[60px] text-center sm:text-sm sm:min-w-[80px]">
{currentPage} / {totalPages || 1}
</span>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= totalPages || loading}
className="h-8 w-8 p-0 sm:h-9 sm:w-auto sm:px-3"
>
<ChevronRight className="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(totalPages)}
disabled={currentPage >= totalPages || loading}
className="h-8 w-8 p-0 sm:h-9 sm:w-auto sm:px-3"
>
<ChevronsRight className="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
<span className="text-[10px] text-muted-foreground ml-2 sm:text-xs sm:ml-4">
{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"
>
<RefreshCw className={cn("h-3 w-3", loading && "animate-spin")} />
</Button>
</div>
);
}, [tableConfig.pagination, isDesignMode, currentPage, totalPages, totalItems, loading]);
// ========================================
// 렌더링
// ========================================
const domProps = {
onClick: handleClick,
onDragStart: isDesignMode ? onDragStart : undefined,
onDragEnd: isDesignMode ? onDragEnd : undefined,
draggable: isDesignMode,
className: cn(className, isDesignMode && "cursor-move"),
style: componentStyle,
};
// 카드 모드
if (tableConfig.displayMode === "card" && !isDesignMode) {
return (
<div {...domProps}>
<CardModeRenderer
data={data}
loading={loading}
error={error}
cardConfig={tableConfig.cardConfig}
onRowClick={handleRowClick}
/>
{paginationJSX}
</div>
);
}
// SingleTableWithSticky 모드
if (tableConfig.stickyHeader && !isDesignMode) {
return (
<div {...domProps}>
{tableConfig.showHeader && (
<div className="px-4 py-3 border-b border-border sm:px-6 sm:py-4">
<h2 className="text-base font-semibold text-foreground sm:text-lg">
{tableConfig.title || tableLabel || finalSelectedTable}
</h2>
</div>
)}
{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">
<AdvancedSearchFilters
filters={activeFilters}
searchValues={searchValues}
onSearchValueChange={handleSearchValueChange}
onSearch={handleAdvancedSearch}
onClear={handleClearAdvancedFilters}
/>
</div>
<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>
</div>
</div>
)}
<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}
formatCellValue={(value: any, format?: string, columnName?: string, rowData?: Record<string, any>) => {
const column = visibleColumns.find((c) => c.columnName === columnName);
return column ? formatCellValue(value, column, rowData) : String(value);
}}
getColumnWidth={getColumnWidth}
containerWidth={calculatedWidth}
/>
</div>
{paginationJSX}
</div>
);
}
// 일반 테이블 모드 (네이티브 HTML 테이블)
return (
<>
<div {...domProps}>
{/* 헤더 */}
{tableConfig.showHeader && (
<div className="px-4 py-3 border-b border-border flex-shrink-0 sm:px-6 sm:py-4">
<h2 className="text-base font-semibold text-foreground sm:text-lg">
{tableConfig.title || tableLabel || finalSelectedTable}
</h2>
</div>
)}
{/* 필터 */}
{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">
<AdvancedSearchFilters
filters={activeFilters}
searchValues={searchValues}
onSearchValueChange={handleSearchValueChange}
onSearch={handleAdvancedSearch}
onClear={handleClearAdvancedFilters}
/>
</div>
<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>
</div>
</div>
)}
{/* 테이블 컨테이너 */}
<div className="flex-1 flex flex-col overflow-hidden w-full max-w-full">
{/* 스크롤 영역 */}
<div
className="w-full max-w-full h-[400px] overflow-y-scroll overflow-x-auto bg-background sm:h-[500px]"
>
{/* 테이블 */}
<table
className="w-full max-w-full table-mobile-fixed"
style={{
borderCollapse: "collapse",
width: "100%",
tableLayout: "fixed",
}}
>
{/* 헤더 (sticky) */}
<thead
className="sticky top-0 z-10"
>
<tr className="h-10 border-b-2 border-primary/20 bg-gradient-to-b from-muted/50 to-muted sm:h-12">
{visibleColumns.map((column, columnIndex) => {
const columnWidth = columnWidths[column.columnName];
return (
<th
key={column.columnName}
ref={(el) => (columnRefs.current[column.columnName] = el)}
className={cn(
"relative h-10 text-xs font-bold text-foreground/90 overflow-hidden text-ellipsis select-none sm:h-12 sm:text-sm sm:whitespace-nowrap",
column.columnName === "__checkbox__" ? "px-0 py-2" : "px-2 py-2 sm:px-6 sm:py-3",
column.sortable && "cursor-pointer hover:bg-muted/70 transition-colors"
)}
style={{
textAlign: column.columnName === "__checkbox__" ? "center" : "center",
width: column.columnName === "__checkbox__" ? '48px' : (columnWidth ? `${columnWidth}px` : undefined),
minWidth: column.columnName === "__checkbox__" ? '48px' : undefined,
maxWidth: column.columnName === "__checkbox__" ? '48px' : undefined,
userSelect: 'none'
}}
onClick={() => {
if (isResizing.current) return;
if (column.sortable) handleSort(column.columnName);
}}
>
{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>
)}
{/* 리사이즈 핸들 (체크박스 제외) */}
{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>
);
})}
</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>
</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>
</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">
</div>
</div>
</td>
</tr>
) : (
data.map((row, index) => (
<tr
key={index}
draggable={!isDesignMode}
onDragStart={(e) => handleRowDragStart(e, row, index)}
onDragEnd={handleRowDragEnd}
className={cn(
"h-14 border-b transition-colors bg-background hover:bg-muted/50 cursor-pointer sm:h-16"
)}
onClick={() => handleRowClick(row)}
>
{visibleColumns.map((column) => {
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";
return (
<td
key={column.columnName}
className={cn(
"h-14 text-xs text-foreground overflow-hidden text-ellipsis sm:h-16 sm:text-sm sm:whitespace-nowrap",
column.columnName === "__checkbox__" ? "px-0 py-2" : "px-2 py-2 sm:px-6 sm:py-3"
)}
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,
}}
>
{column.columnName === "__checkbox__"
? renderCheckboxCell(row, index)
: formatCellValue(cellValue, column, row)}
</td>
);
})}
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* 페이지네이션 */}
{paginationJSX}
</div>
{/* 필터 설정 다이얼로그 */}
<Dialog open={isFilterSettingOpen} onOpenChange={setIsFilterSettingOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
. .
</DialogDescription>
</DialogHeader>
<div className="max-h-[60vh] space-y-3 overflow-y-auto sm:space-y-4">
{(tableConfig.filter?.filters || []).map((filter) => (
<div className="flex items-center gap-3 rounded p-2 hover:bg-muted">
<Checkbox
id={`filter-${filter.columnName}`}
checked={visibleFilterColumns.has(filter.columnName)}
onCheckedChange={() => toggleFilterVisibility(filter.columnName)}
/>
<Label
htmlFor={`filter-${filter.columnName}`}
className="flex-1 cursor-pointer text-xs font-normal sm:text-sm"
>
{columnLabels[filter.columnName] || filter.label || filter.columnName}
</Label>
</div>
))}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setIsFilterSettingOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button onClick={saveFilterSettings} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
export const TableListWrapper: React.FC<TableListComponentProps> = (props) => {
return <TableListComponent {...props} />;
};