2460 lines
92 KiB
TypeScript
2460 lines
92 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
|
import { TableListConfig, ColumnConfig } from "./types";
|
|
import { WebType } from "@/types/common";
|
|
import { tableTypeApi } from "@/lib/api/screen";
|
|
import { entityJoinApi } from "@/lib/api/entityJoin";
|
|
import { codeCache } from "@/lib/caching/codeCache";
|
|
import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization";
|
|
import { getFullImageUrl } from "@/lib/api/client";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
ChevronsLeft,
|
|
ChevronsRight,
|
|
RefreshCw,
|
|
ArrowUp,
|
|
ArrowDown,
|
|
TableIcon,
|
|
Settings,
|
|
X,
|
|
Layers,
|
|
ChevronDown,
|
|
} from "lucide-react";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { cn } from "@/lib/utils";
|
|
import { toast } from "sonner";
|
|
import { tableDisplayStore } from "@/stores/tableDisplayStore";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import { Label } from "@/components/ui/label";
|
|
import { AdvancedSearchFilters } from "@/components/screen/filters/AdvancedSearchFilters";
|
|
import { SingleTableWithSticky } from "./SingleTableWithSticky";
|
|
import { CardModeRenderer } from "./CardModeRenderer";
|
|
import { TableOptionsModal } from "@/components/common/TableOptionsModal";
|
|
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
|
import { TableFilter, ColumnVisibility } from "@/types/table-options";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
|
|
// ========================================
|
|
// 인터페이스
|
|
// ========================================
|
|
|
|
// 그룹화된 데이터 인터페이스
|
|
interface GroupedData {
|
|
groupKey: string;
|
|
groupValues: Record<string, any>;
|
|
items: any[];
|
|
count: number;
|
|
}
|
|
|
|
// ========================================
|
|
// 캐시 및 유틸리티
|
|
// ========================================
|
|
|
|
const tableColumnCache = new Map<string, { columns: any[]; timestamp: number }>();
|
|
const tableInfoCache = new Map<string, { tables: any[]; timestamp: number }>();
|
|
const TABLE_CACHE_TTL = 5 * 60 * 1000; // 5분
|
|
|
|
const cleanupTableCache = () => {
|
|
const now = Date.now();
|
|
for (const [key, entry] of tableColumnCache.entries()) {
|
|
if (now - entry.timestamp > TABLE_CACHE_TTL) {
|
|
tableColumnCache.delete(key);
|
|
}
|
|
}
|
|
for (const [key, entry] of tableInfoCache.entries()) {
|
|
if (now - entry.timestamp > TABLE_CACHE_TTL) {
|
|
tableInfoCache.delete(key);
|
|
}
|
|
}
|
|
};
|
|
|
|
if (typeof window !== "undefined") {
|
|
setInterval(cleanupTableCache, 10 * 60 * 1000);
|
|
}
|
|
|
|
const debounceTimers = new Map<string, NodeJS.Timeout>();
|
|
const activeRequests = new Map<string, Promise<any>>();
|
|
|
|
const debouncedApiCall = <T extends any[], R>(key: string, fn: (...args: T) => Promise<R>, delay: number = 300) => {
|
|
return (...args: T): Promise<R> => {
|
|
const activeRequest = activeRequests.get(key);
|
|
if (activeRequest) {
|
|
return activeRequest as Promise<R>;
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const existingTimer = debounceTimers.get(key);
|
|
if (existingTimer) {
|
|
clearTimeout(existingTimer);
|
|
}
|
|
|
|
const timer = setTimeout(async () => {
|
|
try {
|
|
const requestPromise = fn(...args);
|
|
activeRequests.set(key, requestPromise);
|
|
const result = await requestPromise;
|
|
resolve(result);
|
|
} catch (error) {
|
|
reject(error);
|
|
} finally {
|
|
debounceTimers.delete(key);
|
|
activeRequests.delete(key);
|
|
}
|
|
}, delay);
|
|
|
|
debounceTimers.set(key, timer);
|
|
});
|
|
};
|
|
};
|
|
|
|
// ========================================
|
|
// Props 인터페이스
|
|
// ========================================
|
|
|
|
export interface TableListComponentProps {
|
|
component: any;
|
|
isDesignMode?: boolean;
|
|
isSelected?: boolean;
|
|
isInteractive?: boolean;
|
|
onClick?: () => void;
|
|
onDragStart?: (e: React.DragEvent) => void;
|
|
onDragEnd?: (e: React.DragEvent) => void;
|
|
className?: string;
|
|
style?: React.CSSProperties;
|
|
formData?: Record<string, any>;
|
|
onFormDataChange?: (data: any) => void;
|
|
config?: TableListConfig;
|
|
size?: { width: number; height: number };
|
|
position?: { x: number; y: number; z?: number };
|
|
componentConfig?: any;
|
|
selectedScreen?: any;
|
|
onZoneComponentDrop?: any;
|
|
onZoneClick?: any;
|
|
tableName?: string;
|
|
onRefresh?: () => void;
|
|
onClose?: () => void;
|
|
screenId?: string;
|
|
userId?: string; // 사용자 ID (컬럼 순서 저장용)
|
|
onSelectedRowsChange?: (
|
|
selectedRows: any[],
|
|
selectedRowsData: any[],
|
|
sortBy?: string,
|
|
sortOrder?: "asc" | "desc",
|
|
columnOrder?: string[],
|
|
tableDisplayData?: any[],
|
|
) => void;
|
|
onConfigChange?: (config: any) => void;
|
|
refreshKey?: number;
|
|
}
|
|
|
|
// ========================================
|
|
// 메인 컴포넌트
|
|
// ========================================
|
|
|
|
export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|
component,
|
|
isDesignMode = false,
|
|
isSelected = false,
|
|
onClick,
|
|
onDragStart,
|
|
onDragEnd,
|
|
config,
|
|
className,
|
|
style,
|
|
onFormDataChange,
|
|
componentConfig,
|
|
onSelectedRowsChange,
|
|
onConfigChange,
|
|
refreshKey,
|
|
tableName,
|
|
userId,
|
|
}) => {
|
|
// ========================================
|
|
// 설정 및 스타일
|
|
// ========================================
|
|
|
|
const tableConfig = {
|
|
...config,
|
|
...component.config,
|
|
...componentConfig,
|
|
} as TableListConfig;
|
|
|
|
// selectedTable 안전하게 추출 (문자열인지 확인)
|
|
let finalSelectedTable =
|
|
componentConfig?.selectedTable || component.config?.selectedTable || config?.selectedTable || tableName;
|
|
|
|
// 디버그 로그 제거 (성능 최적화)
|
|
|
|
// 객체인 경우 tableName 속성 추출 시도
|
|
if (typeof finalSelectedTable === "object" && finalSelectedTable !== null) {
|
|
console.warn("⚠️ selectedTable이 객체입니다:", finalSelectedTable);
|
|
finalSelectedTable = (finalSelectedTable as any).tableName || (finalSelectedTable as any).name || tableName;
|
|
console.log("✅ 객체에서 추출한 테이블명:", finalSelectedTable);
|
|
}
|
|
|
|
tableConfig.selectedTable = finalSelectedTable;
|
|
|
|
// 디버그 로그 제거 (성능 최적화)
|
|
|
|
const buttonColor = component.style?.labelColor || "#212121";
|
|
const buttonTextColor = component.config?.buttonTextColor || "#ffffff";
|
|
|
|
const gridColumns = component.gridColumns || 1;
|
|
let calculatedWidth: string;
|
|
|
|
if (isDesignMode) {
|
|
if (gridColumns === 1) {
|
|
calculatedWidth = "400px";
|
|
} else if (gridColumns === 2) {
|
|
calculatedWidth = "800px";
|
|
} else {
|
|
calculatedWidth = "100%";
|
|
}
|
|
} else {
|
|
calculatedWidth = "100%";
|
|
}
|
|
|
|
const componentStyle: React.CSSProperties = {
|
|
width: calculatedWidth,
|
|
height: isDesignMode ? "auto" : "100%",
|
|
minHeight: isDesignMode ? "300px" : "100%",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
backgroundColor: "hsl(var(--background))",
|
|
overflow: "hidden",
|
|
...style,
|
|
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
|
width: "100%",
|
|
};
|
|
|
|
// ========================================
|
|
// 상태 관리
|
|
// ========================================
|
|
|
|
// 사용자 정보 (props에서 받거나 useAuth에서 가져오기)
|
|
const { userId: authUserId } = useAuth();
|
|
const currentUserId = userId || authUserId;
|
|
|
|
// TableOptions Context
|
|
const { registerTable, unregisterTable, updateTableDataCount } = useTableOptions();
|
|
const [filters, setFilters] = useState<TableFilter[]>([]);
|
|
const [grouping, setGrouping] = useState<string[]>([]);
|
|
const [columnVisibility, setColumnVisibility] = useState<ColumnVisibility[]>([]);
|
|
|
|
// filters가 변경되면 searchValues 업데이트 (실시간 검색)
|
|
useEffect(() => {
|
|
const newSearchValues: Record<string, any> = {};
|
|
filters.forEach((filter) => {
|
|
if (filter.value) {
|
|
newSearchValues[filter.columnName] = filter.value;
|
|
}
|
|
});
|
|
|
|
console.log("🔍 [TableListComponent] filters → searchValues:", {
|
|
filters: filters.length,
|
|
searchValues: newSearchValues,
|
|
});
|
|
|
|
setSearchValues(newSearchValues);
|
|
setCurrentPage(1); // 필터 변경 시 첫 페이지로
|
|
}, [filters]);
|
|
|
|
// grouping이 변경되면 groupByColumns 업데이트
|
|
useEffect(() => {
|
|
setGroupByColumns(grouping);
|
|
}, [grouping]);
|
|
|
|
// 초기 로드 시 localStorage에서 저장된 설정 불러오기
|
|
useEffect(() => {
|
|
if (tableConfig.selectedTable && currentUserId) {
|
|
const storageKey = `table_column_visibility_${tableConfig.selectedTable}_${currentUserId}`;
|
|
const savedSettings = localStorage.getItem(storageKey);
|
|
|
|
if (savedSettings) {
|
|
try {
|
|
const parsed = JSON.parse(savedSettings) as ColumnVisibility[];
|
|
setColumnVisibility(parsed);
|
|
} catch (error) {
|
|
console.error("저장된 컬럼 설정 불러오기 실패:", error);
|
|
}
|
|
}
|
|
}
|
|
}, [tableConfig.selectedTable, currentUserId]);
|
|
|
|
// columnVisibility 변경 시 컬럼 순서 및 가시성 적용
|
|
useEffect(() => {
|
|
if (columnVisibility.length > 0) {
|
|
const newOrder = columnVisibility
|
|
.map((cv) => cv.columnName)
|
|
.filter((name) => name !== "__checkbox__"); // 체크박스 제외
|
|
setColumnOrder(newOrder);
|
|
|
|
// localStorage에 저장 (사용자별)
|
|
if (tableConfig.selectedTable && currentUserId) {
|
|
const storageKey = `table_column_visibility_${tableConfig.selectedTable}_${currentUserId}`;
|
|
localStorage.setItem(storageKey, JSON.stringify(columnVisibility));
|
|
}
|
|
}
|
|
}, [columnVisibility, tableConfig.selectedTable, currentUserId]);
|
|
|
|
const [data, setData] = useState<Record<string, any>[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [totalPages, setTotalPages] = useState(0);
|
|
const [totalItems, setTotalItems] = useState(0);
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [sortColumn, setSortColumn] = useState<string | null>(null);
|
|
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
|
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({});
|
|
const [tableLabel, setTableLabel] = useState<string>("");
|
|
const [localPageSize, setLocalPageSize] = useState<number>(tableConfig.pagination?.pageSize || 20);
|
|
const [displayColumns, setDisplayColumns] = useState<ColumnConfig[]>([]);
|
|
const [joinColumnMapping, setJoinColumnMapping] = useState<Record<string, string>>({});
|
|
const [columnMeta, setColumnMeta] = useState<
|
|
Record<string, { webType?: string; codeCategory?: string; inputType?: string }>
|
|
>({});
|
|
const [categoryMappings, setCategoryMappings] = useState<
|
|
Record<string, Record<string, { label: string; color?: string }>>
|
|
>({});
|
|
const [categoryMappingsKey, setCategoryMappingsKey] = useState(0); // 강제 리렌더링용
|
|
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
|
|
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
|
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
|
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
|
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
|
const columnRefs = useRef<Record<string, HTMLTableCellElement | null>>({});
|
|
const [isAllSelected, setIsAllSelected] = useState(false);
|
|
const hasInitializedWidths = useRef(false);
|
|
const isResizing = useRef(false);
|
|
|
|
// 필터 설정 관련 상태
|
|
const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false);
|
|
const [visibleFilterColumns, setVisibleFilterColumns] = useState<Set<string>>(new Set());
|
|
|
|
// 그룹 설정 관련 상태
|
|
const [groupByColumns, setGroupByColumns] = useState<string[]>([]);
|
|
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
|
|
|
// 사용자 옵션 모달 관련 상태
|
|
const [isTableOptionsOpen, setIsTableOptionsOpen] = useState(false);
|
|
const [showGridLines, setShowGridLines] = useState(true);
|
|
const [viewMode, setViewMode] = useState<"table" | "card" | "grouped-card">("table");
|
|
const [frozenColumns, setFrozenColumns] = useState<string[]>([]);
|
|
|
|
// 테이블 등록 (Context에 등록)
|
|
const tableId = `table-list-${component.id}`;
|
|
|
|
useEffect(() => {
|
|
// tableConfig.columns를 직접 사용 (displayColumns는 비어있을 수 있음)
|
|
const columnsToRegister = (tableConfig.columns || [])
|
|
.filter((col) => col.visible !== false && col.columnName !== "__checkbox__");
|
|
|
|
if (!tableConfig.selectedTable || !columnsToRegister || columnsToRegister.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// 컬럼의 고유 값 조회 함수
|
|
const getColumnUniqueValues = async (columnName: string) => {
|
|
console.log("🔍 [getColumnUniqueValues] 호출됨:", {
|
|
columnName,
|
|
dataLength: data.length,
|
|
columnMeta: columnMeta[columnName],
|
|
sampleData: data[0],
|
|
});
|
|
|
|
const meta = columnMeta[columnName];
|
|
const inputType = meta?.inputType || "text";
|
|
|
|
// 카테고리 타입인 경우 전체 정의된 값 조회 (백엔드 API)
|
|
if (inputType === "category") {
|
|
try {
|
|
console.log("🔍 [getColumnUniqueValues] 카테고리 전체 값 조회:", {
|
|
tableName: tableConfig.selectedTable,
|
|
columnName,
|
|
});
|
|
|
|
// API 클라이언트 사용 (쿠키 인증 자동 처리)
|
|
const { apiClient } = await import("@/lib/api/client");
|
|
const response = await apiClient.get(
|
|
`/table-categories/${tableConfig.selectedTable}/${columnName}/values`
|
|
);
|
|
|
|
if (response.data.success && response.data.data) {
|
|
const categoryOptions = response.data.data.map((item: any) => ({
|
|
value: item.valueCode, // 카멜케이스
|
|
label: item.valueLabel, // 카멜케이스
|
|
}));
|
|
|
|
console.log("✅ [getColumnUniqueValues] 카테고리 전체 값:", {
|
|
columnName,
|
|
count: categoryOptions.length,
|
|
options: categoryOptions,
|
|
});
|
|
|
|
return categoryOptions;
|
|
} else {
|
|
console.warn("⚠️ [getColumnUniqueValues] 응답 형식 오류:", response.data);
|
|
}
|
|
} catch (error: any) {
|
|
console.error("❌ [getColumnUniqueValues] 카테고리 조회 실패:", {
|
|
error: error.message,
|
|
response: error.response?.data,
|
|
status: error.response?.status,
|
|
columnName,
|
|
tableName: tableConfig.selectedTable,
|
|
});
|
|
// 에러 시 현재 데이터 기반으로 fallback
|
|
}
|
|
}
|
|
|
|
// 일반 타입 또는 카테고리 조회 실패 시: 현재 데이터 기반
|
|
const isLabelType = ["category", "entity", "code"].includes(inputType);
|
|
const labelField = isLabelType ? `${columnName}_name` : columnName;
|
|
|
|
console.log("🔍 [getColumnUniqueValues] 데이터 기반 조회:", {
|
|
columnName,
|
|
inputType,
|
|
isLabelType,
|
|
labelField,
|
|
hasLabelField: data[0] && labelField in data[0],
|
|
sampleLabelValue: data[0] ? data[0][labelField] : undefined,
|
|
});
|
|
|
|
// 현재 로드된 데이터에서 고유 값 추출
|
|
const uniqueValuesMap = new Map<string, string>(); // value -> label
|
|
|
|
data.forEach((row) => {
|
|
const value = row[columnName];
|
|
if (value !== null && value !== undefined && value !== "") {
|
|
// 백엔드 조인된 _name 필드 사용 (없으면 원본 값)
|
|
const label = isLabelType && row[labelField] ? row[labelField] : String(value);
|
|
uniqueValuesMap.set(String(value), label);
|
|
}
|
|
});
|
|
|
|
// Map을 배열로 변환하고 라벨 기준으로 정렬
|
|
const result = Array.from(uniqueValuesMap.entries())
|
|
.map(([value, label]) => ({
|
|
value: value,
|
|
label: label,
|
|
}))
|
|
.sort((a, b) => a.label.localeCompare(b.label));
|
|
|
|
console.log("✅ [getColumnUniqueValues] 데이터 기반 결과:", {
|
|
columnName,
|
|
inputType,
|
|
isLabelType,
|
|
labelField,
|
|
uniqueCount: result.length,
|
|
values: result,
|
|
});
|
|
|
|
return result;
|
|
};
|
|
|
|
const registration = {
|
|
tableId,
|
|
label: tableLabel || tableConfig.selectedTable,
|
|
tableName: tableConfig.selectedTable,
|
|
dataCount: totalItems || data.length, // 초기 데이터 건수 포함
|
|
columns: columnsToRegister.map((col) => ({
|
|
columnName: col.columnName || col.field,
|
|
columnLabel: columnLabels[col.columnName] || col.displayName || col.label || col.columnName || col.field,
|
|
inputType: columnMeta[col.columnName]?.inputType || "text",
|
|
visible: col.visible !== false,
|
|
width: columnWidths[col.columnName] || col.width || 150,
|
|
sortable: col.sortable !== false,
|
|
filterable: col.searchable !== false,
|
|
})),
|
|
onFilterChange: setFilters,
|
|
onGroupChange: setGrouping,
|
|
onColumnVisibilityChange: setColumnVisibility,
|
|
getColumnUniqueValues, // 고유 값 조회 함수 등록
|
|
};
|
|
|
|
registerTable(registration);
|
|
|
|
return () => {
|
|
unregisterTable(tableId);
|
|
};
|
|
}, [
|
|
tableId,
|
|
tableConfig.selectedTable,
|
|
tableConfig.columns,
|
|
columnLabels,
|
|
columnMeta, // columnMeta가 변경되면 재등록 (inputType 정보 필요)
|
|
columnWidths,
|
|
tableLabel,
|
|
data, // 데이터 자체가 변경되면 재등록 (고유 값 조회용)
|
|
totalItems, // 전체 항목 수가 변경되면 재등록
|
|
registerTable,
|
|
unregisterTable,
|
|
]);
|
|
|
|
// 🆕 초기 로드 시 localStorage에서 컬럼 순서 불러오기
|
|
useEffect(() => {
|
|
if (!tableConfig.selectedTable || !userId) return;
|
|
|
|
const userKey = userId || "guest";
|
|
const storageKey = `table_column_order_${tableConfig.selectedTable}_${userKey}`;
|
|
const savedOrder = localStorage.getItem(storageKey);
|
|
|
|
if (savedOrder) {
|
|
try {
|
|
const parsedOrder = JSON.parse(savedOrder);
|
|
console.log("📂 localStorage에서 컬럼 순서 불러오기:", { storageKey, columnOrder: parsedOrder });
|
|
setColumnOrder(parsedOrder);
|
|
|
|
// 부모 컴포넌트에 초기 컬럼 순서 전달
|
|
if (onSelectedRowsChange && parsedOrder.length > 0) {
|
|
console.log("✅ 초기 컬럼 순서 전달:", parsedOrder);
|
|
|
|
// 초기 데이터도 함께 전달 (컬럼 순서대로 재정렬)
|
|
const initialData = data.map((row: any) => {
|
|
const reordered: any = {};
|
|
parsedOrder.forEach((colName: string) => {
|
|
if (colName in row) {
|
|
reordered[colName] = row[colName];
|
|
}
|
|
});
|
|
// 나머지 컬럼 추가
|
|
Object.keys(row).forEach((key) => {
|
|
if (!(key in reordered)) {
|
|
reordered[key] = row[key];
|
|
}
|
|
});
|
|
return reordered;
|
|
});
|
|
|
|
// 전역 저장소에 데이터 저장
|
|
if (tableConfig.selectedTable) {
|
|
// 컬럼 라벨 매핑 생성
|
|
const labels: Record<string, string> = {};
|
|
visibleColumns.forEach((col) => {
|
|
labels[col.columnName] = columnLabels[col.columnName] || col.columnName;
|
|
});
|
|
|
|
tableDisplayStore.setTableData(
|
|
tableConfig.selectedTable,
|
|
initialData,
|
|
parsedOrder.filter((col) => col !== "__checkbox__"),
|
|
sortColumn,
|
|
sortDirection,
|
|
{
|
|
filterConditions: Object.keys(searchValues).length > 0 ? searchValues : undefined,
|
|
searchTerm: searchTerm || undefined,
|
|
visibleColumns: visibleColumns.map((col) => col.columnName),
|
|
columnLabels: labels,
|
|
currentPage: currentPage,
|
|
pageSize: localPageSize,
|
|
totalItems: totalItems,
|
|
},
|
|
);
|
|
}
|
|
|
|
onSelectedRowsChange([], [], sortColumn, sortDirection, parsedOrder, initialData);
|
|
}
|
|
} catch (error) {
|
|
console.error("❌ 컬럼 순서 파싱 실패:", error);
|
|
}
|
|
}
|
|
}, [tableConfig.selectedTable, userId, data.length]); // data.length 추가 (데이터 로드 후 실행)
|
|
|
|
const { optimizedConvertCode } = useEntityJoinOptimization(columnMeta, {
|
|
enableBatchLoading: true,
|
|
preloadCommonCodes: true,
|
|
maxBatchSize: 5,
|
|
});
|
|
|
|
// ========================================
|
|
// 컬럼 라벨 가져오기
|
|
// ========================================
|
|
|
|
const fetchColumnLabels = useCallback(async () => {
|
|
if (!tableConfig.selectedTable) return;
|
|
|
|
try {
|
|
// 🔥 FIX: 캐시 키에 회사 코드 포함 (멀티테넌시 지원)
|
|
const currentUser = JSON.parse(localStorage.getItem("currentUser") || "{}");
|
|
const companyCode = currentUser.companyCode || "UNKNOWN";
|
|
const cacheKey = `columns_${tableConfig.selectedTable}_${companyCode}`;
|
|
const cached = tableColumnCache.get(cacheKey);
|
|
if (cached && Date.now() - cached.timestamp < TABLE_CACHE_TTL) {
|
|
const labels: Record<string, string> = {};
|
|
const meta: Record<string, { webType?: string; codeCategory?: string; inputType?: string }> = {};
|
|
|
|
// 캐시된 inputTypes 맵 생성
|
|
const inputTypeMap: Record<string, string> = {};
|
|
if (cached.inputTypes) {
|
|
cached.inputTypes.forEach((col: any) => {
|
|
inputTypeMap[col.columnName] = col.inputType;
|
|
});
|
|
}
|
|
|
|
cached.columns.forEach((col: any) => {
|
|
labels[col.columnName] = col.displayName || col.comment || col.columnName;
|
|
meta[col.columnName] = {
|
|
webType: col.webType,
|
|
codeCategory: col.codeCategory,
|
|
inputType: inputTypeMap[col.columnName], // 캐시된 inputType 사용!
|
|
};
|
|
});
|
|
|
|
setColumnLabels(labels);
|
|
setColumnMeta(meta);
|
|
return;
|
|
}
|
|
|
|
const columns = await tableTypeApi.getColumns(tableConfig.selectedTable);
|
|
|
|
// 컬럼 입력 타입 정보 가져오기
|
|
const inputTypes = await tableTypeApi.getColumnInputTypes(tableConfig.selectedTable);
|
|
const inputTypeMap: Record<string, string> = {};
|
|
inputTypes.forEach((col: any) => {
|
|
inputTypeMap[col.columnName] = col.inputType;
|
|
});
|
|
|
|
tableColumnCache.set(cacheKey, {
|
|
columns,
|
|
inputTypes,
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
const labels: Record<string, string> = {};
|
|
const meta: Record<string, { webType?: string; codeCategory?: string; inputType?: string }> = {};
|
|
|
|
columns.forEach((col: any) => {
|
|
labels[col.columnName] = col.displayName || col.comment || col.columnName;
|
|
meta[col.columnName] = {
|
|
webType: col.webType,
|
|
codeCategory: col.codeCategory,
|
|
inputType: inputTypeMap[col.columnName],
|
|
};
|
|
});
|
|
|
|
setColumnLabels(labels);
|
|
setColumnMeta(meta);
|
|
} catch (error) {
|
|
console.error("컬럼 라벨 가져오기 실패:", error);
|
|
}
|
|
}, [tableConfig.selectedTable]);
|
|
|
|
// ========================================
|
|
// 테이블 라벨 가져오기
|
|
// ========================================
|
|
|
|
const fetchTableLabel = useCallback(async () => {
|
|
if (!tableConfig.selectedTable) return;
|
|
|
|
try {
|
|
const cacheKey = `table_info_${tableConfig.selectedTable}`;
|
|
const cached = tableInfoCache.get(cacheKey);
|
|
if (cached && Date.now() - cached.timestamp < TABLE_CACHE_TTL) {
|
|
const tables = cached.tables || [];
|
|
const tableInfo = tables.find((t: any) => t.tableName === tableConfig.selectedTable);
|
|
const label =
|
|
tableInfo?.displayName || (tableInfo as any)?.comment || tableInfo?.description || tableConfig.selectedTable;
|
|
setTableLabel(label);
|
|
return;
|
|
}
|
|
|
|
const tables = await tableTypeApi.getTables();
|
|
|
|
tableInfoCache.set(cacheKey, {
|
|
tables,
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
const tableInfo = tables.find((t: any) => t.tableName === tableConfig.selectedTable);
|
|
const label =
|
|
tableInfo?.displayName || (tableInfo as any)?.comment || tableInfo?.description || tableConfig.selectedTable;
|
|
setTableLabel(label);
|
|
} catch (error) {
|
|
console.error("테이블 라벨 가져오기 실패:", error);
|
|
}
|
|
}, [tableConfig.selectedTable]);
|
|
|
|
// ========================================
|
|
// 카테고리 값 매핑 로드
|
|
// ========================================
|
|
|
|
// 카테고리 컬럼 목록 추출 (useMemo로 최적화)
|
|
const categoryColumns = useMemo(() => {
|
|
const cols = Object.entries(columnMeta)
|
|
.filter(([_, meta]) => meta.inputType === "category")
|
|
.map(([columnName, _]) => columnName);
|
|
|
|
return cols;
|
|
}, [columnMeta]);
|
|
|
|
// 카테고리 매핑 로드 (columnMeta 변경 시 즉시 실행)
|
|
useEffect(() => {
|
|
const loadCategoryMappings = async () => {
|
|
if (!tableConfig.selectedTable) {
|
|
return;
|
|
}
|
|
|
|
if (categoryColumns.length === 0) {
|
|
setCategoryMappings({});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
|
|
|
|
for (const columnName of categoryColumns) {
|
|
try {
|
|
console.log(`📡 [TableList] API 호출 시작 [${columnName}]:`, {
|
|
url: `/table-categories/${tableConfig.selectedTable}/${columnName}/values`,
|
|
});
|
|
|
|
const apiClient = (await import("@/lib/api/client")).apiClient;
|
|
const response = await apiClient.get(`/table-categories/${tableConfig.selectedTable}/${columnName}/values`);
|
|
|
|
console.log(`📡 [TableList] API 응답 [${columnName}]:`, {
|
|
success: response.data.success,
|
|
dataLength: response.data.data?.length,
|
|
rawData: response.data,
|
|
items: response.data.data,
|
|
});
|
|
|
|
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
|
const mapping: Record<string, { label: string; color?: string }> = {};
|
|
|
|
response.data.data.forEach((item: any) => {
|
|
// valueCode를 문자열로 변환하여 키로 사용
|
|
const key = String(item.valueCode);
|
|
mapping[key] = {
|
|
label: item.valueLabel,
|
|
color: item.color,
|
|
};
|
|
console.log(` 🔑 [${columnName}] "${key}" => "${item.valueLabel}" (색상: ${item.color})`);
|
|
});
|
|
|
|
if (Object.keys(mapping).length > 0) {
|
|
mappings[columnName] = mapping;
|
|
console.log(`✅ [TableList] 카테고리 매핑 로드 완료 [${columnName}]:`, {
|
|
columnName,
|
|
mappingCount: Object.keys(mapping).length,
|
|
mappingKeys: Object.keys(mapping),
|
|
mapping,
|
|
});
|
|
} else {
|
|
console.warn(`⚠️ [TableList] 매핑 데이터가 비어있음 [${columnName}]`);
|
|
}
|
|
} else {
|
|
console.warn(`⚠️ [TableList] 카테고리 값 없음 [${columnName}]:`, {
|
|
success: response.data.success,
|
|
hasData: !!response.data.data,
|
|
isArray: Array.isArray(response.data.data),
|
|
response: response.data,
|
|
});
|
|
}
|
|
} catch (error: any) {
|
|
console.error(`❌ [TableList] 카테고리 값 로드 실패 [${columnName}]:`, {
|
|
error: error.message,
|
|
stack: error.stack,
|
|
response: error.response?.data,
|
|
status: error.response?.status,
|
|
});
|
|
}
|
|
}
|
|
|
|
console.log("📊 [TableList] 전체 카테고리 매핑 설정:", {
|
|
mappingsCount: Object.keys(mappings).length,
|
|
mappingsKeys: Object.keys(mappings),
|
|
mappings,
|
|
});
|
|
|
|
if (Object.keys(mappings).length > 0) {
|
|
setCategoryMappings(mappings);
|
|
setCategoryMappingsKey((prev) => prev + 1);
|
|
console.log("✅ [TableList] setCategoryMappings 호출 완료");
|
|
} else {
|
|
console.warn("⚠️ [TableList] 매핑이 비어있어 상태 업데이트 스킵");
|
|
}
|
|
} catch (error) {
|
|
console.error("❌ [TableList] 카테고리 매핑 로드 실패:", error);
|
|
}
|
|
};
|
|
|
|
loadCategoryMappings();
|
|
}, [tableConfig.selectedTable, categoryColumns.length, JSON.stringify(categoryColumns)]); // 더 명확한 의존성
|
|
|
|
// ========================================
|
|
// 데이터 가져오기
|
|
// ========================================
|
|
|
|
const fetchTableDataInternal = useCallback(async () => {
|
|
console.log("📡 [TableList] fetchTableDataInternal 호출됨", {
|
|
tableName: tableConfig.selectedTable,
|
|
isDesignMode,
|
|
currentPage,
|
|
});
|
|
|
|
if (!tableConfig.selectedTable || isDesignMode) {
|
|
setData([]);
|
|
setTotalPages(0);
|
|
setTotalItems(0);
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const page = tableConfig.pagination?.currentPage || currentPage;
|
|
const pageSize = localPageSize;
|
|
const sortBy = sortColumn || undefined;
|
|
const sortOrder = sortDirection;
|
|
const search = searchTerm || undefined;
|
|
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,
|
|
}));
|
|
|
|
console.log("🔍 [TableList] API 호출 시작", {
|
|
tableName: tableConfig.selectedTable,
|
|
page,
|
|
pageSize,
|
|
sortBy,
|
|
sortOrder,
|
|
});
|
|
|
|
// 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원)
|
|
const response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
|
|
page,
|
|
size: pageSize,
|
|
sortBy,
|
|
sortOrder,
|
|
search: filters,
|
|
enableEntityJoin: true,
|
|
additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined,
|
|
dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달
|
|
});
|
|
|
|
// 실제 데이터의 item_number만 추출하여 중복 확인
|
|
const itemNumbers = (response.data || []).map((item: any) => item.item_number);
|
|
const uniqueItemNumbers = [...new Set(itemNumbers)];
|
|
|
|
console.log("✅ [TableList] API 응답 받음");
|
|
console.log(` - dataLength: ${response.data?.length || 0}`);
|
|
console.log(` - total: ${response.total}`);
|
|
console.log(` - itemNumbers: ${JSON.stringify(itemNumbers)}`);
|
|
console.log(` - uniqueItemNumbers: ${JSON.stringify(uniqueItemNumbers)}`);
|
|
console.log(` - isDuplicated: ${itemNumbers.length !== uniqueItemNumbers.length}`);
|
|
|
|
setData(response.data || []);
|
|
setTotalPages(response.totalPages || 0);
|
|
setTotalItems(response.total || 0);
|
|
setError(null);
|
|
|
|
// 🎯 Store에 필터 조건 저장 (엑셀 다운로드용)
|
|
const labels: Record<string, string> = {};
|
|
visibleColumns.forEach((col) => {
|
|
labels[col.columnName] = columnLabels[col.columnName] || col.columnName;
|
|
});
|
|
|
|
tableDisplayStore.setTableData(
|
|
tableConfig.selectedTable,
|
|
response.data || [],
|
|
visibleColumns.map((col) => col.columnName),
|
|
sortBy,
|
|
sortOrder,
|
|
{
|
|
filterConditions: filters,
|
|
searchTerm: search,
|
|
visibleColumns: visibleColumns.map((col) => col.columnName),
|
|
columnLabels: labels,
|
|
currentPage: page,
|
|
pageSize: pageSize,
|
|
totalItems: response.total || 0,
|
|
}
|
|
);
|
|
} catch (err: any) {
|
|
console.error("데이터 가져오기 실패:", err);
|
|
setData([]);
|
|
setTotalPages(0);
|
|
setTotalItems(0);
|
|
setError(err.message || "데이터를 불러오지 못했습니다.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [
|
|
tableConfig.selectedTable,
|
|
tableConfig.pagination?.currentPage,
|
|
tableConfig.columns,
|
|
currentPage,
|
|
localPageSize,
|
|
sortColumn,
|
|
sortDirection,
|
|
searchTerm,
|
|
searchValues,
|
|
isDesignMode,
|
|
]);
|
|
|
|
const fetchTableDataDebounced = useCallback(
|
|
(...args: Parameters<typeof fetchTableDataInternal>) => {
|
|
const key = `fetchData_${tableConfig.selectedTable}_${currentPage}_${sortColumn}_${sortDirection}`;
|
|
return debouncedApiCall(key, fetchTableDataInternal, 300)(...args);
|
|
},
|
|
[fetchTableDataInternal, tableConfig.selectedTable, currentPage, sortColumn, sortDirection],
|
|
);
|
|
|
|
// ========================================
|
|
// 이벤트 핸들러
|
|
// ========================================
|
|
|
|
const handlePageChange = (newPage: number) => {
|
|
if (newPage < 1 || newPage > totalPages) return;
|
|
setCurrentPage(newPage);
|
|
if (tableConfig.pagination) {
|
|
tableConfig.pagination.currentPage = newPage;
|
|
}
|
|
if (onConfigChange) {
|
|
onConfigChange({ ...tableConfig, pagination: { ...tableConfig.pagination, currentPage: newPage } });
|
|
}
|
|
};
|
|
|
|
const handleSort = (column: string) => {
|
|
console.log("🔄 정렬 클릭:", { column, currentSortColumn: sortColumn, currentSortDirection: sortDirection });
|
|
|
|
let newSortColumn = column;
|
|
let newSortDirection: "asc" | "desc" = "asc";
|
|
|
|
if (sortColumn === column) {
|
|
newSortDirection = sortDirection === "asc" ? "desc" : "asc";
|
|
setSortDirection(newSortDirection);
|
|
} else {
|
|
setSortColumn(column);
|
|
setSortDirection("asc");
|
|
newSortColumn = column;
|
|
newSortDirection = "asc";
|
|
}
|
|
|
|
console.log("📊 새로운 정렬 정보:", { newSortColumn, newSortDirection });
|
|
console.log("🔍 onSelectedRowsChange 존재 여부:", !!onSelectedRowsChange);
|
|
|
|
// 정렬 변경 시 선택 정보와 함께 정렬 정보도 전달
|
|
if (onSelectedRowsChange) {
|
|
const selectedRowsData = data.filter((row, index) => selectedRows.has(getRowKey(row, index)));
|
|
|
|
// 1단계: 데이터를 정렬
|
|
const sortedData = [...data].sort((a, b) => {
|
|
const aVal = a[newSortColumn];
|
|
const bVal = b[newSortColumn];
|
|
|
|
// null/undefined 처리
|
|
if (aVal == null && bVal == null) return 0;
|
|
if (aVal == null) return 1;
|
|
if (bVal == null) return -1;
|
|
|
|
// 숫자 비교 (문자열이어도 숫자로 변환 가능하면 숫자로 비교)
|
|
const aNum = Number(aVal);
|
|
const bNum = Number(bVal);
|
|
|
|
// 둘 다 유효한 숫자이고, 원본 값이 빈 문자열이 아닌 경우
|
|
if (!isNaN(aNum) && !isNaN(bNum) && aVal !== "" && bVal !== "") {
|
|
return newSortDirection === "desc" ? bNum - aNum : aNum - bNum;
|
|
}
|
|
|
|
// 문자열 비교 (대소문자 구분 없이, 숫자 포함 문자열도 자연스럽게 정렬)
|
|
const aStr = String(aVal).toLowerCase();
|
|
const bStr = String(bVal).toLowerCase();
|
|
|
|
// 자연스러운 정렬 (숫자 포함 문자열)
|
|
const comparison = aStr.localeCompare(bStr, undefined, { numeric: true, sensitivity: "base" });
|
|
return newSortDirection === "desc" ? -comparison : comparison;
|
|
});
|
|
|
|
// 2단계: 정렬된 데이터를 컬럼 순서대로 재정렬
|
|
const reorderedData = sortedData.map((row: any) => {
|
|
const reordered: any = {};
|
|
visibleColumns.forEach((col) => {
|
|
if (col.columnName in row) {
|
|
reordered[col.columnName] = row[col.columnName];
|
|
}
|
|
});
|
|
// 나머지 컬럼 추가
|
|
Object.keys(row).forEach((key) => {
|
|
if (!(key in reordered)) {
|
|
reordered[key] = row[key];
|
|
}
|
|
});
|
|
return reordered;
|
|
});
|
|
|
|
console.log("✅ 정렬 정보 전달:", {
|
|
selectedRowsCount: selectedRows.size,
|
|
selectedRowsDataCount: selectedRowsData.length,
|
|
sortBy: newSortColumn,
|
|
sortOrder: newSortDirection,
|
|
columnOrder: columnOrder.length > 0 ? columnOrder : undefined,
|
|
tableDisplayDataCount: reorderedData.length,
|
|
firstRowAfterSort: reorderedData[0]?.[newSortColumn],
|
|
lastRowAfterSort: reorderedData[reorderedData.length - 1]?.[newSortColumn],
|
|
});
|
|
onSelectedRowsChange(
|
|
Array.from(selectedRows),
|
|
selectedRowsData,
|
|
newSortColumn,
|
|
newSortDirection,
|
|
columnOrder.length > 0 ? columnOrder : undefined,
|
|
reorderedData,
|
|
);
|
|
|
|
// 전역 저장소에 정렬된 데이터 저장
|
|
if (tableConfig.selectedTable) {
|
|
const cleanColumnOrder = (
|
|
columnOrder.length > 0 ? columnOrder : visibleColumns.map((c) => c.columnName)
|
|
).filter((col) => col !== "__checkbox__");
|
|
|
|
// 컬럼 라벨 정보도 함께 저장
|
|
const labels: Record<string, string> = {};
|
|
visibleColumns.forEach((col) => {
|
|
labels[col.columnName] = columnLabels[col.columnName] || col.columnName;
|
|
});
|
|
|
|
tableDisplayStore.setTableData(
|
|
tableConfig.selectedTable,
|
|
reorderedData,
|
|
cleanColumnOrder,
|
|
newSortColumn,
|
|
newSortDirection,
|
|
{
|
|
filterConditions: Object.keys(searchValues).length > 0 ? searchValues : undefined,
|
|
searchTerm: searchTerm || undefined,
|
|
visibleColumns: visibleColumns.map((col) => col.columnName),
|
|
columnLabels: labels,
|
|
currentPage: currentPage,
|
|
pageSize: localPageSize,
|
|
totalItems: totalItems,
|
|
},
|
|
);
|
|
}
|
|
} else {
|
|
console.warn("⚠️ onSelectedRowsChange 콜백이 없습니다!");
|
|
}
|
|
};
|
|
|
|
const handleSearchValueChange = (columnName: string, value: any) => {
|
|
setSearchValues((prev) => ({ ...prev, [columnName]: value }));
|
|
};
|
|
|
|
const handleAdvancedSearch = () => {
|
|
setCurrentPage(1);
|
|
fetchTableDataDebounced();
|
|
};
|
|
|
|
const handleClearAdvancedFilters = useCallback(() => {
|
|
console.log("🔄 필터 초기화 시작", { 이전searchValues: searchValues });
|
|
|
|
// 상태를 초기화하고 useEffect로 데이터 새로고침
|
|
setSearchValues({});
|
|
setCurrentPage(1);
|
|
|
|
// 강제로 데이터 새로고침 트리거
|
|
setRefreshTrigger((prev) => prev + 1);
|
|
}, [searchValues]);
|
|
|
|
const handleRefresh = () => {
|
|
fetchTableDataDebounced();
|
|
};
|
|
|
|
const getRowKey = (row: any, index: number) => {
|
|
return row.id || row.uuid || `row-${index}`;
|
|
};
|
|
|
|
const handleRowSelection = (rowKey: string, checked: boolean) => {
|
|
const newSelectedRows = new Set(selectedRows);
|
|
if (checked) {
|
|
newSelectedRows.add(rowKey);
|
|
} else {
|
|
newSelectedRows.delete(rowKey);
|
|
}
|
|
setSelectedRows(newSelectedRows);
|
|
|
|
const selectedRowsData = data.filter((row, index) => newSelectedRows.has(getRowKey(row, index)));
|
|
if (onSelectedRowsChange) {
|
|
onSelectedRowsChange(Array.from(newSelectedRows), selectedRowsData, sortColumn || undefined, sortDirection);
|
|
}
|
|
if (onFormDataChange) {
|
|
onFormDataChange({
|
|
selectedRows: Array.from(newSelectedRows),
|
|
selectedRowsData,
|
|
});
|
|
}
|
|
|
|
const allRowsSelected = data.every((row, index) => newSelectedRows.has(getRowKey(row, index)));
|
|
setIsAllSelected(allRowsSelected && data.length > 0);
|
|
};
|
|
|
|
const handleSelectAll = (checked: boolean) => {
|
|
if (checked) {
|
|
const allKeys = data.map((row, index) => getRowKey(row, index));
|
|
const newSelectedRows = new Set(allKeys);
|
|
setSelectedRows(newSelectedRows);
|
|
setIsAllSelected(true);
|
|
|
|
if (onSelectedRowsChange) {
|
|
onSelectedRowsChange(Array.from(newSelectedRows), data, sortColumn || undefined, sortDirection);
|
|
}
|
|
if (onFormDataChange) {
|
|
onFormDataChange({
|
|
selectedRows: Array.from(newSelectedRows),
|
|
selectedRowsData: data,
|
|
});
|
|
}
|
|
} else {
|
|
setSelectedRows(new Set());
|
|
setIsAllSelected(false);
|
|
|
|
if (onSelectedRowsChange) {
|
|
onSelectedRowsChange([], [], sortColumn || undefined, sortDirection);
|
|
}
|
|
if (onFormDataChange) {
|
|
onFormDataChange({ selectedRows: [], selectedRowsData: [] });
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleRowClick = (row: any, index: number, e: React.MouseEvent) => {
|
|
// 체크박스 클릭은 무시 (이미 handleRowSelection에서 처리됨)
|
|
const target = e.target as HTMLElement;
|
|
if (target.closest('input[type="checkbox"]')) {
|
|
return;
|
|
}
|
|
|
|
// 행 선택/해제 토글
|
|
const rowKey = getRowKey(row, index);
|
|
const isCurrentlySelected = selectedRows.has(rowKey);
|
|
|
|
handleRowSelection(rowKey, !isCurrentlySelected);
|
|
|
|
console.log("행 클릭:", { row, index, isSelected: !isCurrentlySelected });
|
|
};
|
|
|
|
// 컬럼 드래그앤드롭 기능 제거됨 (테이블 옵션 모달에서 컬럼 순서 변경 가능)
|
|
|
|
const handleClick = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
onClick?.();
|
|
};
|
|
|
|
// ========================================
|
|
// 컬럼 관련
|
|
// ========================================
|
|
|
|
const visibleColumns = useMemo(() => {
|
|
let cols = (tableConfig.columns || []).filter((col) => col.visible !== false);
|
|
|
|
// columnVisibility가 있으면 가시성 적용
|
|
if (columnVisibility.length > 0) {
|
|
cols = cols.filter((col) => {
|
|
const visibilityConfig = columnVisibility.find((cv) => cv.columnName === col.columnName);
|
|
return visibilityConfig ? visibilityConfig.visible : true;
|
|
});
|
|
}
|
|
|
|
// 체크박스 컬럼 (나중에 위치 결정)
|
|
let checkboxCol: ColumnConfig | null = null;
|
|
if (tableConfig.checkbox?.enabled) {
|
|
checkboxCol = {
|
|
columnName: "__checkbox__",
|
|
displayName: "",
|
|
visible: true,
|
|
sortable: false,
|
|
searchable: false,
|
|
width: 50,
|
|
align: "center",
|
|
order: -1,
|
|
};
|
|
}
|
|
|
|
// columnOrder 상태가 있으면 그 순서대로 정렬 (체크박스 제외)
|
|
if (columnOrder.length > 0) {
|
|
const orderedCols = columnOrder
|
|
.map((colName) => cols.find((c) => c.columnName === colName))
|
|
.filter(Boolean) as ColumnConfig[];
|
|
|
|
// columnOrder에 없는 새로운 컬럼들 추가
|
|
const remainingCols = cols.filter((c) => !columnOrder.includes(c.columnName));
|
|
|
|
cols = [...orderedCols, ...remainingCols];
|
|
} else {
|
|
cols = cols.sort((a, b) => (a.order || 0) - (b.order || 0));
|
|
}
|
|
|
|
// 체크박스를 맨 앞 또는 맨 뒤에 추가
|
|
if (checkboxCol) {
|
|
if (tableConfig.checkbox.position === "right") {
|
|
cols = [...cols, checkboxCol];
|
|
} else {
|
|
cols = [checkboxCol, ...cols];
|
|
}
|
|
}
|
|
|
|
return cols;
|
|
}, [tableConfig.columns, tableConfig.checkbox, columnOrder, columnVisibility]);
|
|
|
|
// 🆕 visibleColumns가 변경될 때마다 현재 컬럼 순서를 부모에게 전달
|
|
const lastColumnOrderRef = useRef<string>("");
|
|
|
|
useEffect(() => {
|
|
console.log("🔍 [컬럼 순서 전달 useEffect] 실행됨:", {
|
|
hasCallback: !!onSelectedRowsChange,
|
|
visibleColumnsLength: visibleColumns.length,
|
|
visibleColumnsNames: visibleColumns.map((c) => c.columnName),
|
|
});
|
|
|
|
if (!onSelectedRowsChange) {
|
|
console.warn("⚠️ onSelectedRowsChange 콜백이 없습니다!");
|
|
return;
|
|
}
|
|
|
|
if (visibleColumns.length === 0) {
|
|
console.warn("⚠️ visibleColumns가 비어있습니다!");
|
|
return;
|
|
}
|
|
|
|
const currentColumnOrder = visibleColumns.map((col) => col.columnName).filter((name) => name !== "__checkbox__"); // 체크박스 컬럼 제외
|
|
|
|
console.log("🔍 [컬럼 순서] 체크박스 제외 후:", currentColumnOrder);
|
|
|
|
// 컬럼 순서가 실제로 변경되었을 때만 전달 (무한 루프 방지)
|
|
const columnOrderString = currentColumnOrder.join(",");
|
|
console.log("🔍 [컬럼 순서] 비교:", {
|
|
current: columnOrderString,
|
|
last: lastColumnOrderRef.current,
|
|
isDifferent: columnOrderString !== lastColumnOrderRef.current,
|
|
});
|
|
|
|
if (columnOrderString === lastColumnOrderRef.current) {
|
|
console.log("⏭️ 컬럼 순서 변경 없음, 전달 스킵");
|
|
return;
|
|
}
|
|
|
|
lastColumnOrderRef.current = columnOrderString;
|
|
console.log("📊 현재 화면 컬럼 순서 전달:", currentColumnOrder);
|
|
|
|
// 선택된 행 데이터 가져오기
|
|
const selectedRowsData = data.filter((row, index) => selectedRows.has(getRowKey(row, index)));
|
|
|
|
// 화면에 표시된 데이터를 컬럼 순서대로 재정렬
|
|
const reorderedData = data.map((row: any) => {
|
|
const reordered: any = {};
|
|
visibleColumns.forEach((col) => {
|
|
if (col.columnName in row) {
|
|
reordered[col.columnName] = row[col.columnName];
|
|
}
|
|
});
|
|
// 나머지 컬럼 추가
|
|
Object.keys(row).forEach((key) => {
|
|
if (!(key in reordered)) {
|
|
reordered[key] = row[key];
|
|
}
|
|
});
|
|
return reordered;
|
|
});
|
|
|
|
onSelectedRowsChange(
|
|
Array.from(selectedRows),
|
|
selectedRowsData,
|
|
sortColumn,
|
|
sortDirection,
|
|
currentColumnOrder,
|
|
reorderedData,
|
|
);
|
|
}, [visibleColumns.length, visibleColumns.map((c) => c.columnName).join(",")]); // 의존성 단순화
|
|
|
|
const getColumnWidth = (column: ColumnConfig) => {
|
|
if (column.columnName === "__checkbox__") return 50;
|
|
if (column.width) return column.width;
|
|
|
|
switch (column.format) {
|
|
case "date":
|
|
return 120;
|
|
case "number":
|
|
case "currency":
|
|
return 100;
|
|
case "boolean":
|
|
return 80;
|
|
default:
|
|
return 150;
|
|
}
|
|
};
|
|
|
|
const renderCheckboxHeader = () => {
|
|
if (!tableConfig.checkbox?.selectAll) return null;
|
|
|
|
return <Checkbox checked={isAllSelected} onCheckedChange={handleSelectAll} aria-label="전체 선택" />;
|
|
};
|
|
|
|
const renderCheckboxCell = (row: any, index: number) => {
|
|
const rowKey = getRowKey(row, index);
|
|
const isChecked = selectedRows.has(rowKey);
|
|
|
|
return (
|
|
<Checkbox
|
|
checked={isChecked}
|
|
onCheckedChange={(checked) => handleRowSelection(rowKey, checked as boolean)}
|
|
aria-label={`행 ${index + 1} 선택`}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const formatCellValue = useCallback(
|
|
(value: any, column: ColumnConfig, rowData?: Record<string, any>) => {
|
|
if (value === null || value === undefined) return "-";
|
|
|
|
// 🎯 writer 컬럼 자동 변환: user_id -> user_name
|
|
if (column.columnName === "writer" && rowData && rowData.writer_name) {
|
|
return rowData.writer_name;
|
|
}
|
|
|
|
// 🎯 엔티티 컬럼 표시 설정이 있는 경우
|
|
if (column.entityDisplayConfig && rowData) {
|
|
// displayColumns 또는 selectedColumns 둘 다 체크
|
|
const displayColumns = column.entityDisplayConfig.displayColumns || column.entityDisplayConfig.selectedColumns;
|
|
const separator = column.entityDisplayConfig.separator;
|
|
|
|
if (displayColumns && displayColumns.length > 0) {
|
|
// 선택된 컬럼들의 값을 구분자로 조합
|
|
const values = displayColumns
|
|
.map((colName) => {
|
|
const cellValue = rowData[colName];
|
|
if (cellValue === null || cellValue === undefined) return "";
|
|
return String(cellValue);
|
|
})
|
|
.filter((v) => v !== ""); // 빈 값 제외
|
|
|
|
return values.join(separator || " - ");
|
|
}
|
|
}
|
|
|
|
const meta = columnMeta[column.columnName];
|
|
|
|
// inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선)
|
|
const inputType = meta?.inputType || column.inputType;
|
|
|
|
// 🖼️ 이미지 타입: 작은 썸네일 표시
|
|
if (inputType === "image" && value && typeof value === "string") {
|
|
const imageUrl = getFullImageUrl(value);
|
|
return (
|
|
<img
|
|
src={imageUrl}
|
|
alt="이미지"
|
|
className="h-10 w-10 rounded object-cover"
|
|
onError={(e) => {
|
|
e.currentTarget.src =
|
|
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Crect width='40' height='40' fill='%23f3f4f6'/%3E%3C/svg%3E";
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원)
|
|
if (inputType === "category") {
|
|
if (!value) return "";
|
|
|
|
const mapping = categoryMappings[column.columnName];
|
|
const categoryData = mapping?.[String(value)];
|
|
|
|
// 매핑 데이터가 있으면 라벨과 색상 사용, 없으면 코드값과 기본색상
|
|
const displayLabel = categoryData?.label || String(value);
|
|
const displayColor = categoryData?.color || "#64748b"; // 기본 slate 색상
|
|
|
|
// 배지 없음 옵션: color가 "none"이면 텍스트만 표시
|
|
if (displayColor === "none") {
|
|
return <span className="text-sm">{displayLabel}</span>;
|
|
}
|
|
|
|
const { Badge } = require("@/components/ui/badge");
|
|
return (
|
|
<Badge
|
|
style={{
|
|
backgroundColor: displayColor,
|
|
borderColor: displayColor,
|
|
}}
|
|
className="text-white"
|
|
>
|
|
{displayLabel}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
// 코드 타입: 코드 값 → 코드명 변환
|
|
if (inputType === "code" && meta?.codeCategory && value) {
|
|
try {
|
|
// optimizedConvertCode(categoryCode, codeValue) 순서 주의!
|
|
const convertedValue = optimizedConvertCode(meta.codeCategory, value);
|
|
// 변환에 성공했으면 변환된 코드명 반환
|
|
if (convertedValue && convertedValue !== value) {
|
|
return convertedValue;
|
|
}
|
|
} catch (error) {
|
|
console.error(`코드 변환 실패: ${column.columnName}, 카테고리: ${meta.codeCategory}, 값: ${value}`, error);
|
|
}
|
|
// 변환 실패 시 원본 코드 값 반환
|
|
return String(value);
|
|
}
|
|
|
|
// 날짜 타입 포맷팅 (yyyy-mm-dd)
|
|
if (inputType === "date" || inputType === "datetime") {
|
|
if (value) {
|
|
try {
|
|
const date = new Date(value);
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
const day = String(date.getDate()).padStart(2, '0');
|
|
return `${year}-${month}-${day}`;
|
|
} catch {
|
|
return String(value);
|
|
}
|
|
}
|
|
return "-";
|
|
}
|
|
|
|
// 숫자 타입 포맷팅
|
|
if (inputType === "number" || inputType === "decimal") {
|
|
if (value !== null && value !== undefined && value !== "") {
|
|
const numValue = typeof value === "string" ? parseFloat(value) : value;
|
|
if (!isNaN(numValue)) {
|
|
return numValue.toLocaleString("ko-KR");
|
|
}
|
|
}
|
|
return String(value);
|
|
}
|
|
|
|
switch (column.format) {
|
|
case "number":
|
|
if (value !== null && value !== undefined && value !== "") {
|
|
const numValue = typeof value === "string" ? parseFloat(value) : value;
|
|
if (!isNaN(numValue)) {
|
|
return numValue.toLocaleString("ko-KR");
|
|
}
|
|
}
|
|
return String(value);
|
|
case "date":
|
|
if (value) {
|
|
try {
|
|
const date = new Date(value);
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
const day = String(date.getDate()).padStart(2, '0');
|
|
return `${year}-${month}-${day}`;
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
return "-";
|
|
case "number":
|
|
return typeof value === "number" ? value.toLocaleString() : value;
|
|
case "currency":
|
|
return typeof value === "number" ? `₩${value.toLocaleString()}` : value;
|
|
case "boolean":
|
|
return value ? "예" : "아니오";
|
|
default:
|
|
return String(value);
|
|
}
|
|
},
|
|
[columnMeta, optimizedConvertCode, categoryMappings],
|
|
);
|
|
|
|
// ========================================
|
|
// useEffect 훅
|
|
// ========================================
|
|
|
|
// 필터 설정 localStorage 키 생성
|
|
const filterSettingKey = useMemo(() => {
|
|
if (!tableConfig.selectedTable) return null;
|
|
return `tableList_filterSettings_${tableConfig.selectedTable}`;
|
|
}, [tableConfig.selectedTable]);
|
|
|
|
// 그룹 설정 localStorage 키 생성
|
|
const groupSettingKey = useMemo(() => {
|
|
if (!tableConfig.selectedTable) return null;
|
|
return `tableList_groupSettings_${tableConfig.selectedTable}`;
|
|
}, [tableConfig.selectedTable]);
|
|
|
|
// 저장된 필터 설정 불러오기
|
|
useEffect(() => {
|
|
if (!filterSettingKey || visibleColumns.length === 0) return;
|
|
|
|
try {
|
|
const saved = localStorage.getItem(filterSettingKey);
|
|
if (saved) {
|
|
const savedFilters = JSON.parse(saved);
|
|
setVisibleFilterColumns(new Set(savedFilters));
|
|
} else {
|
|
// 초기값: 빈 Set (아무것도 선택 안 함)
|
|
setVisibleFilterColumns(new Set());
|
|
}
|
|
} catch (error) {
|
|
console.error("필터 설정 불러오기 실패:", error);
|
|
setVisibleFilterColumns(new Set());
|
|
}
|
|
}, [filterSettingKey, visibleColumns]);
|
|
|
|
// 필터 설정 저장
|
|
const saveFilterSettings = useCallback(() => {
|
|
if (!filterSettingKey) return;
|
|
|
|
try {
|
|
localStorage.setItem(filterSettingKey, JSON.stringify(Array.from(visibleFilterColumns)));
|
|
setIsFilterSettingOpen(false);
|
|
toast.success("검색 필터 설정이 저장되었습니다");
|
|
|
|
// 검색 값 초기화
|
|
setSearchValues({});
|
|
} catch (error) {
|
|
console.error("필터 설정 저장 실패:", error);
|
|
toast.error("설정 저장에 실패했습니다");
|
|
}
|
|
}, [filterSettingKey, visibleFilterColumns]);
|
|
|
|
// 필터 컬럼 토글
|
|
const toggleFilterVisibility = useCallback((columnName: string) => {
|
|
setVisibleFilterColumns((prev) => {
|
|
const newSet = new Set(prev);
|
|
if (newSet.has(columnName)) {
|
|
newSet.delete(columnName);
|
|
} else {
|
|
newSet.add(columnName);
|
|
}
|
|
return newSet;
|
|
});
|
|
}, []);
|
|
|
|
// 전체 선택/해제
|
|
const toggleAllFilters = useCallback(() => {
|
|
const filterableColumns = visibleColumns.filter((col) => col.columnName !== "__checkbox__");
|
|
const columnNames = filterableColumns.map((col) => col.columnName);
|
|
|
|
if (visibleFilterColumns.size === columnNames.length) {
|
|
// 전체 해제
|
|
setVisibleFilterColumns(new Set());
|
|
} else {
|
|
// 전체 선택
|
|
setVisibleFilterColumns(new Set(columnNames));
|
|
}
|
|
}, [visibleFilterColumns, visibleColumns]);
|
|
|
|
// 표시할 필터 목록 (선택된 컬럼만)
|
|
const activeFilters = useMemo(() => {
|
|
return visibleColumns
|
|
.filter((col) => col.columnName !== "__checkbox__" && visibleFilterColumns.has(col.columnName))
|
|
.map((col) => ({
|
|
columnName: col.columnName,
|
|
label: columnLabels[col.columnName] || col.displayName || col.columnName,
|
|
type: col.format || "text",
|
|
}));
|
|
}, [visibleColumns, visibleFilterColumns, columnLabels]);
|
|
|
|
// 그룹 설정 자동 저장 (localStorage)
|
|
useEffect(() => {
|
|
if (!groupSettingKey) return;
|
|
|
|
try {
|
|
localStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns));
|
|
} catch (error) {
|
|
console.error("그룹 설정 저장 실패:", error);
|
|
}
|
|
}, [groupSettingKey, groupByColumns]);
|
|
|
|
// 그룹 컬럼 토글
|
|
const toggleGroupColumn = useCallback((columnName: string) => {
|
|
setGroupByColumns((prev) => {
|
|
if (prev.includes(columnName)) {
|
|
return prev.filter((col) => col !== columnName);
|
|
} else {
|
|
return [...prev, columnName];
|
|
}
|
|
});
|
|
}, []);
|
|
|
|
// 사용자 옵션 저장 핸들러
|
|
const handleTableOptionsSave = useCallback(
|
|
(config: {
|
|
columns: Array<{ columnName: string; label: string; visible: boolean; width?: number; frozen?: boolean }>;
|
|
showGridLines: boolean;
|
|
viewMode: "table" | "card" | "grouped-card";
|
|
}) => {
|
|
// 컬럼 순서 업데이트
|
|
const newColumnOrder = config.columns.map((col) => col.columnName);
|
|
setColumnOrder(newColumnOrder);
|
|
|
|
// 컬럼 너비 업데이트
|
|
const newWidths: Record<string, number> = {};
|
|
config.columns.forEach((col) => {
|
|
if (col.width) {
|
|
newWidths[col.columnName] = col.width;
|
|
}
|
|
});
|
|
setColumnWidths(newWidths);
|
|
|
|
// 틀고정 컬럼 업데이트
|
|
const newFrozenColumns = config.columns.filter((col) => col.frozen).map((col) => col.columnName);
|
|
setFrozenColumns(newFrozenColumns);
|
|
|
|
// 그리드선 표시 업데이트
|
|
setShowGridLines(config.showGridLines);
|
|
|
|
// 보기 모드 업데이트
|
|
setViewMode(config.viewMode);
|
|
|
|
// 컬럼 표시/숨기기 업데이트
|
|
const newDisplayColumns = displayColumns.map((col) => {
|
|
const configCol = config.columns.find((c) => c.columnName === col.columnName);
|
|
if (configCol) {
|
|
return { ...col, visible: configCol.visible };
|
|
}
|
|
return col;
|
|
});
|
|
setDisplayColumns(newDisplayColumns);
|
|
|
|
toast.success("테이블 옵션이 저장되었습니다");
|
|
},
|
|
[displayColumns],
|
|
);
|
|
|
|
// 그룹 펼치기/접기 토글
|
|
const toggleGroupCollapse = useCallback((groupKey: string) => {
|
|
setCollapsedGroups((prev) => {
|
|
const newSet = new Set(prev);
|
|
if (newSet.has(groupKey)) {
|
|
newSet.delete(groupKey);
|
|
} else {
|
|
newSet.add(groupKey);
|
|
}
|
|
return newSet;
|
|
});
|
|
}, []);
|
|
|
|
// 그룹 해제
|
|
const clearGrouping = useCallback(() => {
|
|
setGroupByColumns([]);
|
|
setCollapsedGroups(new Set());
|
|
if (groupSettingKey) {
|
|
localStorage.removeItem(groupSettingKey);
|
|
}
|
|
toast.success("그룹이 해제되었습니다");
|
|
}, [groupSettingKey]);
|
|
|
|
// 데이터 그룹화
|
|
const groupedData = useMemo((): GroupedData[] => {
|
|
if (groupByColumns.length === 0 || data.length === 0) return [];
|
|
|
|
const grouped = new Map<string, any[]>();
|
|
|
|
data.forEach((item) => {
|
|
// 그룹 키 생성: "통화:KRW > 단위:EA"
|
|
const keyParts = groupByColumns.map((col) => {
|
|
// 카테고리/엔티티 타입인 경우 _name 필드 사용
|
|
const inputType = columnMeta?.[col]?.inputType;
|
|
let displayValue = item[col];
|
|
|
|
if (inputType === 'category' || inputType === 'entity' || inputType === 'code') {
|
|
// _name 필드가 있으면 사용 (예: division_name, writer_name)
|
|
const nameField = `${col}_name`;
|
|
if (item[nameField] !== undefined && item[nameField] !== null) {
|
|
displayValue = item[nameField];
|
|
}
|
|
}
|
|
|
|
const label = columnLabels[col] || col;
|
|
return `${label}:${displayValue !== null && displayValue !== undefined ? displayValue : "-"}`;
|
|
});
|
|
const groupKey = keyParts.join(" > ");
|
|
|
|
if (!grouped.has(groupKey)) {
|
|
grouped.set(groupKey, []);
|
|
}
|
|
grouped.get(groupKey)!.push(item);
|
|
});
|
|
|
|
return Array.from(grouped.entries()).map(([groupKey, items]) => {
|
|
const groupValues: Record<string, any> = {};
|
|
groupByColumns.forEach((col) => {
|
|
groupValues[col] = items[0]?.[col];
|
|
});
|
|
|
|
return {
|
|
groupKey,
|
|
groupValues,
|
|
items,
|
|
count: items.length,
|
|
};
|
|
});
|
|
}, [data, groupByColumns, columnLabels, columnMeta]);
|
|
|
|
// 저장된 그룹 설정 불러오기
|
|
useEffect(() => {
|
|
if (!groupSettingKey || visibleColumns.length === 0) return;
|
|
|
|
try {
|
|
const saved = localStorage.getItem(groupSettingKey);
|
|
if (saved) {
|
|
const savedGroups = JSON.parse(saved);
|
|
setGroupByColumns(savedGroups);
|
|
}
|
|
} catch (error) {
|
|
console.error("그룹 설정 불러오기 실패:", error);
|
|
}
|
|
}, [groupSettingKey, visibleColumns]);
|
|
|
|
useEffect(() => {
|
|
fetchColumnLabels();
|
|
fetchTableLabel();
|
|
}, [tableConfig.selectedTable, fetchColumnLabels, fetchTableLabel]);
|
|
|
|
useEffect(() => {
|
|
console.log("🔍 [TableList] useEffect 실행 - 데이터 조회 트리거", {
|
|
isDesignMode,
|
|
tableName: tableConfig.selectedTable,
|
|
currentPage,
|
|
sortColumn,
|
|
sortDirection,
|
|
});
|
|
|
|
if (!isDesignMode && tableConfig.selectedTable) {
|
|
fetchTableDataDebounced();
|
|
}
|
|
}, [
|
|
tableConfig.selectedTable,
|
|
currentPage,
|
|
localPageSize,
|
|
sortColumn,
|
|
sortDirection,
|
|
searchTerm,
|
|
searchValues, // 필터 값 변경 시에도 데이터 새로고침
|
|
refreshKey,
|
|
refreshTrigger, // 강제 새로고침 트리거
|
|
isDesignMode,
|
|
// fetchTableDataDebounced 제거: useCallback 재생성으로 인한 무한 루프 방지
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (tableConfig.refreshInterval && !isDesignMode) {
|
|
const interval = setInterval(() => {
|
|
fetchTableDataDebounced();
|
|
}, tableConfig.refreshInterval * 1000);
|
|
|
|
return () => clearInterval(interval);
|
|
}
|
|
}, [tableConfig.refreshInterval, isDesignMode]);
|
|
|
|
// 초기 컬럼 너비 측정 (한 번만)
|
|
useEffect(() => {
|
|
if (!hasInitializedWidths.current && visibleColumns.length > 0) {
|
|
// 약간의 지연을 두고 DOM이 완전히 렌더링된 후 측정
|
|
const timer = setTimeout(() => {
|
|
const newWidths: Record<string, number> = {};
|
|
let hasAnyWidth = false;
|
|
|
|
visibleColumns.forEach((column) => {
|
|
// 체크박스 컬럼은 제외 (고정 48px)
|
|
if (column.columnName === "__checkbox__") return;
|
|
|
|
const thElement = columnRefs.current[column.columnName];
|
|
if (thElement) {
|
|
const measuredWidth = thElement.offsetWidth;
|
|
if (measuredWidth > 0) {
|
|
newWidths[column.columnName] = measuredWidth;
|
|
hasAnyWidth = true;
|
|
}
|
|
}
|
|
});
|
|
|
|
if (hasAnyWidth) {
|
|
setColumnWidths(newWidths);
|
|
hasInitializedWidths.current = true;
|
|
}
|
|
}, 100);
|
|
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [visibleColumns]);
|
|
|
|
// ========================================
|
|
// 페이지네이션 JSX
|
|
// ========================================
|
|
|
|
const paginationJSX = useMemo(() => {
|
|
if (!tableConfig.pagination?.enabled || isDesignMode) return null;
|
|
|
|
return (
|
|
<div className="border-border bg-background relative flex h-14 w-full flex-shrink-0 items-center justify-center border-t-2 px-4 sm:h-[60px] sm:px-6">
|
|
{/* 중앙 페이지네이션 컨트롤 */}
|
|
<div className="flex items-center gap-2 sm:gap-4">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handlePageChange(1)}
|
|
disabled={currentPage === 1 || loading}
|
|
className="h-8 w-8 p-0 sm:h-9 sm:w-auto sm:px-3"
|
|
>
|
|
<ChevronsLeft className="h-3 w-3 sm:h-4 sm:w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handlePageChange(currentPage - 1)}
|
|
disabled={currentPage === 1 || loading}
|
|
className="h-8 w-8 p-0 sm:h-9 sm:w-auto sm:px-3"
|
|
>
|
|
<ChevronLeft className="h-3 w-3 sm:h-4 sm:w-4" />
|
|
</Button>
|
|
|
|
<span className="text-foreground min-w-[60px] text-center text-xs font-medium sm:min-w-[80px] sm:text-sm">
|
|
{currentPage} / {totalPages || 1}
|
|
</span>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handlePageChange(currentPage + 1)}
|
|
disabled={currentPage >= totalPages || loading}
|
|
className="h-8 w-8 p-0 sm:h-9 sm:w-auto sm:px-3"
|
|
>
|
|
<ChevronRight className="h-3 w-3 sm:h-4 sm:w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handlePageChange(totalPages)}
|
|
disabled={currentPage >= totalPages || loading}
|
|
className="h-8 w-8 p-0 sm:h-9 sm:w-auto sm:px-3"
|
|
>
|
|
<ChevronsRight className="h-3 w-3 sm:h-4 sm:w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 우측 새로고침 버튼 */}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleRefresh}
|
|
disabled={loading}
|
|
className="absolute right-2 h-8 w-8 p-0 sm:right-6 sm:h-9 sm:w-auto sm:px-3"
|
|
>
|
|
<RefreshCw className={cn("h-3 w-3", loading && "animate-spin")} />
|
|
</Button>
|
|
</div>
|
|
);
|
|
}, [tableConfig.pagination, isDesignMode, currentPage, totalPages, totalItems, loading]);
|
|
|
|
// ========================================
|
|
// 렌더링
|
|
// ========================================
|
|
|
|
const domProps = {
|
|
onClick: handleClick,
|
|
onDragStart: isDesignMode ? onDragStart : undefined,
|
|
onDragEnd: isDesignMode ? onDragEnd : undefined,
|
|
draggable: isDesignMode,
|
|
className: cn(className, isDesignMode && "cursor-move"),
|
|
style: componentStyle,
|
|
};
|
|
|
|
// 카드 모드
|
|
if (tableConfig.displayMode === "card" && !isDesignMode) {
|
|
return (
|
|
<div {...domProps}>
|
|
<CardModeRenderer
|
|
data={data}
|
|
loading={loading}
|
|
error={error}
|
|
cardConfig={tableConfig.cardConfig}
|
|
onRowClick={handleRowClick}
|
|
/>
|
|
{paginationJSX}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// SingleTableWithSticky 모드
|
|
if (tableConfig.stickyHeader && !isDesignMode) {
|
|
return (
|
|
<div {...domProps}>
|
|
{/* 필터 헤더는 TableSearchWidget으로 이동 */}
|
|
|
|
{/* 그룹 표시 배지 */}
|
|
{groupByColumns.length > 0 && (
|
|
<div className="border-border bg-muted/30 border-b px-4 py-1.5 sm:px-6">
|
|
<div className="flex items-center gap-2 text-xs sm:text-sm">
|
|
<span className="text-muted-foreground">그룹:</span>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{groupByColumns.map((col, idx) => (
|
|
<span key={col} className="flex items-center">
|
|
{idx > 0 && <span className="text-muted-foreground mx-1">→</span>}
|
|
<span className="bg-primary/10 text-primary rounded px-2 py-1 text-xs font-medium">
|
|
{columnLabels[col] || col}
|
|
</span>
|
|
</span>
|
|
))}
|
|
</div>
|
|
<button
|
|
onClick={clearGrouping}
|
|
className="hover:bg-destructive/10 text-destructive ml-auto rounded p-1"
|
|
title="그룹 해제"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ marginTop: `${tableConfig.filter?.bottomSpacing ?? 8}px`, flex: 1, overflow: "hidden" }}>
|
|
<SingleTableWithSticky
|
|
data={data}
|
|
columns={visibleColumns}
|
|
loading={loading}
|
|
error={error}
|
|
sortColumn={sortColumn}
|
|
sortDirection={sortDirection}
|
|
onSort={handleSort}
|
|
tableConfig={tableConfig}
|
|
isDesignMode={isDesignMode}
|
|
isAllSelected={isAllSelected}
|
|
handleSelectAll={handleSelectAll}
|
|
handleRowClick={handleRowClick}
|
|
columnLabels={columnLabels}
|
|
renderCheckboxHeader={renderCheckboxHeader}
|
|
renderCheckboxCell={renderCheckboxCell}
|
|
formatCellValue={(value: any, format?: string, columnName?: string, rowData?: Record<string, any>) => {
|
|
const column = visibleColumns.find((c) => c.columnName === columnName);
|
|
return column ? formatCellValue(value, column, rowData) : String(value);
|
|
}}
|
|
getColumnWidth={getColumnWidth}
|
|
containerWidth={calculatedWidth}
|
|
/>
|
|
</div>
|
|
|
|
{paginationJSX}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 일반 테이블 모드 (네이티브 HTML 테이블)
|
|
return (
|
|
<>
|
|
<div {...domProps}>
|
|
{/* 필터 헤더는 TableSearchWidget으로 이동 */}
|
|
|
|
{/* 그룹 표시 배지 */}
|
|
{groupByColumns.length > 0 && (
|
|
<div className="border-border bg-muted/30 border-b px-4 py-2 sm:px-6">
|
|
<div className="flex items-center gap-2 text-xs sm:text-sm">
|
|
<span className="text-muted-foreground">그룹:</span>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{groupByColumns.map((col, idx) => (
|
|
<span key={col} className="flex items-center">
|
|
{idx > 0 && <span className="text-muted-foreground mx-1">→</span>}
|
|
<span className="bg-primary/10 text-primary rounded px-2 py-1 text-xs font-medium">
|
|
{columnLabels[col] || col}
|
|
</span>
|
|
</span>
|
|
))}
|
|
</div>
|
|
<button
|
|
onClick={clearGrouping}
|
|
className="hover:bg-destructive/10 text-destructive ml-auto rounded p-1"
|
|
title="그룹 해제"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 테이블 컨테이너 */}
|
|
<div
|
|
className="flex w-full max-w-full flex-1 flex-col overflow-hidden"
|
|
style={{ marginTop: `${tableConfig.filter?.bottomSpacing ?? 8}px` }}
|
|
>
|
|
{/* 스크롤 영역 */}
|
|
<div
|
|
className="bg-background flex-1 w-full max-w-full overflow-x-auto overflow-y-auto"
|
|
style={{ position: "relative" }}
|
|
>
|
|
{/* 테이블 */}
|
|
<table
|
|
className={cn("table-mobile-fixed w-full max-w-full", !showGridLines && "hide-grid")}
|
|
style={{
|
|
borderCollapse: "collapse",
|
|
width: "100%",
|
|
tableLayout: "fixed",
|
|
}}
|
|
>
|
|
{/* 헤더 (sticky) */}
|
|
<thead
|
|
className="sticky z-50"
|
|
style={{
|
|
position: "sticky",
|
|
top: "-2px",
|
|
zIndex: 50,
|
|
backgroundColor: "hsl(var(--background))",
|
|
}}
|
|
>
|
|
<tr
|
|
className="border-primary/20 bg-muted h-10 border-b-2 sm:h-12"
|
|
style={{
|
|
backgroundColor: "hsl(var(--muted))",
|
|
}}
|
|
>
|
|
{visibleColumns.map((column, columnIndex) => {
|
|
const columnWidth = columnWidths[column.columnName];
|
|
const isFrozen = frozenColumns.includes(column.columnName);
|
|
const frozenIndex = frozenColumns.indexOf(column.columnName);
|
|
|
|
// 틀고정된 컬럼의 left 위치 계산
|
|
let leftPosition = 0;
|
|
if (isFrozen && frozenIndex > 0) {
|
|
for (let i = 0; i < frozenIndex; i++) {
|
|
const frozenCol = frozenColumns[i];
|
|
const frozenColWidth = columnWidths[frozenCol] || 150;
|
|
leftPosition += frozenColWidth;
|
|
}
|
|
}
|
|
|
|
return (
|
|
<th
|
|
key={column.columnName}
|
|
ref={(el) => (columnRefs.current[column.columnName] = el)}
|
|
className={cn(
|
|
"text-foreground/90 relative h-8 overflow-hidden text-xs font-bold text-ellipsis whitespace-nowrap select-none sm:h-10 sm:text-sm",
|
|
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-2",
|
|
column.sortable !== false &&
|
|
column.columnName !== "__checkbox__" &&
|
|
"hover:bg-muted/70 cursor-pointer transition-colors",
|
|
isFrozen && "sticky z-60 shadow-[2px_0_4px_rgba(0,0,0,0.1)]",
|
|
)}
|
|
style={{
|
|
textAlign: column.columnName === "__checkbox__" ? "center" : "center",
|
|
width:
|
|
column.columnName === "__checkbox__"
|
|
? "48px"
|
|
: columnWidth
|
|
? `${columnWidth}px`
|
|
: undefined,
|
|
minWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
|
maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
|
userSelect: "none",
|
|
backgroundColor: "hsl(var(--muted))",
|
|
...(isFrozen && { left: `${leftPosition}px` }),
|
|
}}
|
|
onClick={() => {
|
|
if (isResizing.current) return;
|
|
if (column.sortable !== false && column.columnName !== "__checkbox__") {
|
|
handleSort(column.columnName);
|
|
}
|
|
}}
|
|
>
|
|
{column.columnName === "__checkbox__" ? (
|
|
renderCheckboxHeader()
|
|
) : (
|
|
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
|
<span>{columnLabels[column.columnName] || column.displayName}</span>
|
|
{column.sortable !== false && sortColumn === column.columnName && (
|
|
<span>{sortDirection === "asc" ? "↑" : "↓"}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
{/* 리사이즈 핸들 (체크박스 제외) */}
|
|
{columnIndex < visibleColumns.length - 1 && column.columnName !== "__checkbox__" && (
|
|
<div
|
|
className="absolute top-0 right-0 z-20 h-full w-2 cursor-col-resize hover:bg-blue-500"
|
|
style={{ marginRight: "-4px", paddingLeft: "4px", paddingRight: "4px" }}
|
|
onClick={(e) => e.stopPropagation()} // 정렬 클릭 방지
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const thElement = columnRefs.current[column.columnName];
|
|
if (!thElement) return;
|
|
|
|
isResizing.current = true;
|
|
|
|
const startX = e.clientX;
|
|
const startWidth = columnWidth || thElement.offsetWidth;
|
|
|
|
// 드래그 중 텍스트 선택 방지
|
|
document.body.style.userSelect = "none";
|
|
document.body.style.cursor = "col-resize";
|
|
|
|
const handleMouseMove = (moveEvent: MouseEvent) => {
|
|
moveEvent.preventDefault();
|
|
|
|
const diff = moveEvent.clientX - startX;
|
|
const newWidth = Math.max(80, startWidth + diff);
|
|
|
|
// 직접 DOM 스타일 변경 (리렌더링 없음)
|
|
if (thElement) {
|
|
thElement.style.width = `${newWidth}px`;
|
|
}
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
// 최종 너비를 state에 저장
|
|
if (thElement) {
|
|
const finalWidth = Math.max(80, thElement.offsetWidth);
|
|
setColumnWidths((prev) => ({ ...prev, [column.columnName]: finalWidth }));
|
|
}
|
|
|
|
// 텍스트 선택 복원
|
|
document.body.style.userSelect = "";
|
|
document.body.style.cursor = "";
|
|
|
|
// 약간의 지연 후 리사이즈 플래그 해제 (클릭 이벤트가 먼저 처리되지 않도록)
|
|
setTimeout(() => {
|
|
isResizing.current = false;
|
|
}, 100);
|
|
|
|
document.removeEventListener("mousemove", handleMouseMove);
|
|
document.removeEventListener("mouseup", handleMouseUp);
|
|
};
|
|
|
|
document.addEventListener("mousemove", handleMouseMove);
|
|
document.addEventListener("mouseup", handleMouseUp);
|
|
}}
|
|
/>
|
|
)}
|
|
</th>
|
|
);
|
|
})}
|
|
</tr>
|
|
</thead>
|
|
|
|
{/* 바디 (스크롤) */}
|
|
<tbody key={`tbody-${categoryMappingsKey}`} style={{ position: "relative" }}>
|
|
{loading ? (
|
|
<tr>
|
|
<td colSpan={visibleColumns.length} className="p-12 text-center">
|
|
<div className="flex flex-col items-center gap-3">
|
|
<RefreshCw className="text-muted-foreground h-8 w-8 animate-spin" />
|
|
<div className="text-muted-foreground text-sm font-medium">로딩 중...</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
) : error ? (
|
|
<tr>
|
|
<td colSpan={visibleColumns.length} className="p-12 text-center">
|
|
<div className="flex flex-col items-center gap-3">
|
|
<div className="text-destructive text-sm font-medium">오류 발생</div>
|
|
<div className="text-muted-foreground text-xs">{error}</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
) : data.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={visibleColumns.length} className="p-12 text-center">
|
|
<div className="flex flex-col items-center gap-3">
|
|
<TableIcon className="text-muted-foreground/50 h-12 w-12" />
|
|
<div className="text-muted-foreground text-sm font-medium">데이터가 없습니다</div>
|
|
<div className="text-muted-foreground text-xs">
|
|
조건을 변경하거나 새로운 데이터를 추가해보세요
|
|
</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
) : (() => {
|
|
console.log("🔍 [TableList] 렌더링 조건 체크", {
|
|
groupByColumns: groupByColumns.length,
|
|
groupedDataLength: groupedData.length,
|
|
willRenderGrouped: groupByColumns.length > 0 && groupedData.length > 0,
|
|
dataLength: data.length,
|
|
});
|
|
return groupByColumns.length > 0 && groupedData.length > 0;
|
|
})() ? (
|
|
// 그룹화된 렌더링
|
|
groupedData.map((group) => {
|
|
console.log("📊 [TableList] 그룹 렌더링:", group.groupKey, group.count);
|
|
const isCollapsed = collapsedGroups.has(group.groupKey);
|
|
return (
|
|
<React.Fragment key={group.groupKey}>
|
|
{/* 그룹 헤더 */}
|
|
<tr>
|
|
<td
|
|
colSpan={visibleColumns.length}
|
|
className="bg-muted/50 border-border sticky top-[48px] z-[5] border-b"
|
|
style={{ top: "48px" }}
|
|
>
|
|
<div
|
|
className="hover:bg-muted flex cursor-pointer items-center gap-3 p-3"
|
|
onClick={() => toggleGroupCollapse(group.groupKey)}
|
|
>
|
|
{isCollapsed ? (
|
|
<ChevronRight className="h-4 w-4 flex-shrink-0" />
|
|
) : (
|
|
<ChevronDown className="h-4 w-4 flex-shrink-0" />
|
|
)}
|
|
<span className="flex-1 text-sm font-medium">{group.groupKey}</span>
|
|
<span className="text-muted-foreground text-xs">({group.count}건)</span>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
{/* 그룹 데이터 */}
|
|
{!isCollapsed &&
|
|
group.items.map((row, index) => (
|
|
<tr
|
|
key={index}
|
|
className={cn(
|
|
"bg-background hover:bg-muted/50 cursor-pointer border-b transition-colors",
|
|
)}
|
|
onClick={(e) => handleRowClick(row, index, e)}
|
|
>
|
|
{visibleColumns.map((column, colIndex) => {
|
|
const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName;
|
|
const cellValue = row[mappedColumnName];
|
|
|
|
const meta = columnMeta[column.columnName];
|
|
const inputType = meta?.inputType || column.inputType;
|
|
const isNumeric = inputType === "number" || inputType === "decimal";
|
|
|
|
const isFrozen = frozenColumns.includes(column.columnName);
|
|
const frozenIndex = frozenColumns.indexOf(column.columnName);
|
|
|
|
// 틀고정된 컬럼의 left 위치 계산
|
|
let leftPosition = 0;
|
|
if (isFrozen && frozenIndex > 0) {
|
|
for (let i = 0; i < frozenIndex; i++) {
|
|
const frozenCol = frozenColumns[i];
|
|
const frozenColWidth = columnWidths[frozenCol] || 150;
|
|
leftPosition += frozenColWidth;
|
|
}
|
|
}
|
|
|
|
return (
|
|
<td
|
|
key={column.columnName}
|
|
className={cn(
|
|
"text-foreground overflow-hidden text-xs text-ellipsis whitespace-nowrap font-normal sm:text-sm",
|
|
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-1.5",
|
|
isFrozen && "bg-background sticky z-10 shadow-[2px_0_4px_rgba(0,0,0,0.05)]",
|
|
)}
|
|
style={{
|
|
textAlign:
|
|
column.columnName === "__checkbox__"
|
|
? "center"
|
|
: isNumeric
|
|
? "right"
|
|
: column.align || "left",
|
|
width:
|
|
column.columnName === "__checkbox__"
|
|
? "48px"
|
|
: `${100 / visibleColumns.length}%`,
|
|
minWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
|
maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
|
...(isFrozen && { left: `${leftPosition}px` }),
|
|
}}
|
|
>
|
|
{column.columnName === "__checkbox__"
|
|
? renderCheckboxCell(row, index)
|
|
: formatCellValue(cellValue, column, row)}
|
|
</td>
|
|
);
|
|
})}
|
|
</tr>
|
|
))}
|
|
</React.Fragment>
|
|
);
|
|
})
|
|
) : (
|
|
// 일반 렌더링 (그룹 없음)
|
|
(() => {
|
|
console.log("📋 [TableList] 일반 렌더링 시작:", data.length, "개 행");
|
|
return data;
|
|
})().map((row, index) => (
|
|
<tr
|
|
key={index}
|
|
className={cn(
|
|
"bg-background hover:bg-muted/50 cursor-pointer border-b transition-colors",
|
|
)}
|
|
onClick={(e) => handleRowClick(row, index, e)}
|
|
>
|
|
{visibleColumns.map((column, colIndex) => {
|
|
const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName;
|
|
const cellValue = row[mappedColumnName];
|
|
|
|
const meta = columnMeta[column.columnName];
|
|
const inputType = meta?.inputType || column.inputType;
|
|
const isNumeric = inputType === "number" || inputType === "decimal";
|
|
|
|
const isFrozen = frozenColumns.includes(column.columnName);
|
|
const frozenIndex = frozenColumns.indexOf(column.columnName);
|
|
|
|
// 틀고정된 컬럼의 left 위치 계산
|
|
let leftPosition = 0;
|
|
if (isFrozen && frozenIndex > 0) {
|
|
for (let i = 0; i < frozenIndex; i++) {
|
|
const frozenCol = frozenColumns[i];
|
|
const frozenColWidth = columnWidths[frozenCol] || 150;
|
|
leftPosition += frozenColWidth;
|
|
}
|
|
}
|
|
|
|
return (
|
|
<td
|
|
key={column.columnName}
|
|
className={cn(
|
|
"text-foreground overflow-hidden text-xs text-ellipsis whitespace-nowrap font-normal sm:text-sm",
|
|
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-1.5",
|
|
isFrozen && "bg-background sticky z-10 shadow-[2px_0_4px_rgba(0,0,0,0.05)]",
|
|
)}
|
|
style={{
|
|
textAlign:
|
|
column.columnName === "__checkbox__"
|
|
? "center"
|
|
: isNumeric
|
|
? "right"
|
|
: column.align || "left",
|
|
width: column.columnName === "__checkbox__" ? "48px" : `${100 / visibleColumns.length}%`,
|
|
minWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
|
maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
|
...(isFrozen && { left: `${leftPosition}px` }),
|
|
}}
|
|
>
|
|
{column.columnName === "__checkbox__"
|
|
? renderCheckboxCell(row, index)
|
|
: formatCellValue(cellValue, column, row)}
|
|
</td>
|
|
);
|
|
})}
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 페이지네이션 */}
|
|
{paginationJSX}
|
|
</div>
|
|
|
|
{/* 필터 설정 다이얼로그 */}
|
|
<Dialog open={isFilterSettingOpen} onOpenChange={setIsFilterSettingOpen}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-base sm:text-lg">검색 필터 설정</DialogTitle>
|
|
<DialogDescription className="text-xs sm:text-sm">
|
|
검색 필터로 사용할 컬럼을 선택하세요. 선택한 컬럼의 검색 입력 필드가 표시됩니다.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-3 sm:space-y-4">
|
|
{/* 전체 선택/해제 */}
|
|
<div className="bg-muted/50 flex items-center gap-3 rounded border p-3">
|
|
<Checkbox
|
|
id="select-all-filters"
|
|
checked={
|
|
visibleFilterColumns.size ===
|
|
visibleColumns.filter((col) => col.columnName !== "__checkbox__").length &&
|
|
visibleColumns.filter((col) => col.columnName !== "__checkbox__").length > 0
|
|
}
|
|
onCheckedChange={toggleAllFilters}
|
|
/>
|
|
<Label htmlFor="select-all-filters" className="flex-1 cursor-pointer text-xs font-semibold sm:text-sm">
|
|
전체 선택/해제
|
|
</Label>
|
|
<span className="text-muted-foreground text-xs">
|
|
{visibleFilterColumns.size} / {visibleColumns.filter((col) => col.columnName !== "__checkbox__").length}
|
|
개
|
|
</span>
|
|
</div>
|
|
|
|
{/* 컬럼 목록 */}
|
|
<div className="max-h-[50vh] space-y-2 overflow-y-auto rounded border p-2">
|
|
{visibleColumns
|
|
.filter((col) => col.columnName !== "__checkbox__")
|
|
.map((col) => (
|
|
<div key={col.columnName} className="hover:bg-muted/50 flex items-center gap-3 rounded p-2">
|
|
<Checkbox
|
|
id={`filter-${col.columnName}`}
|
|
checked={visibleFilterColumns.has(col.columnName)}
|
|
onCheckedChange={() => toggleFilterVisibility(col.columnName)}
|
|
/>
|
|
<Label
|
|
htmlFor={`filter-${col.columnName}`}
|
|
className="flex-1 cursor-pointer text-xs font-normal sm:text-sm"
|
|
>
|
|
{columnLabels[col.columnName] || col.displayName || col.columnName}
|
|
</Label>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* 선택된 컬럼 개수 안내 */}
|
|
<div className="text-muted-foreground bg-muted/30 rounded p-3 text-center text-xs">
|
|
{visibleFilterColumns.size === 0 ? (
|
|
<span>검색 필터를 사용하려면 최소 1개 이상의 컬럼을 선택하세요</span>
|
|
) : (
|
|
<span>
|
|
총 <span className="text-primary font-semibold">{visibleFilterColumns.size}개</span>의 검색 필터가
|
|
표시됩니다
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setIsFilterSettingOpen(false)}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
취소
|
|
</Button>
|
|
<Button onClick={saveFilterSettings} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
|
저장
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* 테이블 옵션 모달 */}
|
|
<TableOptionsModal
|
|
isOpen={isTableOptionsOpen}
|
|
onClose={() => setIsTableOptionsOpen(false)}
|
|
columns={visibleColumns.map((col) => ({
|
|
columnName: col.columnName,
|
|
label: columnLabels[col.columnName] || col.displayName || col.columnName,
|
|
visible: col.visible !== false,
|
|
width: columnWidths[col.columnName],
|
|
frozen: frozenColumns.includes(col.columnName),
|
|
}))}
|
|
onSave={handleTableOptionsSave}
|
|
tableName={tableConfig.selectedTable || "table"}
|
|
userId={userId}
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export const TableListWrapper: React.FC<TableListComponentProps> = (props) => {
|
|
return <TableListComponent {...props} />;
|
|
};
|