6661 lines
257 KiB
TypeScript
6661 lines
257 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";
|
||
|
||
// 🆕 RelatedDataButtons 전역 레지스트리 타입 선언
|
||
declare global {
|
||
interface Window {
|
||
__relatedButtonsTargetTables?: Set<string>;
|
||
}
|
||
}
|
||
import {
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
ChevronsLeft,
|
||
ChevronsRight,
|
||
RefreshCw,
|
||
ArrowUp,
|
||
ArrowDown,
|
||
TableIcon,
|
||
Settings,
|
||
X,
|
||
Layers,
|
||
ChevronDown,
|
||
Filter,
|
||
Check,
|
||
Download,
|
||
FileSpreadsheet,
|
||
Copy,
|
||
ClipboardCopy,
|
||
Edit,
|
||
CheckSquare,
|
||
Trash2,
|
||
Lock,
|
||
} from "lucide-react";
|
||
import * as XLSX from "xlsx";
|
||
import { FileText, ChevronRightIcon } 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, GroupSumConfig } from "@/types/table-options";
|
||
import { useAuth } from "@/hooks/useAuth";
|
||
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||
import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
||
import type { DataProvidable, DataReceivable, DataReceiverConfig } from "@/types/data-transfer";
|
||
|
||
// ========================================
|
||
// 인터페이스
|
||
// ========================================
|
||
|
||
// 그룹화된 데이터 인터페이스
|
||
interface GroupedData {
|
||
groupKey: string;
|
||
groupValues: Record<string, any>;
|
||
items: any[];
|
||
count: number;
|
||
summary?: Record<string, { sum: number; avg: number; 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);
|
||
});
|
||
};
|
||
};
|
||
|
||
// ========================================
|
||
// Filter Builder 인터페이스
|
||
// ========================================
|
||
|
||
interface FilterCondition {
|
||
id: string;
|
||
column: string;
|
||
operator:
|
||
| "equals"
|
||
| "notEquals"
|
||
| "contains"
|
||
| "notContains"
|
||
| "startsWith"
|
||
| "endsWith"
|
||
| "greaterThan"
|
||
| "lessThan"
|
||
| "greaterOrEqual"
|
||
| "lessOrEqual"
|
||
| "isEmpty"
|
||
| "isNotEmpty";
|
||
value: string;
|
||
}
|
||
|
||
interface FilterGroup {
|
||
id: string;
|
||
logic: "AND" | "OR";
|
||
conditions: FilterCondition[];
|
||
}
|
||
|
||
// ========================================
|
||
// 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?: number | string; // 화면 ID (필터 설정 저장용)
|
||
userId?: string; // 사용자 ID (컬럼 순서 저장용)
|
||
onSelectedRowsChange?: (
|
||
selectedRows: any[],
|
||
selectedRowsData: any[],
|
||
sortBy?: string,
|
||
sortOrder?: "asc" | "desc",
|
||
columnOrder?: string[],
|
||
tableDisplayData?: any[],
|
||
) => void;
|
||
onConfigChange?: (config: any) => void;
|
||
refreshKey?: number;
|
||
// 탭 관련 정보 (탭 내부의 테이블에서 사용)
|
||
parentTabId?: string; // 부모 탭 ID
|
||
parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID
|
||
}
|
||
|
||
// ========================================
|
||
// 메인 컴포넌트
|
||
// ========================================
|
||
|
||
export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||
component,
|
||
isDesignMode = false,
|
||
isSelected = false,
|
||
onClick,
|
||
onDragStart,
|
||
onDragEnd,
|
||
config,
|
||
className,
|
||
style,
|
||
formData: propFormData,
|
||
onFormDataChange,
|
||
componentConfig,
|
||
onSelectedRowsChange,
|
||
onConfigChange,
|
||
refreshKey,
|
||
tableName,
|
||
userId,
|
||
screenId,
|
||
parentTabId,
|
||
parentTabsComponentId,
|
||
}) => {
|
||
// ========================================
|
||
// 설정 및 스타일
|
||
// ========================================
|
||
|
||
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 = {
|
||
position: "relative",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
backgroundColor: "hsl(var(--background))",
|
||
overflow: "hidden",
|
||
boxSizing: "border-box",
|
||
width: "100%",
|
||
height: "100%",
|
||
minHeight: isDesignMode ? "300px" : "100%",
|
||
...style, // style prop이 위의 기본값들을 덮어씀
|
||
};
|
||
|
||
// ========================================
|
||
// 상태 관리
|
||
// ========================================
|
||
|
||
// 사용자 정보 (props에서 받거나 useAuth에서 가져오기)
|
||
const { userId: authUserId } = useAuth();
|
||
const currentUserId = userId || authUserId;
|
||
|
||
// 화면 컨텍스트 (데이터 제공자로 등록)
|
||
const screenContext = useScreenContextOptional();
|
||
|
||
// 분할 패널 컨텍스트 (분할 패널 내부에서 데이터 수신자로 등록)
|
||
const splitPanelContext = useSplitPanelContext();
|
||
// 🆕 ScreenContext에서 splitPanelPosition 가져오기 (중첩 화면에서도 작동)
|
||
const splitPanelPosition = screenContext?.splitPanelPosition;
|
||
|
||
// 🆕 연결된 필터 상태 (다른 컴포넌트 값으로 필터링)
|
||
const [linkedFilterValues, setLinkedFilterValues] = useState<Record<string, any>>({});
|
||
|
||
// 🆕 RelatedDataButtons 컴포넌트에서 발생하는 필터 상태
|
||
const [relatedButtonFilter, setRelatedButtonFilter] = useState<{
|
||
filterColumn: string;
|
||
filterValue: any;
|
||
} | null>(null);
|
||
|
||
// 🆕 RelatedDataButtons가 이 테이블을 대상으로 등록되어 있는지 여부
|
||
const [isRelatedButtonTarget, setIsRelatedButtonTarget] = useState(() => {
|
||
// 초기값: 전역 레지스트리에서 확인
|
||
if (typeof window !== "undefined" && window.__relatedButtonsTargetTables) {
|
||
return window.__relatedButtonsTargetTables.has(tableConfig.selectedTable || "");
|
||
}
|
||
return false;
|
||
});
|
||
|
||
// 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]);
|
||
|
||
// 🆕 columnOrder를 visibleColumns 이전에 정의 (visibleColumns에서 사용)
|
||
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
||
|
||
// 🆕 visibleColumns를 상단에서 정의 (다른 useCallback/useMemo에서 사용하기 위해)
|
||
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;
|
||
});
|
||
}
|
||
|
||
// 체크박스 컬럼 (나중에 위치 결정)
|
||
// 기본값: enabled가 undefined면 true로 처리
|
||
let checkboxCol: ColumnConfig | null = null;
|
||
if (tableConfig.checkbox?.enabled ?? true) {
|
||
checkboxCol = {
|
||
columnName: "__checkbox__",
|
||
displayName: "",
|
||
visible: true,
|
||
sortable: false,
|
||
searchable: false,
|
||
width: 40,
|
||
align: "center" as const,
|
||
order: -1,
|
||
editable: false, // 체크박스는 편집 불가
|
||
};
|
||
}
|
||
|
||
// columnOrder가 있으면 해당 순서로 정렬
|
||
if (columnOrder.length > 0) {
|
||
const orderMap = new Map(columnOrder.map((name, idx) => [name, idx]));
|
||
cols = [...cols].sort((a, b) => {
|
||
const aIdx = orderMap.get(a.columnName) ?? 9999;
|
||
const bIdx = orderMap.get(b.columnName) ?? 9999;
|
||
return aIdx - bIdx;
|
||
});
|
||
}
|
||
|
||
// 체크박스 위치 결정
|
||
if (checkboxCol) {
|
||
const checkboxPosition = tableConfig.checkbox?.position || "left";
|
||
if (checkboxPosition === "left") {
|
||
return [checkboxCol, ...cols];
|
||
} else {
|
||
return [...cols, checkboxCol];
|
||
}
|
||
}
|
||
|
||
return cols;
|
||
}, [tableConfig.columns, tableConfig.checkbox, columnVisibility, columnOrder]);
|
||
|
||
const [data, setData] = useState<Record<string, any>[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
// 🆕 컬럼 헤더 필터 상태 (상단에서 선언)
|
||
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
|
||
const [openFilterColumn, setOpenFilterColumn] = useState<string | null>(null);
|
||
|
||
// 🆕 Filter Builder (고급 필터) 관련 상태 - filteredData보다 먼저 정의해야 함
|
||
const [filterGroups, setFilterGroups] = useState<FilterGroup[]>([]);
|
||
|
||
// 🆕 분할 패널에서 우측에 이미 추가된 항목 필터링 (좌측 테이블에만 적용) + 헤더 필터
|
||
const filteredData = useMemo(() => {
|
||
let result = data;
|
||
|
||
// 1. 분할 패널 좌측에 있고, 우측에 추가된 항목이 있는 경우 필터링
|
||
if (splitPanelPosition === "left" && splitPanelContext?.addedItemIds && splitPanelContext.addedItemIds.size > 0) {
|
||
const addedIds = splitPanelContext.addedItemIds;
|
||
result = result.filter((row) => {
|
||
const rowId = String(row.id || row.po_item_id || row.item_id || "");
|
||
return !addedIds.has(rowId);
|
||
});
|
||
}
|
||
|
||
// 2. 헤더 필터 적용 (joinColumnMapping 사용 안 함 - 직접 컬럼명 사용)
|
||
if (Object.keys(headerFilters).length > 0) {
|
||
result = result.filter((row) => {
|
||
return Object.entries(headerFilters).every(([columnName, values]) => {
|
||
if (values.size === 0) return true;
|
||
|
||
// 여러 가능한 컬럼명 시도
|
||
const cellValue = row[columnName] ?? row[columnName.toLowerCase()] ?? row[columnName.toUpperCase()];
|
||
const cellStr = cellValue !== null && cellValue !== undefined ? String(cellValue) : "";
|
||
|
||
return values.has(cellStr);
|
||
});
|
||
});
|
||
}
|
||
|
||
// 3. 🆕 Filter Builder 적용
|
||
if (filterGroups.length > 0) {
|
||
result = result.filter((row) => {
|
||
return filterGroups.every((group) => {
|
||
const validConditions = group.conditions.filter(
|
||
(c) => c.column && (c.operator === "isEmpty" || c.operator === "isNotEmpty" || c.value),
|
||
);
|
||
if (validConditions.length === 0) return true;
|
||
|
||
const evaluateCondition = (value: any, condition: (typeof group.conditions)[0]): boolean => {
|
||
const strValue = value !== null && value !== undefined ? String(value).toLowerCase() : "";
|
||
const condValue = condition.value.toLowerCase();
|
||
|
||
switch (condition.operator) {
|
||
case "equals":
|
||
return strValue === condValue;
|
||
case "notEquals":
|
||
return strValue !== condValue;
|
||
case "contains":
|
||
return strValue.includes(condValue);
|
||
case "notContains":
|
||
return !strValue.includes(condValue);
|
||
case "startsWith":
|
||
return strValue.startsWith(condValue);
|
||
case "endsWith":
|
||
return strValue.endsWith(condValue);
|
||
case "greaterThan":
|
||
return parseFloat(strValue) > parseFloat(condValue);
|
||
case "lessThan":
|
||
return parseFloat(strValue) < parseFloat(condValue);
|
||
case "greaterOrEqual":
|
||
return parseFloat(strValue) >= parseFloat(condValue);
|
||
case "lessOrEqual":
|
||
return parseFloat(strValue) <= parseFloat(condValue);
|
||
case "isEmpty":
|
||
return strValue === "" || value === null || value === undefined;
|
||
case "isNotEmpty":
|
||
return strValue !== "" && value !== null && value !== undefined;
|
||
default:
|
||
return true;
|
||
}
|
||
};
|
||
|
||
if (group.logic === "AND") {
|
||
return validConditions.every((cond) => evaluateCondition(row[cond.column], cond));
|
||
} else {
|
||
return validConditions.some((cond) => evaluateCondition(row[cond.column], cond));
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
return result;
|
||
}, [data, splitPanelPosition, splitPanelContext?.addedItemIds, headerFilters, filterGroups]);
|
||
|
||
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 hasInitializedSort = useRef(false);
|
||
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 }>
|
||
>({});
|
||
// 🆕 엔티티 조인 테이블의 컬럼 메타데이터 (테이블명.컬럼명 → inputType)
|
||
const [joinedColumnMeta, setJoinedColumnMeta] = 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);
|
||
// columnOrder는 상단에서 정의됨 (visibleColumns보다 먼저 필요)
|
||
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 [focusedCell, setFocusedCell] = useState<{ rowIndex: number; colIndex: number } | null>(null);
|
||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||
|
||
// 🆕 인라인 셀 편집 관련 상태
|
||
const [editingCell, setEditingCell] = useState<{
|
||
rowIndex: number;
|
||
colIndex: number;
|
||
columnName: string;
|
||
originalValue: any;
|
||
} | null>(null);
|
||
const [editingValue, setEditingValue] = useState<string>("");
|
||
const editInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
// 🆕 배치 편집 관련 상태
|
||
const [editMode, setEditMode] = useState<"immediate" | "batch">("immediate"); // 편집 모드
|
||
const [pendingChanges, setPendingChanges] = useState<
|
||
Map<
|
||
string,
|
||
{
|
||
rowIndex: number;
|
||
columnName: string;
|
||
originalValue: any;
|
||
newValue: any;
|
||
primaryKeyValue: any;
|
||
}
|
||
>
|
||
>(new Map()); // key: `${rowIndex}-${columnName}`
|
||
const [localEditedData, setLocalEditedData] = useState<Record<number, Record<string, any>>>({}); // 로컬 수정 데이터
|
||
|
||
// 🆕 유효성 검사 관련 상태
|
||
const [validationErrors, setValidationErrors] = useState<Map<string, string>>(new Map()); // key: `${rowIndex}-${columnName}`
|
||
|
||
// 🆕 유효성 검사 규칙 타입
|
||
type ValidationRule = {
|
||
required?: boolean;
|
||
min?: number;
|
||
max?: number;
|
||
minLength?: number;
|
||
maxLength?: number;
|
||
pattern?: RegExp;
|
||
customMessage?: string;
|
||
validate?: (value: any, row: any) => string | null; // 커스텀 검증 함수 (에러 메시지 또는 null)
|
||
};
|
||
|
||
// 🆕 Cascading Lookups 관련 상태
|
||
const [cascadingOptions, setCascadingOptions] = useState<Record<string, { value: string; label: string }[]>>({});
|
||
const [loadingCascading, setLoadingCascading] = useState<Record<string, boolean>>({});
|
||
|
||
// 🆕 Multi-Level Headers (Column Bands) 타입
|
||
type ColumnBand = {
|
||
caption: string;
|
||
columns: string[]; // 포함될 컬럼명 배열
|
||
};
|
||
|
||
// 그룹 설정 관련 상태
|
||
const [groupByColumns, setGroupByColumns] = useState<string[]>([]);
|
||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||
|
||
// 🆕 그룹별 합산 설정 상태
|
||
const [groupSumConfig, setGroupSumConfig] = useState<GroupSumConfig | null>(null);
|
||
|
||
// 🆕 Master-Detail 관련 상태
|
||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set()); // 확장된 행 키 목록
|
||
const [detailData, setDetailData] = useState<Record<string, any[]>>({}); // 상세 데이터 캐시
|
||
|
||
// 🆕 Drag & Drop 재정렬 관련 상태
|
||
const [draggedRowIndex, setDraggedRowIndex] = useState<number | null>(null);
|
||
const [dropTargetIndex, setDropTargetIndex] = useState<number | null>(null);
|
||
const [isDragEnabled, setIsDragEnabled] = useState<boolean>((tableConfig as any).enableRowDrag ?? false);
|
||
|
||
// 🆕 Virtual Scrolling 관련 상태
|
||
const [isVirtualScrollEnabled] = useState<boolean>((tableConfig as any).virtualScroll ?? false);
|
||
const [scrollTop, setScrollTop] = useState(0);
|
||
const ROW_HEIGHT = 40; // 각 행의 높이 (픽셀)
|
||
const OVERSCAN = 5; // 버퍼로 추가 렌더링할 행 수
|
||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||
|
||
// 🆕 Column Reordering 관련 상태
|
||
const [draggedColumnIndex, setDraggedColumnIndex] = useState<number | null>(null);
|
||
const [dropTargetColumnIndex, setDropTargetColumnIndex] = useState<number | null>(null);
|
||
const [isColumnDragEnabled] = useState<boolean>((tableConfig as any).enableColumnDrag ?? true);
|
||
|
||
// 🆕 State Persistence: 통합 상태 키
|
||
const tableStateKey = useMemo(() => {
|
||
if (!tableConfig.selectedTable) return null;
|
||
return `tableState_${tableConfig.selectedTable}`;
|
||
}, [tableConfig.selectedTable]);
|
||
|
||
// 🆕 Real-Time Updates 관련 상태
|
||
const [isRealTimeEnabled] = useState<boolean>((tableConfig as any).realTimeUpdates ?? false);
|
||
const [wsConnectionStatus, setWsConnectionStatus] = useState<"connecting" | "connected" | "disconnected">(
|
||
"disconnected",
|
||
);
|
||
const wsRef = useRef<WebSocket | null>(null);
|
||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||
|
||
// 🆕 Context Menu 관련 상태
|
||
const [contextMenu, setContextMenu] = useState<{
|
||
x: number;
|
||
y: number;
|
||
rowIndex: number;
|
||
colIndex: number;
|
||
row: any;
|
||
} | null>(null);
|
||
|
||
// 사용자 옵션 모달 관련 상태
|
||
const [isTableOptionsOpen, setIsTableOptionsOpen] = useState(false);
|
||
const [showGridLines, setShowGridLines] = useState(true);
|
||
const [viewMode, setViewMode] = useState<"table" | "card" | "grouped-card">("table");
|
||
const [frozenColumns, setFrozenColumns] = useState<string[]>([]);
|
||
|
||
// 🆕 Search Panel (통합 검색) 관련 상태
|
||
const [globalSearchTerm, setGlobalSearchTerm] = useState("");
|
||
const [isSearchPanelOpen, setIsSearchPanelOpen] = useState(false);
|
||
const [searchHighlights, setSearchHighlights] = useState<Set<string>>(new Set()); // "rowIndex-colIndex" 형식
|
||
|
||
// 🆕 Filter Builder (고급 필터) 관련 상태 추가
|
||
const [isFilterBuilderOpen, setIsFilterBuilderOpen] = useState(false);
|
||
const [activeFilterCount, setActiveFilterCount] = useState(0);
|
||
|
||
// 🆕 연결된 필터 처리 (셀렉트박스 등 다른 컴포넌트 값으로 필터링)
|
||
useEffect(() => {
|
||
const linkedFilters = tableConfig.linkedFilters;
|
||
|
||
if (!linkedFilters || linkedFilters.length === 0 || !screenContext) {
|
||
return;
|
||
}
|
||
|
||
// 연결된 소스 컴포넌트들의 값을 주기적으로 확인
|
||
const checkLinkedFilters = () => {
|
||
const newFilterValues: Record<string, any> = {};
|
||
let hasChanges = false;
|
||
|
||
linkedFilters.forEach((filter) => {
|
||
if (filter.enabled === false) return;
|
||
|
||
const sourceProvider = screenContext.getDataProvider(filter.sourceComponentId);
|
||
if (sourceProvider) {
|
||
const selectedData = sourceProvider.getSelectedData();
|
||
if (selectedData && selectedData.length > 0) {
|
||
const sourceField = filter.sourceField || "value";
|
||
const value = selectedData[0][sourceField];
|
||
|
||
if (value !== linkedFilterValues[filter.targetColumn]) {
|
||
newFilterValues[filter.targetColumn] = value;
|
||
hasChanges = true;
|
||
} else {
|
||
newFilterValues[filter.targetColumn] = linkedFilterValues[filter.targetColumn];
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
if (hasChanges) {
|
||
console.log("🔗 [TableList] 연결된 필터 값 변경:", newFilterValues);
|
||
setLinkedFilterValues(newFilterValues);
|
||
|
||
// searchValues에 연결된 필터 값 병합
|
||
setSearchValues((prev) => ({
|
||
...prev,
|
||
...newFilterValues,
|
||
}));
|
||
|
||
// 첫 페이지로 이동
|
||
setCurrentPage(1);
|
||
}
|
||
};
|
||
|
||
// 초기 체크
|
||
checkLinkedFilters();
|
||
|
||
// 주기적으로 체크 (500ms마다)
|
||
const intervalId = setInterval(checkLinkedFilters, 500);
|
||
|
||
return () => {
|
||
clearInterval(intervalId);
|
||
};
|
||
}, [screenContext, tableConfig.linkedFilters, linkedFilterValues]);
|
||
|
||
// DataProvidable 인터페이스 구현
|
||
const dataProvider: DataProvidable = {
|
||
componentId: component.id,
|
||
componentType: "table-list",
|
||
|
||
getSelectedData: () => {
|
||
// 🆕 필터링된 데이터에서 선택된 행만 반환 (우측에 추가된 항목 제외)
|
||
const selectedData = filteredData.filter((row) => {
|
||
const rowId = String(row.id || row[tableConfig.selectedTable + "_id"] || "");
|
||
return selectedRows.has(rowId);
|
||
});
|
||
return selectedData;
|
||
},
|
||
|
||
getAllData: () => {
|
||
// 🆕 필터링된 데이터 반환
|
||
return filteredData;
|
||
},
|
||
|
||
clearSelection: () => {
|
||
setSelectedRows(new Set());
|
||
setIsAllSelected(false);
|
||
},
|
||
};
|
||
|
||
// DataReceivable 인터페이스 구현
|
||
const dataReceiver: DataReceivable = {
|
||
componentId: component.id,
|
||
componentType: "table",
|
||
|
||
receiveData: async (receivedData: any[], config: DataReceiverConfig) => {
|
||
console.log("📥 TableList 데이터 수신:", {
|
||
componentId: component.id,
|
||
receivedDataCount: receivedData.length,
|
||
mode: config.mode,
|
||
currentDataCount: data.length,
|
||
});
|
||
|
||
try {
|
||
let newData: any[] = [];
|
||
|
||
switch (config.mode) {
|
||
case "append":
|
||
// 기존 데이터에 추가
|
||
newData = [...data, ...receivedData];
|
||
console.log("✅ Append 모드: 기존 데이터에 추가", { newDataCount: newData.length });
|
||
break;
|
||
|
||
case "replace":
|
||
// 기존 데이터를 완전히 교체
|
||
newData = receivedData;
|
||
console.log("✅ Replace 모드: 데이터 교체", { newDataCount: newData.length });
|
||
break;
|
||
|
||
case "merge":
|
||
// 기존 데이터와 병합 (ID 기반)
|
||
const existingMap = new Map(data.map((item) => [item.id, item]));
|
||
receivedData.forEach((item) => {
|
||
if (item.id && existingMap.has(item.id)) {
|
||
// 기존 데이터 업데이트
|
||
existingMap.set(item.id, { ...existingMap.get(item.id), ...item });
|
||
} else {
|
||
// 새 데이터 추가
|
||
existingMap.set(item.id || Date.now() + Math.random(), item);
|
||
}
|
||
});
|
||
newData = Array.from(existingMap.values());
|
||
console.log("✅ Merge 모드: 데이터 병합", { newDataCount: newData.length });
|
||
break;
|
||
}
|
||
|
||
// 상태 업데이트
|
||
setData(newData);
|
||
|
||
// 총 아이템 수 업데이트
|
||
setTotalItems(newData.length);
|
||
|
||
console.log("✅ 데이터 수신 완료:", { finalDataCount: newData.length });
|
||
} catch (error) {
|
||
console.error("❌ 데이터 수신 실패:", error);
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
getData: () => {
|
||
return data;
|
||
},
|
||
};
|
||
|
||
// 화면 컨텍스트에 데이터 제공자/수신자로 등록
|
||
useEffect(() => {
|
||
if (screenContext && component.id) {
|
||
screenContext.registerDataProvider(component.id, dataProvider);
|
||
screenContext.registerDataReceiver(component.id, dataReceiver);
|
||
|
||
return () => {
|
||
screenContext.unregisterDataProvider(component.id);
|
||
screenContext.unregisterDataReceiver(component.id);
|
||
};
|
||
}
|
||
}, [screenContext, component.id, data, selectedRows]);
|
||
|
||
// 분할 패널 컨텍스트에 데이터 수신자로 등록
|
||
// useSplitPanelPosition 훅으로 위치 가져오기 (중첩된 화면에서도 작동)
|
||
const currentSplitPosition =
|
||
splitPanelPosition || splitPanelContext?.getPositionByScreenId(screenId as number) || null;
|
||
|
||
useEffect(() => {
|
||
if (splitPanelContext && component.id && currentSplitPosition) {
|
||
const splitPanelReceiver = {
|
||
componentId: component.id,
|
||
componentType: "table-list",
|
||
receiveData: async (incomingData: any[], mode: "append" | "replace" | "merge") => {
|
||
console.log("📥 [TableListComponent] 분할 패널에서 데이터 수신:", {
|
||
count: incomingData.length,
|
||
mode,
|
||
position: currentSplitPosition,
|
||
});
|
||
|
||
await dataReceiver.receiveData(incomingData, {
|
||
targetComponentId: component.id,
|
||
targetComponentType: "table-list",
|
||
mode,
|
||
mappingRules: [],
|
||
});
|
||
},
|
||
};
|
||
|
||
splitPanelContext.registerReceiver(currentSplitPosition, component.id, splitPanelReceiver);
|
||
|
||
return () => {
|
||
splitPanelContext.unregisterReceiver(currentSplitPosition, component.id);
|
||
};
|
||
}
|
||
}, [splitPanelContext, component.id, currentSplitPosition, dataReceiver]);
|
||
|
||
// 테이블 등록 (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, // 고유 값 조회 함수 등록
|
||
onGroupSumChange: setGroupSumConfig, // 그룹별 합산 설정
|
||
// 탭 관련 정보 (탭 내부의 테이블인 경우)
|
||
parentTabId,
|
||
parentTabsComponentId,
|
||
screenId: screenId ? Number(screenId) : undefined,
|
||
};
|
||
|
||
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 || hasInitializedSort.current) return;
|
||
|
||
const storageKey = `table_sort_state_${tableConfig.selectedTable}_${userId}`;
|
||
const savedSort = localStorage.getItem(storageKey);
|
||
|
||
if (savedSort) {
|
||
try {
|
||
const { column, direction } = JSON.parse(savedSort);
|
||
if (column && direction) {
|
||
setSortColumn(column);
|
||
setSortDirection(direction);
|
||
hasInitializedSort.current = true;
|
||
console.log("📂 localStorage에서 정렬 상태 복원:", { column, direction });
|
||
}
|
||
} catch (error) {
|
||
console.error("❌ 정렬 상태 복원 실패:", error);
|
||
}
|
||
}
|
||
}, [tableConfig.selectedTable, userId]);
|
||
|
||
// 🆕 초기 로드 시 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) {
|
||
// 컬럼 라벨 매핑 생성 (tableConfig.columns 사용 - visibleColumns는 아직 정의되지 않음)
|
||
const cols = (tableConfig.columns || []).filter((col) => col.visible !== false);
|
||
const labels: Record<string, string> = {};
|
||
cols.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: cols.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 }>> = {};
|
||
const apiClient = (await import("@/lib/api/client")).apiClient;
|
||
|
||
for (const columnName of categoryColumns) {
|
||
try {
|
||
// 🆕 엔티티 조인 컬럼 처리: "테이블명.컬럼명" 형태인지 확인
|
||
let targetTable = tableConfig.selectedTable;
|
||
let targetColumn = columnName;
|
||
|
||
if (columnName.includes(".")) {
|
||
const parts = columnName.split(".");
|
||
targetTable = parts[0]; // 조인된 테이블명 (예: item_info)
|
||
targetColumn = parts[1]; // 실제 컬럼명 (예: material)
|
||
console.log(`🔗 [TableList] 엔티티 조인 컬럼 감지:`, {
|
||
originalColumn: columnName,
|
||
targetTable,
|
||
targetColumn,
|
||
});
|
||
}
|
||
|
||
|
||
const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values`);
|
||
|
||
|
||
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,
|
||
};
|
||
});
|
||
|
||
if (Object.keys(mapping).length > 0) {
|
||
// 🆕 원래 컬럼명(item_info.material)으로 매핑 저장
|
||
mappings[columnName] = 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,
|
||
});
|
||
}
|
||
}
|
||
|
||
// 🆕 엔티티 조인 컬럼의 inputType 정보 가져오기 및 카테고리 매핑 로드
|
||
// 1. "테이블명.컬럼명" 형태의 조인 컬럼 추출
|
||
const joinedColumns =
|
||
tableConfig.columns?.filter((col) => col.columnName?.includes(".")).map((col) => col.columnName) || [];
|
||
|
||
// 2. additionalJoinInfo가 있는 컬럼도 추출 (예: item_code_material → item_info.material)
|
||
const additionalJoinColumns =
|
||
tableConfig.columns
|
||
?.filter((col: any) => col.additionalJoinInfo?.referenceTable)
|
||
.map((col: any) => ({
|
||
columnName: col.columnName, // 예: item_code_material
|
||
referenceTable: col.additionalJoinInfo.referenceTable, // 예: item_info
|
||
// joinAlias에서 실제 컬럼명 추출 (item_code_material → material)
|
||
actualColumn:
|
||
col.additionalJoinInfo.joinAlias?.replace(`${col.additionalJoinInfo.sourceColumn}_`, "") ||
|
||
col.columnName,
|
||
})) || [];
|
||
|
||
|
||
// 조인 테이블별로 그룹화
|
||
const joinedTableColumns: Record<string, { columnName: string; actualColumn: string }[]> = {};
|
||
|
||
// "테이블명.컬럼명" 형태 처리
|
||
for (const joinedColumn of joinedColumns) {
|
||
const parts = joinedColumn.split(".");
|
||
if (parts.length !== 2) continue;
|
||
|
||
const joinedTable = parts[0];
|
||
const joinedColumnName = parts[1];
|
||
|
||
if (!joinedTableColumns[joinedTable]) {
|
||
joinedTableColumns[joinedTable] = [];
|
||
}
|
||
joinedTableColumns[joinedTable].push({
|
||
columnName: joinedColumn,
|
||
actualColumn: joinedColumnName,
|
||
});
|
||
}
|
||
|
||
// additionalJoinInfo 형태 처리
|
||
for (const col of additionalJoinColumns) {
|
||
if (!joinedTableColumns[col.referenceTable]) {
|
||
joinedTableColumns[col.referenceTable] = [];
|
||
}
|
||
joinedTableColumns[col.referenceTable].push({
|
||
columnName: col.columnName, // 예: item_code_material
|
||
actualColumn: col.actualColumn, // 예: material
|
||
});
|
||
}
|
||
|
||
|
||
// 조인된 테이블별로 inputType 정보 가져오기
|
||
const newJoinedColumnMeta: Record<string, { webType?: string; codeCategory?: string; inputType?: string }> = {};
|
||
|
||
for (const [joinedTable, columns] of Object.entries(joinedTableColumns)) {
|
||
try {
|
||
// 조인 테이블의 컬럼 inputType 정보 가져오기 (이미 import된 tableTypeApi 사용)
|
||
const inputTypes = await tableTypeApi.getColumnInputTypes(joinedTable);
|
||
|
||
console.log(`📡 [TableList] 조인 테이블 inputType 로드 [${joinedTable}]:`, inputTypes);
|
||
|
||
for (const col of columns) {
|
||
const inputTypeInfo = inputTypes.find((it: any) => it.columnName === col.actualColumn);
|
||
|
||
// 컬럼명 그대로 저장 (item_code_material 또는 item_info.material)
|
||
newJoinedColumnMeta[col.columnName] = {
|
||
inputType: inputTypeInfo?.inputType,
|
||
};
|
||
|
||
console.log(
|
||
` 🔗 [${col.columnName}] (실제: ${col.actualColumn}) inputType: ${inputTypeInfo?.inputType || "unknown"}`,
|
||
);
|
||
|
||
// inputType이 category인 경우 카테고리 매핑 로드
|
||
if (inputTypeInfo?.inputType === "category" && !mappings[col.columnName]) {
|
||
try {
|
||
console.log(`📡 [TableList] 조인 테이블 카테고리 로드 시도 [${col.columnName}]:`, {
|
||
url: `/table-categories/${joinedTable}/${col.actualColumn}/values`,
|
||
});
|
||
|
||
const response = await apiClient.get(`/table-categories/${joinedTable}/${col.actualColumn}/values`);
|
||
|
||
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) => {
|
||
const key = String(item.valueCode);
|
||
mapping[key] = {
|
||
label: item.valueLabel,
|
||
color: item.color,
|
||
};
|
||
});
|
||
|
||
if (Object.keys(mapping).length > 0) {
|
||
mappings[col.columnName] = mapping;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.log(`ℹ️ [TableList] 조인 테이블 카테고리 없음 (${col.columnName})`);
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error(`❌ [TableList] 조인 테이블 inputType 로드 실패 [${joinedTable}]:`, error);
|
||
}
|
||
}
|
||
|
||
// 조인 컬럼 메타데이터 상태 업데이트
|
||
if (Object.keys(newJoinedColumnMeta).length > 0) {
|
||
setJoinedColumnMeta(newJoinedColumnMeta);
|
||
console.log("✅ [TableList] 조인 컬럼 메타데이터 설정:", newJoinedColumnMeta);
|
||
}
|
||
|
||
if (Object.keys(mappings).length > 0) {
|
||
setCategoryMappings(mappings);
|
||
setCategoryMappingsKey((prev) => prev + 1);
|
||
} else {
|
||
console.warn("⚠️ [TableList] 매핑이 비어있어 상태 업데이트 스킵");
|
||
}
|
||
} catch (error) {
|
||
console.error("❌ [TableList] 카테고리 매핑 로드 실패:", error);
|
||
}
|
||
};
|
||
|
||
loadCategoryMappings();
|
||
}, [
|
||
tableConfig.selectedTable,
|
||
categoryColumns.length,
|
||
JSON.stringify(categoryColumns),
|
||
JSON.stringify(tableConfig.columns),
|
||
]); // 더 명확한 의존성
|
||
|
||
// ========================================
|
||
// 데이터 가져오기
|
||
// ========================================
|
||
|
||
const fetchTableDataInternal = useCallback(async () => {
|
||
|
||
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;
|
||
|
||
// 🆕 연결 필터 값 가져오기 (분할 패널 내부일 때)
|
||
let linkedFilterValues: Record<string, any> = {};
|
||
let hasLinkedFiltersConfigured = false; // 연결 필터가 설정되어 있는지 여부
|
||
let hasSelectedLeftData = false; // 좌측에서 데이터가 선택되었는지 여부
|
||
|
||
|
||
if (splitPanelContext) {
|
||
// 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지)
|
||
const linkedFiltersConfig = splitPanelContext.linkedFilters || [];
|
||
hasLinkedFiltersConfigured = linkedFiltersConfig.some(
|
||
(filter) =>
|
||
filter.targetColumn?.startsWith(tableConfig.selectedTable + ".") ||
|
||
filter.targetColumn === tableConfig.selectedTable,
|
||
);
|
||
|
||
// 좌측 데이터 선택 여부 확인
|
||
hasSelectedLeftData =
|
||
splitPanelContext.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0;
|
||
|
||
const allLinkedFilters = splitPanelContext.getLinkedFilterValues();
|
||
|
||
// 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서)
|
||
// 연결 필터는 코드 값이므로 정확한 매칭(equals)을 사용해야 함
|
||
for (const [key, value] of Object.entries(allLinkedFilters)) {
|
||
if (key.includes(".")) {
|
||
const [tableName, columnName] = key.split(".");
|
||
if (tableName === tableConfig.selectedTable) {
|
||
// 연결 필터는 코드 값이므로 equals 연산자 사용
|
||
linkedFilterValues[columnName] = { value, operator: "equals" };
|
||
hasLinkedFiltersConfigured = true; // 이 테이블에 대한 필터가 있음
|
||
}
|
||
} else {
|
||
// 테이블명 없이 컬럼명만 있는 경우 그대로 사용 (equals)
|
||
linkedFilterValues[key] = { value, operator: "equals" };
|
||
}
|
||
}
|
||
|
||
// 🆕 자동 컬럼 매칭: linkedFilters가 설정되어 있지 않아도
|
||
// 우측 화면(splitPanelPosition === "right")이고 좌측 데이터가 선택되어 있으면
|
||
// 동일한 컬럼명이 있는 경우 자동으로 필터링 적용
|
||
if (
|
||
splitPanelPosition === "right" &&
|
||
hasSelectedLeftData &&
|
||
Object.keys(linkedFilterValues).length === 0 &&
|
||
!hasLinkedFiltersConfigured
|
||
) {
|
||
const leftData = splitPanelContext.selectedLeftData!;
|
||
const tableColumns = (tableConfig.columns || []).map((col) => col.columnName);
|
||
|
||
// 좌측 데이터의 컬럼 중 현재 테이블에 동일한 컬럼이 있는지 확인
|
||
for (const [colName, colValue] of Object.entries(leftData)) {
|
||
// null, undefined, 빈 문자열 제외
|
||
if (colValue === null || colValue === undefined || colValue === "") continue;
|
||
// id, objid 등 기본 키는 제외 (너무 일반적인 컬럼명)
|
||
if (colName === "id" || colName === "objid" || colName === "company_code") continue;
|
||
|
||
// 현재 테이블에 동일한 컬럼이 있는지 확인
|
||
if (tableColumns.includes(colName)) {
|
||
// 자동 컬럼 매칭도 equals 연산자 사용
|
||
linkedFilterValues[colName] = { value: colValue, operator: "equals" };
|
||
hasLinkedFiltersConfigured = true;
|
||
console.log(`🔗 [TableList] 자동 컬럼 매칭: ${colName} = ${colValue}`);
|
||
}
|
||
}
|
||
|
||
if (Object.keys(linkedFilterValues).length > 0) {
|
||
console.log("🔗 [TableList] 자동 컬럼 매칭 필터 적용:", linkedFilterValues);
|
||
}
|
||
}
|
||
|
||
if (Object.keys(linkedFilterValues).length > 0) {
|
||
console.log("🔗 [TableList] 연결 필터 적용:", linkedFilterValues);
|
||
}
|
||
}
|
||
|
||
// 🆕 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우
|
||
// → 빈 데이터 표시 (모든 데이터를 보여주지 않음)
|
||
if (hasLinkedFiltersConfigured && !hasSelectedLeftData) {
|
||
console.log("⚠️ [TableList] 연결 필터 설정됨 but 좌측 데이터 미선택 → 빈 데이터 표시");
|
||
setData([]);
|
||
setTotalItems(0);
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
|
||
// 🆕 RelatedDataButtons 대상이지만 아직 버튼이 선택되지 않은 경우
|
||
// → 빈 데이터 표시 (모든 데이터를 보여주지 않음)
|
||
if (isRelatedButtonTarget && !relatedButtonFilter) {
|
||
console.log("⚠️ [TableList] RelatedDataButtons 대상이지만 버튼 미선택 → 빈 데이터 표시");
|
||
setData([]);
|
||
setTotalItems(0);
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
|
||
// 🆕 RelatedDataButtons 필터 값 준비
|
||
let relatedButtonFilterValues: Record<string, any> = {};
|
||
if (relatedButtonFilter) {
|
||
relatedButtonFilterValues[relatedButtonFilter.filterColumn] = {
|
||
value: relatedButtonFilter.filterValue,
|
||
operator: "equals",
|
||
};
|
||
console.log("🔗 [TableList] RelatedDataButtons 필터 적용:", relatedButtonFilterValues);
|
||
}
|
||
|
||
// 검색 필터, 연결 필터, RelatedDataButtons 필터 병합
|
||
const filters = {
|
||
...(Object.keys(searchValues).length > 0 ? searchValues : {}),
|
||
...linkedFilterValues,
|
||
...relatedButtonFilterValues, // 🆕 RelatedDataButtons 필터 추가
|
||
};
|
||
const hasFilters = Object.keys(filters).length > 0;
|
||
|
||
// 🆕 REST API 데이터 소스 처리
|
||
const isRestApiTable =
|
||
tableConfig.selectedTable.startsWith("restapi_") || tableConfig.selectedTable.startsWith("_restapi_");
|
||
|
||
let response: any;
|
||
|
||
if (isRestApiTable) {
|
||
// REST API 데이터 소스인 경우
|
||
const connectionIdMatch = tableConfig.selectedTable.match(/restapi_(\d+)/);
|
||
const connectionId = connectionIdMatch ? parseInt(connectionIdMatch[1]) : null;
|
||
|
||
if (connectionId) {
|
||
console.log("🌐 [TableList] REST API 데이터 소스 호출", { connectionId });
|
||
|
||
// REST API 연결 정보 가져오기 및 데이터 조회
|
||
const { ExternalRestApiConnectionAPI } = await import("@/lib/api/externalRestApiConnection");
|
||
const restApiData = await ExternalRestApiConnectionAPI.fetchData(
|
||
connectionId,
|
||
undefined, // endpoint - 연결 정보에서 가져옴
|
||
"response", // jsonPath - 기본값 response
|
||
);
|
||
|
||
response = {
|
||
data: restApiData.rows || [],
|
||
total: restApiData.total || restApiData.rows?.length || 0,
|
||
totalPages: Math.ceil((restApiData.total || restApiData.rows?.length || 0) / pageSize),
|
||
};
|
||
|
||
console.log("✅ [TableList] REST API 응답:", {
|
||
dataLength: response.data.length,
|
||
total: response.total,
|
||
});
|
||
} else {
|
||
throw new Error("REST API 연결 ID를 찾을 수 없습니다.");
|
||
}
|
||
} else {
|
||
// 일반 DB 테이블인 경우 (기존 로직)
|
||
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 screenEntityConfigs: Record<string, any> = {};
|
||
(tableConfig.columns || [])
|
||
.filter((col) => col.entityDisplayConfig && col.entityDisplayConfig.displayColumns?.length > 0)
|
||
.forEach((col) => {
|
||
screenEntityConfigs[col.columnName] = {
|
||
displayColumns: col.entityDisplayConfig!.displayColumns,
|
||
separator: col.entityDisplayConfig!.separator || " - ",
|
||
sourceTable: col.entityDisplayConfig!.sourceTable || tableConfig.selectedTable,
|
||
joinTable: col.entityDisplayConfig!.joinTable,
|
||
};
|
||
});
|
||
|
||
|
||
// 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외)
|
||
let excludeFilterParam: any = undefined;
|
||
if (tableConfig.excludeFilter?.enabled) {
|
||
const excludeConfig = tableConfig.excludeFilter;
|
||
let filterValue: any = undefined;
|
||
|
||
// 필터 값 소스에 따라 값 가져오기 (우선순위: formData > URL > 분할패널)
|
||
if (excludeConfig.filterColumn && excludeConfig.filterValueField) {
|
||
const fieldName = excludeConfig.filterValueField;
|
||
|
||
// 1순위: props로 전달받은 formData에서 값 가져오기 (모달에서 사용)
|
||
if (propFormData && propFormData[fieldName]) {
|
||
filterValue = propFormData[fieldName];
|
||
console.log("🔗 [TableList] formData에서 excludeFilter 값 가져오기:", {
|
||
field: fieldName,
|
||
value: filterValue,
|
||
});
|
||
}
|
||
// 2순위: URL 파라미터에서 값 가져오기
|
||
else if (typeof window !== "undefined") {
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
filterValue = urlParams.get(fieldName);
|
||
if (filterValue) {
|
||
console.log("🔗 [TableList] URL에서 excludeFilter 값 가져오기:", {
|
||
field: fieldName,
|
||
value: filterValue,
|
||
});
|
||
}
|
||
}
|
||
// 3순위: 분할 패널 부모 데이터에서 값 가져오기
|
||
if (!filterValue && splitPanelContext?.selectedLeftData) {
|
||
filterValue = splitPanelContext.selectedLeftData[fieldName];
|
||
if (filterValue) {
|
||
console.log("🔗 [TableList] 분할패널에서 excludeFilter 값 가져오기:", {
|
||
field: fieldName,
|
||
value: filterValue,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
if (filterValue || !excludeConfig.filterColumn) {
|
||
excludeFilterParam = {
|
||
enabled: true,
|
||
referenceTable: excludeConfig.referenceTable,
|
||
referenceColumn: excludeConfig.referenceColumn,
|
||
sourceColumn: excludeConfig.sourceColumn,
|
||
filterColumn: excludeConfig.filterColumn,
|
||
filterValue: filterValue,
|
||
};
|
||
console.log("🚫 [TableList] 제외 필터 적용:", excludeFilterParam);
|
||
}
|
||
}
|
||
|
||
// 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원)
|
||
response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
|
||
page,
|
||
size: pageSize,
|
||
sortBy,
|
||
sortOrder,
|
||
search: hasFilters ? filters : undefined,
|
||
enableEntityJoin: true,
|
||
additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined,
|
||
screenEntityConfigs: Object.keys(screenEntityConfigs).length > 0 ? screenEntityConfigs : undefined, // 🎯 화면별 엔티티 설정 전달
|
||
dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달
|
||
excludeFilter: excludeFilterParam, // 🆕 제외 필터 전달
|
||
});
|
||
|
||
// 실제 데이터의 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에 필터 조건 저장 (엑셀 다운로드용)
|
||
// tableConfig.columns 사용 (visibleColumns는 이 시점에서 아직 정의되지 않을 수 있음)
|
||
const cols = (tableConfig.columns || []).filter((col) => col.visible !== false);
|
||
const labels: Record<string, string> = {};
|
||
cols.forEach((col) => {
|
||
labels[col.columnName] = columnLabels[col.columnName] || col.columnName;
|
||
});
|
||
|
||
tableDisplayStore.setTableData(
|
||
tableConfig.selectedTable,
|
||
response.data || [],
|
||
cols.map((col) => col.columnName),
|
||
sortBy,
|
||
sortOrder,
|
||
{
|
||
filterConditions: filters,
|
||
searchTerm: search,
|
||
visibleColumns: cols.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,
|
||
// 🆕 우측 화면일 때만 selectedLeftData 변경에 반응 (좌측 테이블은 재조회 불필요)
|
||
splitPanelPosition,
|
||
currentSplitPosition,
|
||
splitPanelContext?.selectedLeftData,
|
||
// 🆕 RelatedDataButtons 필터 추가
|
||
relatedButtonFilter,
|
||
isRelatedButtonTarget,
|
||
]);
|
||
|
||
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";
|
||
}
|
||
|
||
// 🎯 정렬 상태를 localStorage에 저장 (사용자별)
|
||
if (tableConfig.selectedTable && userId) {
|
||
const storageKey = `table_sort_state_${tableConfig.selectedTable}_${userId}`;
|
||
try {
|
||
localStorage.setItem(
|
||
storageKey,
|
||
JSON.stringify({
|
||
column: newSortColumn,
|
||
direction: newSortDirection,
|
||
}),
|
||
);
|
||
console.log("💾 정렬 상태 저장:", { column: newSortColumn, direction: newSortDirection });
|
||
} catch (error) {
|
||
console.error("❌ 정렬 상태 저장 실패:", error);
|
||
}
|
||
}
|
||
|
||
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단계: 정렬된 데이터를 컬럼 순서대로 재정렬
|
||
// tableConfig.columns 사용 (visibleColumns는 이 시점에서 아직 정의되지 않을 수 있음)
|
||
const cols = (tableConfig.columns || []).filter((col) => col.visible !== false);
|
||
const reorderedData = sortedData.map((row: any) => {
|
||
const reordered: any = {};
|
||
cols.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 : cols.map((c) => c.columnName)).filter(
|
||
(col) => col !== "__checkbox__",
|
||
);
|
||
|
||
// 컬럼 라벨 정보도 함께 저장
|
||
const labels: Record<string, string> = {};
|
||
cols.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: cols.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,
|
||
});
|
||
}
|
||
|
||
// 🆕 modalDataStore에 선택된 데이터 자동 저장 (테이블명 기반 dataSourceId)
|
||
if (tableConfig.selectedTable && selectedRowsData.length > 0) {
|
||
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
||
const modalItems = selectedRowsData.map((row, idx) => ({
|
||
id: getRowKey(row, idx),
|
||
originalData: row,
|
||
additionalData: {},
|
||
}));
|
||
|
||
useModalDataStore.getState().setData(tableConfig.selectedTable!, modalItems);
|
||
console.log("✅ [TableList] modalDataStore에 데이터 저장:", {
|
||
dataSourceId: tableConfig.selectedTable,
|
||
count: modalItems.length,
|
||
});
|
||
});
|
||
} else if (tableConfig.selectedTable && selectedRowsData.length === 0) {
|
||
// 선택 해제 시 데이터 제거
|
||
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
||
useModalDataStore.getState().clearData(tableConfig.selectedTable!);
|
||
console.log("🗑️ [TableList] modalDataStore 데이터 제거:", tableConfig.selectedTable);
|
||
});
|
||
}
|
||
|
||
const allRowsSelected = filteredData.every((row, index) => newSelectedRows.has(getRowKey(row, index)));
|
||
setIsAllSelected(allRowsSelected && filteredData.length > 0);
|
||
};
|
||
|
||
const handleSelectAll = (checked: boolean) => {
|
||
if (checked) {
|
||
const allKeys = filteredData.map((row, index) => getRowKey(row, index));
|
||
const newSelectedRows = new Set(allKeys);
|
||
setSelectedRows(newSelectedRows);
|
||
setIsAllSelected(true);
|
||
|
||
if (onSelectedRowsChange) {
|
||
onSelectedRowsChange(Array.from(newSelectedRows), filteredData, sortColumn || undefined, sortDirection);
|
||
}
|
||
if (onFormDataChange) {
|
||
onFormDataChange({
|
||
selectedRows: Array.from(newSelectedRows),
|
||
selectedRowsData: filteredData,
|
||
});
|
||
}
|
||
|
||
// 🆕 modalDataStore에 전체 데이터 저장
|
||
if (tableConfig.selectedTable && filteredData.length > 0) {
|
||
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
||
const modalItems = filteredData.map((row, idx) => ({
|
||
id: getRowKey(row, idx),
|
||
originalData: row,
|
||
additionalData: {},
|
||
}));
|
||
|
||
useModalDataStore.getState().setData(tableConfig.selectedTable!, modalItems);
|
||
console.log("✅ [TableList] modalDataStore에 전체 데이터 저장:", {
|
||
dataSourceId: tableConfig.selectedTable,
|
||
count: modalItems.length,
|
||
});
|
||
});
|
||
}
|
||
} else {
|
||
setSelectedRows(new Set());
|
||
setIsAllSelected(false);
|
||
|
||
if (onSelectedRowsChange) {
|
||
onSelectedRowsChange([], [], sortColumn || undefined, sortDirection);
|
||
}
|
||
if (onFormDataChange) {
|
||
onFormDataChange({ selectedRows: [], selectedRowsData: [] });
|
||
}
|
||
|
||
// 🆕 modalDataStore 데이터 제거
|
||
if (tableConfig.selectedTable) {
|
||
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
||
useModalDataStore.getState().clearData(tableConfig.selectedTable!);
|
||
console.log("🗑️ [TableList] modalDataStore 전체 데이터 제거:", tableConfig.selectedTable);
|
||
});
|
||
}
|
||
}
|
||
};
|
||
|
||
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);
|
||
|
||
// 🆕 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우)
|
||
// disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달)
|
||
// currentSplitPosition을 사용하여 정확한 위치 확인 (splitPanelPosition이 없을 수 있음)
|
||
const effectiveSplitPosition = splitPanelPosition || currentSplitPosition;
|
||
|
||
console.log("🔗 [TableList] 행 클릭 - 분할 패널 위치 확인:", {
|
||
splitPanelPosition,
|
||
currentSplitPosition,
|
||
effectiveSplitPosition,
|
||
hasSplitPanelContext: !!splitPanelContext,
|
||
disableAutoDataTransfer: splitPanelContext?.disableAutoDataTransfer,
|
||
});
|
||
|
||
if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
|
||
if (!isCurrentlySelected) {
|
||
// 선택된 경우: 데이터 저장
|
||
splitPanelContext.setSelectedLeftData(row);
|
||
console.log("🔗 [TableList] 분할 패널 좌측 데이터 저장:", {
|
||
row,
|
||
parentDataMapping: splitPanelContext.parentDataMapping,
|
||
});
|
||
} else {
|
||
// 선택 해제된 경우: 데이터 초기화
|
||
splitPanelContext.setSelectedLeftData(null);
|
||
console.log("🔗 [TableList] 분할 패널 좌측 데이터 초기화");
|
||
}
|
||
}
|
||
|
||
console.log("행 클릭:", { row, index, isSelected: !isCurrentlySelected });
|
||
};
|
||
|
||
// 🆕 셀 클릭 핸들러 (포커스 설정 + 행 선택)
|
||
const handleCellClick = (rowIndex: number, colIndex: number, e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
setFocusedCell({ rowIndex, colIndex });
|
||
// 테이블 컨테이너에 포커스 설정 (키보드 이벤트 수신용)
|
||
tableContainerRef.current?.focus();
|
||
|
||
// 🆕 분할 패널 내에서 셀 클릭 시에도 해당 행 선택 처리
|
||
// filteredData에서 해당 행의 데이터 가져오기
|
||
const row = filteredData[rowIndex];
|
||
if (!row) return;
|
||
|
||
const rowKey = getRowKey(row, rowIndex);
|
||
const isCurrentlySelected = selectedRows.has(rowKey);
|
||
|
||
// 분할 패널 컨텍스트가 있고, 좌측 화면인 경우에만 행 선택 및 데이터 전달
|
||
const effectiveSplitPosition = splitPanelPosition || currentSplitPosition;
|
||
|
||
if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
|
||
// 이미 선택된 행과 다른 행을 클릭한 경우에만 처리
|
||
if (!isCurrentlySelected) {
|
||
// 기존 선택 해제하고 새 행 선택
|
||
setSelectedRows(new Set([rowKey]));
|
||
setIsAllSelected(false);
|
||
|
||
// 분할 패널 컨텍스트에 데이터 저장
|
||
splitPanelContext.setSelectedLeftData(row);
|
||
|
||
// onSelectedRowsChange 콜백 호출
|
||
if (onSelectedRowsChange) {
|
||
onSelectedRowsChange([rowKey], [row], sortColumn || undefined, sortDirection);
|
||
}
|
||
if (onFormDataChange) {
|
||
onFormDataChange({ selectedRows: [rowKey], selectedRowsData: [row] });
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
// 🆕 셀 더블클릭 핸들러 (편집 모드 진입) - visibleColumns 정의 후 사용
|
||
const handleCellDoubleClick = useCallback(
|
||
(rowIndex: number, colIndex: number, columnName: string, value: any) => {
|
||
// 체크박스 컬럼은 편집 불가
|
||
if (columnName === "__checkbox__") return;
|
||
|
||
// 🆕 편집 불가 컬럼 체크
|
||
const column = visibleColumns.find((col) => col.columnName === columnName);
|
||
if (column?.editable === false) {
|
||
toast.warning(`'${column.displayName || columnName}' 컬럼은 편집할 수 없습니다.`);
|
||
return;
|
||
}
|
||
|
||
setEditingCell({ rowIndex, colIndex, columnName, originalValue: value });
|
||
setEditingValue(value !== null && value !== undefined ? String(value) : "");
|
||
setFocusedCell({ rowIndex, colIndex });
|
||
},
|
||
[visibleColumns],
|
||
);
|
||
|
||
// 🆕 편집 모드 진입 placeholder (실제 구현은 visibleColumns 정의 후)
|
||
const startEditingRef = useRef<() => void>(() => {});
|
||
|
||
// 🆕 각 컬럼의 고유값 목록 계산
|
||
const columnUniqueValues = useMemo(() => {
|
||
const result: Record<string, string[]> = {};
|
||
|
||
if (data.length === 0) return result;
|
||
|
||
(tableConfig.columns || []).forEach((column: { columnName: string }) => {
|
||
if (column.columnName === "__checkbox__") return;
|
||
|
||
const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName;
|
||
const values = new Set<string>();
|
||
|
||
data.forEach((row) => {
|
||
const val = row[mappedColumnName];
|
||
if (val !== null && val !== undefined && val !== "") {
|
||
values.add(String(val));
|
||
}
|
||
});
|
||
|
||
result[column.columnName] = Array.from(values).sort();
|
||
});
|
||
|
||
return result;
|
||
}, [data, tableConfig.columns, joinColumnMapping]);
|
||
|
||
// 🆕 헤더 필터 토글
|
||
const toggleHeaderFilter = useCallback((columnName: string, value: string) => {
|
||
setHeaderFilters((prev) => {
|
||
const current = prev[columnName] || new Set<string>();
|
||
const newSet = new Set(current);
|
||
|
||
if (newSet.has(value)) {
|
||
newSet.delete(value);
|
||
} else {
|
||
newSet.add(value);
|
||
}
|
||
|
||
return { ...prev, [columnName]: newSet };
|
||
});
|
||
}, []);
|
||
|
||
// 🆕 헤더 필터 초기화
|
||
const clearHeaderFilter = useCallback((columnName: string) => {
|
||
setHeaderFilters((prev) => {
|
||
const newFilters = { ...prev };
|
||
delete newFilters[columnName];
|
||
return newFilters;
|
||
});
|
||
setOpenFilterColumn(null);
|
||
}, []);
|
||
|
||
// 🆕 모든 헤더 필터 초기화
|
||
const clearAllHeaderFilters = useCallback(() => {
|
||
setHeaderFilters({});
|
||
setOpenFilterColumn(null);
|
||
}, []);
|
||
|
||
// 🆕 데이터 요약 (Total Summaries) 설정
|
||
// 형식: { columnName: { type: 'sum' | 'avg' | 'count' | 'min' | 'max', label?: string } }
|
||
const summaryConfig = useMemo(() => {
|
||
const config: Record<string, { type: string; label?: string }> = {};
|
||
|
||
// tableConfig에서 summary 설정 읽기
|
||
if (tableConfig.summaries) {
|
||
tableConfig.summaries.forEach((summary: { columnName: string; type: string; label?: string }) => {
|
||
config[summary.columnName] = { type: summary.type, label: summary.label };
|
||
});
|
||
}
|
||
|
||
return config;
|
||
}, [tableConfig.summaries]);
|
||
|
||
// 🆕 요약 데이터 계산
|
||
const summaryData = useMemo(() => {
|
||
if (Object.keys(summaryConfig).length === 0 || data.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
const result: Record<string, { value: number | string; label: string }> = {};
|
||
|
||
Object.entries(summaryConfig).forEach(([columnName, config]) => {
|
||
const values = data
|
||
.map((row) => {
|
||
const mappedColumnName = joinColumnMapping[columnName] || columnName;
|
||
const val = row[mappedColumnName];
|
||
return typeof val === "number" ? val : parseFloat(val);
|
||
})
|
||
.filter((v) => !isNaN(v));
|
||
|
||
let value: number | string = 0;
|
||
let label = config.label || "";
|
||
|
||
switch (config.type) {
|
||
case "sum":
|
||
value = values.reduce((acc, v) => acc + v, 0);
|
||
label = label || "합계";
|
||
break;
|
||
case "avg":
|
||
value = values.length > 0 ? values.reduce((acc, v) => acc + v, 0) / values.length : 0;
|
||
label = label || "평균";
|
||
break;
|
||
case "count":
|
||
value = data.length;
|
||
label = label || "개수";
|
||
break;
|
||
case "min":
|
||
value = values.length > 0 ? Math.min(...values) : 0;
|
||
label = label || "최소";
|
||
break;
|
||
case "max":
|
||
value = values.length > 0 ? Math.max(...values) : 0;
|
||
label = label || "최대";
|
||
break;
|
||
default:
|
||
value = 0;
|
||
}
|
||
|
||
result[columnName] = { value, label };
|
||
});
|
||
|
||
return result;
|
||
}, [data, summaryConfig, joinColumnMapping]);
|
||
|
||
// 🆕 편집 취소
|
||
const cancelEditing = useCallback(() => {
|
||
setEditingCell(null);
|
||
setEditingValue("");
|
||
tableContainerRef.current?.focus();
|
||
}, []);
|
||
|
||
// 🆕 편집 저장 (즉시 저장 또는 배치 저장)
|
||
const saveEditing = useCallback(async () => {
|
||
if (!editingCell) return;
|
||
|
||
const { rowIndex, columnName, originalValue } = editingCell;
|
||
const newValue = editingValue;
|
||
|
||
// 값이 변경되지 않았으면 그냥 닫기
|
||
if (String(originalValue ?? "") === newValue) {
|
||
setCellValidationError(rowIndex, columnName, null); // 에러 초기화
|
||
cancelEditing();
|
||
return;
|
||
}
|
||
|
||
// 현재 행 데이터 가져오기
|
||
const row = data[rowIndex];
|
||
if (!row || !tableConfig.selectedTable) {
|
||
cancelEditing();
|
||
return;
|
||
}
|
||
|
||
// 🆕 유효성 검사 실행
|
||
const validationError = validateValue(newValue === "" ? null : newValue, columnName, row);
|
||
if (validationError) {
|
||
setCellValidationError(rowIndex, columnName, validationError);
|
||
toast.error(validationError);
|
||
// 편집 상태 유지 (에러 수정 가능하도록)
|
||
return;
|
||
}
|
||
// 유효성 통과 시 에러 초기화
|
||
setCellValidationError(rowIndex, columnName, null);
|
||
|
||
// 기본 키 필드 찾기 (id 또는 첫 번째 컬럼)
|
||
const primaryKeyField = tableConfig.primaryKey || "id";
|
||
const primaryKeyValue = row[primaryKeyField];
|
||
|
||
if (primaryKeyValue === undefined || primaryKeyValue === null) {
|
||
console.error("기본 키 값을 찾을 수 없습니다:", primaryKeyField);
|
||
cancelEditing();
|
||
return;
|
||
}
|
||
|
||
// 🆕 배치 모드: 변경사항을 pending에 저장
|
||
if (editMode === "batch") {
|
||
const changeKey = `${rowIndex}-${columnName}`;
|
||
setPendingChanges((prev) => {
|
||
const newMap = new Map(prev);
|
||
newMap.set(changeKey, {
|
||
rowIndex,
|
||
columnName,
|
||
originalValue,
|
||
newValue: newValue === "" ? null : newValue,
|
||
primaryKeyValue,
|
||
});
|
||
return newMap;
|
||
});
|
||
|
||
// 로컬 수정 데이터 업데이트 (화면 표시용)
|
||
setLocalEditedData((prev) => ({
|
||
...prev,
|
||
[rowIndex]: {
|
||
...(prev[rowIndex] || {}),
|
||
[columnName]: newValue === "" ? null : newValue,
|
||
},
|
||
}));
|
||
|
||
console.log("📝 배치 편집 추가:", { columnName, newValue, pendingCount: pendingChanges.size + 1 });
|
||
cancelEditing();
|
||
return;
|
||
}
|
||
|
||
// 🆕 즉시 모드: 바로 저장
|
||
try {
|
||
const { apiClient } = await import("@/lib/api/client");
|
||
|
||
await apiClient.put(`/dynamic-form/update-field`, {
|
||
tableName: tableConfig.selectedTable,
|
||
keyField: primaryKeyField,
|
||
keyValue: primaryKeyValue,
|
||
updateField: columnName,
|
||
updateValue: newValue === "" ? null : newValue,
|
||
});
|
||
|
||
// 데이터 새로고침 트리거
|
||
setRefreshTrigger((prev) => prev + 1);
|
||
|
||
console.log("✅ 셀 편집 저장 완료:", { columnName, newValue });
|
||
} catch (error) {
|
||
console.error("❌ 셀 편집 저장 실패:", error);
|
||
}
|
||
|
||
cancelEditing();
|
||
}, [
|
||
editingCell,
|
||
editingValue,
|
||
data,
|
||
tableConfig.selectedTable,
|
||
tableConfig.primaryKey,
|
||
cancelEditing,
|
||
editMode,
|
||
pendingChanges.size,
|
||
]);
|
||
|
||
// 🆕 배치 저장: 모든 변경사항 한번에 저장
|
||
const saveBatchChanges = useCallback(async () => {
|
||
if (pendingChanges.size === 0) {
|
||
toast.info("저장할 변경사항이 없습니다.");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const { apiClient } = await import("@/lib/api/client");
|
||
const primaryKeyField = tableConfig.primaryKey || "id";
|
||
|
||
// 모든 변경사항 저장
|
||
const savePromises = Array.from(pendingChanges.values()).map((change) =>
|
||
apiClient.put(`/dynamic-form/update-field`, {
|
||
tableName: tableConfig.selectedTable,
|
||
keyField: primaryKeyField,
|
||
keyValue: change.primaryKeyValue,
|
||
updateField: change.columnName,
|
||
updateValue: change.newValue,
|
||
}),
|
||
);
|
||
|
||
await Promise.all(savePromises);
|
||
|
||
// 상태 초기화
|
||
setPendingChanges(new Map());
|
||
setLocalEditedData({});
|
||
setRefreshTrigger((prev) => prev + 1);
|
||
|
||
toast.success(`${pendingChanges.size}개의 변경사항이 저장되었습니다.`);
|
||
console.log("✅ 배치 저장 완료:", pendingChanges.size, "개");
|
||
} catch (error) {
|
||
console.error("❌ 배치 저장 실패:", error);
|
||
toast.error("저장 중 오류가 발생했습니다.");
|
||
}
|
||
}, [pendingChanges, tableConfig.selectedTable, tableConfig.primaryKey]);
|
||
|
||
// 🆕 배치 취소: 모든 변경사항 롤백
|
||
const cancelBatchChanges = useCallback(() => {
|
||
if (pendingChanges.size === 0) return;
|
||
|
||
setPendingChanges(new Map());
|
||
setLocalEditedData({});
|
||
toast.info("변경사항이 취소되었습니다.");
|
||
console.log("🔄 배치 편집 취소");
|
||
}, [pendingChanges.size]);
|
||
|
||
// 🆕 특정 셀이 수정되었는지 확인
|
||
const isCellModified = useCallback(
|
||
(rowIndex: number, columnName: string) => {
|
||
return pendingChanges.has(`${rowIndex}-${columnName}`);
|
||
},
|
||
[pendingChanges],
|
||
);
|
||
|
||
// 🆕 수정된 셀 값 가져오기 (로컬 수정 데이터 우선)
|
||
const getDisplayValue = useCallback(
|
||
(row: any, rowIndex: number, columnName: string) => {
|
||
const localValue = localEditedData[rowIndex]?.[columnName];
|
||
if (localValue !== undefined) {
|
||
return localValue;
|
||
}
|
||
return row[columnName];
|
||
},
|
||
[localEditedData],
|
||
);
|
||
|
||
// 🆕 유효성 검사 함수
|
||
const validateValue = useCallback(
|
||
(value: any, columnName: string, row: any): string | null => {
|
||
// tableConfig.validation에서 컬럼별 규칙 가져오기
|
||
const rules = (tableConfig as any).validation?.[columnName] as ValidationRule | undefined;
|
||
if (!rules) return null;
|
||
|
||
const strValue = value !== null && value !== undefined ? String(value) : "";
|
||
const numValue = parseFloat(strValue);
|
||
|
||
// 필수 검사
|
||
if (rules.required && (!strValue || strValue.trim() === "")) {
|
||
return rules.customMessage || "필수 입력 항목입니다.";
|
||
}
|
||
|
||
// 값이 비어있으면 다른 검사 스킵 (required가 아닌 경우)
|
||
if (!strValue || strValue.trim() === "") return null;
|
||
|
||
// 최소값 검사
|
||
if (rules.min !== undefined && !isNaN(numValue) && numValue < rules.min) {
|
||
return rules.customMessage || `최소값은 ${rules.min}입니다.`;
|
||
}
|
||
|
||
// 최대값 검사
|
||
if (rules.max !== undefined && !isNaN(numValue) && numValue > rules.max) {
|
||
return rules.customMessage || `최대값은 ${rules.max}입니다.`;
|
||
}
|
||
|
||
// 최소 길이 검사
|
||
if (rules.minLength !== undefined && strValue.length < rules.minLength) {
|
||
return rules.customMessage || `최소 ${rules.minLength}자 이상 입력해주세요.`;
|
||
}
|
||
|
||
// 최대 길이 검사
|
||
if (rules.maxLength !== undefined && strValue.length > rules.maxLength) {
|
||
return rules.customMessage || `최대 ${rules.maxLength}자까지 입력 가능합니다.`;
|
||
}
|
||
|
||
// 패턴 검사
|
||
if (rules.pattern && !rules.pattern.test(strValue)) {
|
||
return rules.customMessage || "입력 형식이 올바르지 않습니다.";
|
||
}
|
||
|
||
// 커스텀 검증
|
||
if (rules.validate) {
|
||
const customError = rules.validate(value, row);
|
||
if (customError) return customError;
|
||
}
|
||
|
||
return null;
|
||
},
|
||
[tableConfig],
|
||
);
|
||
|
||
// 🆕 셀 유효성 에러 여부 확인
|
||
const getCellValidationError = useCallback(
|
||
(rowIndex: number, columnName: string): string | null => {
|
||
return validationErrors.get(`${rowIndex}-${columnName}`) || null;
|
||
},
|
||
[validationErrors],
|
||
);
|
||
|
||
// 🆕 유효성 검사 에러 설정
|
||
const setCellValidationError = useCallback((rowIndex: number, columnName: string, error: string | null) => {
|
||
setValidationErrors((prev) => {
|
||
const newMap = new Map(prev);
|
||
const key = `${rowIndex}-${columnName}`;
|
||
if (error) {
|
||
newMap.set(key, error);
|
||
} else {
|
||
newMap.delete(key);
|
||
}
|
||
return newMap;
|
||
});
|
||
}, []);
|
||
|
||
// 🆕 모든 유효성 에러 초기화
|
||
const clearAllValidationErrors = useCallback(() => {
|
||
setValidationErrors(new Map());
|
||
}, []);
|
||
|
||
// 🆕 Excel 내보내기 함수
|
||
const exportToExcel = useCallback(
|
||
(exportAll: boolean = true) => {
|
||
try {
|
||
// 내보낼 데이터 선택 (선택된 행만 또는 전체)
|
||
let exportData: any[];
|
||
if (exportAll) {
|
||
exportData = filteredData;
|
||
} else {
|
||
// 선택된 행만 내보내기
|
||
exportData = filteredData.filter((row, index) => {
|
||
const rowKey = getRowKey(row, index);
|
||
return selectedRows.has(rowKey);
|
||
});
|
||
}
|
||
|
||
if (exportData.length === 0) {
|
||
toast.error(exportAll ? "내보낼 데이터가 없습니다." : "선택된 행이 없습니다.");
|
||
return;
|
||
}
|
||
|
||
// 컬럼 정보 가져오기 (체크박스 제외)
|
||
const exportColumns = visibleColumns.filter((col) => col.columnName !== "__checkbox__");
|
||
|
||
// 헤더 행 생성
|
||
const headers = exportColumns.map((col) => columnLabels[col.columnName] || col.columnName);
|
||
|
||
// 데이터 행 생성
|
||
const rows = exportData.map((row) => {
|
||
return exportColumns.map((col) => {
|
||
const mappedColumnName = joinColumnMapping[col.columnName] || col.columnName;
|
||
const value = row[mappedColumnName];
|
||
|
||
// 카테고리 매핑된 값 처리
|
||
if (categoryMappings[col.columnName] && value !== null && value !== undefined) {
|
||
const mapping = categoryMappings[col.columnName][String(value)];
|
||
if (mapping) {
|
||
return mapping.label;
|
||
}
|
||
}
|
||
|
||
// null/undefined 처리
|
||
if (value === null || value === undefined) {
|
||
return "";
|
||
}
|
||
|
||
return value;
|
||
});
|
||
});
|
||
|
||
// 워크시트 생성
|
||
const wsData = [headers, ...rows];
|
||
const ws = XLSX.utils.aoa_to_sheet(wsData);
|
||
|
||
// 컬럼 너비 자동 조정
|
||
const colWidths = exportColumns.map((col, idx) => {
|
||
const headerLength = headers[idx]?.length || 10;
|
||
const maxDataLength = Math.max(...rows.map((row) => String(row[idx] ?? "").length));
|
||
return { wch: Math.min(Math.max(headerLength, maxDataLength) + 2, 50) };
|
||
});
|
||
ws["!cols"] = colWidths;
|
||
|
||
// 워크북 생성
|
||
const wb = XLSX.utils.book_new();
|
||
XLSX.utils.book_append_sheet(wb, ws, tableLabel || "데이터");
|
||
|
||
// 파일명 생성
|
||
const fileName = `${tableLabel || tableConfig.selectedTable || "export"}_${new Date().toISOString().split("T")[0]}.xlsx`;
|
||
|
||
// 파일 다운로드
|
||
XLSX.writeFile(wb, fileName);
|
||
|
||
toast.success(`${exportData.length}개 행이 Excel로 내보내기 되었습니다.`);
|
||
console.log("✅ Excel 내보내기 완료:", fileName);
|
||
} catch (error) {
|
||
console.error("❌ Excel 내보내기 실패:", error);
|
||
toast.error("Excel 내보내기 중 오류가 발생했습니다.");
|
||
}
|
||
},
|
||
[
|
||
filteredData,
|
||
selectedRows,
|
||
visibleColumns,
|
||
columnLabels,
|
||
joinColumnMapping,
|
||
categoryMappings,
|
||
tableLabel,
|
||
tableConfig.selectedTable,
|
||
getRowKey,
|
||
],
|
||
);
|
||
|
||
// 🆕 행 확장/축소 토글
|
||
const toggleRowExpand = useCallback(
|
||
async (rowKey: string, row: any) => {
|
||
setExpandedRows((prev) => {
|
||
const newSet = new Set(prev);
|
||
if (newSet.has(rowKey)) {
|
||
newSet.delete(rowKey);
|
||
} else {
|
||
newSet.add(rowKey);
|
||
// 상세 데이터 로딩 (아직 없는 경우)
|
||
if (!detailData[rowKey] && (tableConfig as any).masterDetail?.detailTable) {
|
||
loadDetailData(rowKey, row);
|
||
}
|
||
}
|
||
return newSet;
|
||
});
|
||
},
|
||
[detailData, tableConfig],
|
||
);
|
||
|
||
// 🆕 상세 데이터 로딩
|
||
const loadDetailData = useCallback(
|
||
async (rowKey: string, row: any) => {
|
||
const masterDetailConfig = (tableConfig as any).masterDetail;
|
||
if (!masterDetailConfig?.detailTable) return;
|
||
|
||
try {
|
||
const { apiClient } = await import("@/lib/api/client");
|
||
|
||
// masterKey 값 가져오기
|
||
const masterKeyField = masterDetailConfig.masterKey || "id";
|
||
const masterKeyValue = row[masterKeyField];
|
||
|
||
// 상세 테이블에서 데이터 조회
|
||
const response = await apiClient.post(`/table-management/tables/${masterDetailConfig.detailTable}/data`, {
|
||
page: 1,
|
||
size: 100,
|
||
search: {
|
||
[masterDetailConfig.detailKey || masterKeyField]: masterKeyValue,
|
||
},
|
||
autoFilter: true,
|
||
});
|
||
|
||
const details = response.data?.data?.data || [];
|
||
|
||
setDetailData((prev) => ({
|
||
...prev,
|
||
[rowKey]: details,
|
||
}));
|
||
|
||
console.log("✅ 상세 데이터 로딩 완료:", { rowKey, count: details.length });
|
||
} catch (error) {
|
||
console.error("❌ 상세 데이터 로딩 실패:", error);
|
||
setDetailData((prev) => ({
|
||
...prev,
|
||
[rowKey]: [],
|
||
}));
|
||
}
|
||
},
|
||
[tableConfig],
|
||
);
|
||
|
||
// 🆕 모든 행 확장/축소
|
||
const expandAllRows = useCallback(() => {
|
||
if (expandedRows.size === filteredData.length) {
|
||
// 모두 축소
|
||
setExpandedRows(new Set());
|
||
} else {
|
||
// 모두 확장
|
||
const allKeys = new Set(filteredData.map((row, index) => getRowKey(row, index)));
|
||
setExpandedRows(allKeys);
|
||
}
|
||
}, [expandedRows.size, filteredData, getRowKey]);
|
||
|
||
// 🆕 Multi-Level Headers: Band 정보 계산
|
||
const columnBandsInfo = useMemo(() => {
|
||
const bands = (tableConfig as any).columnBands as ColumnBand[] | undefined;
|
||
if (!bands || bands.length === 0) return null;
|
||
|
||
// 각 band의 시작 인덱스와 colspan 계산
|
||
const bandInfo = bands
|
||
.map((band) => {
|
||
const visibleBandColumns = band.columns.filter((colName) =>
|
||
visibleColumns.some((vc) => vc.columnName === colName),
|
||
);
|
||
|
||
const startIndex = visibleColumns.findIndex((vc) => visibleBandColumns.includes(vc.columnName));
|
||
|
||
return {
|
||
caption: band.caption,
|
||
columns: visibleBandColumns,
|
||
colSpan: visibleBandColumns.length,
|
||
startIndex,
|
||
};
|
||
})
|
||
.filter((b) => b.colSpan > 0);
|
||
|
||
// Band에 포함되지 않은 컬럼 찾기
|
||
const bandedColumns = new Set(bands.flatMap((b) => b.columns));
|
||
const unbandedColumns = visibleColumns
|
||
.map((vc, idx) => ({ columnName: vc.columnName, index: idx }))
|
||
.filter((c) => !bandedColumns.has(c.columnName));
|
||
|
||
return {
|
||
bands: bandInfo,
|
||
unbandedColumns,
|
||
hasBands: bandInfo.length > 0,
|
||
};
|
||
}, [tableConfig, visibleColumns]);
|
||
|
||
// 🆕 Cascading Lookups: 연계 드롭다운 옵션 로딩
|
||
const loadCascadingOptions = useCallback(
|
||
async (columnName: string, parentColumnName: string, parentValue: any) => {
|
||
const cascadingConfig = (tableConfig as any).cascadingLookups?.[columnName];
|
||
if (!cascadingConfig) return;
|
||
|
||
const cacheKey = `${columnName}_${parentValue}`;
|
||
|
||
// 이미 로딩 중이면 스킵
|
||
if (loadingCascading[cacheKey]) return;
|
||
|
||
// 이미 캐시된 데이터가 있으면 스킵
|
||
if (cascadingOptions[cacheKey]) return;
|
||
|
||
setLoadingCascading((prev) => ({ ...prev, [cacheKey]: true }));
|
||
|
||
try {
|
||
const { apiClient } = await import("@/lib/api/client");
|
||
|
||
// API에서 연계 옵션 로딩
|
||
const response = await apiClient.post(`/table-management/tables/${cascadingConfig.sourceTable}/data`, {
|
||
page: 1,
|
||
size: 1000,
|
||
search: {
|
||
[cascadingConfig.parentKeyField || parentColumnName]: parentValue,
|
||
},
|
||
autoFilter: true,
|
||
});
|
||
|
||
const items = response.data?.data?.data || [];
|
||
const options = items.map((item: any) => ({
|
||
value: item[cascadingConfig.valueField || "id"],
|
||
label: item[cascadingConfig.labelField || "name"],
|
||
}));
|
||
|
||
setCascadingOptions((prev) => ({
|
||
...prev,
|
||
[cacheKey]: options,
|
||
}));
|
||
|
||
console.log("✅ Cascading options 로딩 완료:", { columnName, parentValue, count: options.length });
|
||
} catch (error) {
|
||
console.error("❌ Cascading options 로딩 실패:", error);
|
||
setCascadingOptions((prev) => ({
|
||
...prev,
|
||
[cacheKey]: [],
|
||
}));
|
||
} finally {
|
||
setLoadingCascading((prev) => ({ ...prev, [cacheKey]: false }));
|
||
}
|
||
},
|
||
[tableConfig, cascadingOptions, loadingCascading],
|
||
);
|
||
|
||
// 🆕 Cascading Lookups: 특정 컬럼의 옵션 가져오기
|
||
const getCascadingOptions = useCallback(
|
||
(columnName: string, row: any): { value: string; label: string }[] => {
|
||
const cascadingConfig = (tableConfig as any).cascadingLookups?.[columnName];
|
||
if (!cascadingConfig) return [];
|
||
|
||
const parentValue = row[cascadingConfig.parentColumn];
|
||
if (parentValue === undefined || parentValue === null) return [];
|
||
|
||
const cacheKey = `${columnName}_${parentValue}`;
|
||
return cascadingOptions[cacheKey] || [];
|
||
},
|
||
[tableConfig, cascadingOptions],
|
||
);
|
||
|
||
// 🆕 Virtual Scrolling: virtualScrollInfo는 displayData 정의 이후로 이동됨 (아래 참조)
|
||
|
||
// 🆕 Virtual Scrolling: 스크롤 핸들러
|
||
const handleVirtualScroll = useCallback(
|
||
(e: React.UIEvent<HTMLDivElement>) => {
|
||
if (!isVirtualScrollEnabled) return;
|
||
setScrollTop(e.currentTarget.scrollTop);
|
||
},
|
||
[isVirtualScrollEnabled],
|
||
);
|
||
|
||
// 🆕 State Persistence: 통합 상태 저장
|
||
const saveTableState = useCallback(() => {
|
||
if (!tableStateKey) return;
|
||
|
||
const state = {
|
||
columnWidths,
|
||
columnOrder,
|
||
sortColumn,
|
||
sortDirection,
|
||
groupByColumns,
|
||
frozenColumns,
|
||
showGridLines,
|
||
headerFilters: Object.fromEntries(
|
||
Object.entries(headerFilters).map(([key, set]) => [key, Array.from(set as Set<string>)]),
|
||
),
|
||
pageSize: localPageSize,
|
||
timestamp: Date.now(),
|
||
};
|
||
|
||
try {
|
||
localStorage.setItem(tableStateKey, JSON.stringify(state));
|
||
} catch (error) {
|
||
console.error("❌ 테이블 상태 저장 실패:", error);
|
||
}
|
||
}, [
|
||
tableStateKey,
|
||
columnWidths,
|
||
columnOrder,
|
||
sortColumn,
|
||
sortDirection,
|
||
groupByColumns,
|
||
frozenColumns,
|
||
showGridLines,
|
||
headerFilters,
|
||
localPageSize,
|
||
]);
|
||
|
||
// 🆕 State Persistence: 통합 상태 복원
|
||
const loadTableState = useCallback(() => {
|
||
if (!tableStateKey) return;
|
||
|
||
try {
|
||
const saved = localStorage.getItem(tableStateKey);
|
||
if (!saved) return;
|
||
|
||
const state = JSON.parse(saved);
|
||
|
||
if (state.columnWidths) setColumnWidths(state.columnWidths);
|
||
if (state.columnOrder) setColumnOrder(state.columnOrder);
|
||
if (state.sortColumn !== undefined) setSortColumn(state.sortColumn);
|
||
if (state.sortDirection) setSortDirection(state.sortDirection);
|
||
if (state.groupByColumns) setGroupByColumns(state.groupByColumns);
|
||
if (state.frozenColumns) setFrozenColumns(state.frozenColumns);
|
||
if (state.showGridLines !== undefined) setShowGridLines(state.showGridLines);
|
||
if (state.headerFilters) {
|
||
const filters: Record<string, Set<string>> = {};
|
||
Object.entries(state.headerFilters).forEach(([key, values]) => {
|
||
filters[key] = new Set(values as string[]);
|
||
});
|
||
setHeaderFilters(filters);
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error("❌ 테이블 상태 복원 실패:", error);
|
||
}
|
||
}, [tableStateKey]);
|
||
|
||
// 🆕 State Persistence: 상태 초기화
|
||
const resetTableState = useCallback(() => {
|
||
if (!tableStateKey) return;
|
||
|
||
try {
|
||
localStorage.removeItem(tableStateKey);
|
||
setColumnWidths({});
|
||
setColumnOrder([]);
|
||
setSortColumn(null);
|
||
setSortDirection("asc");
|
||
setGroupByColumns([]);
|
||
setFrozenColumns([]);
|
||
setShowGridLines(true);
|
||
setHeaderFilters({});
|
||
toast.success("테이블 설정이 초기화되었습니다.");
|
||
} catch (error) {
|
||
console.error("❌ 테이블 상태 초기화 실패:", error);
|
||
}
|
||
}, [tableStateKey]);
|
||
|
||
// 🆕 State Persistence: 컴포넌트 마운트 시 상태 복원
|
||
useEffect(() => {
|
||
loadTableState();
|
||
}, [tableStateKey]); // loadTableState는 의존성에서 제외 (무한 루프 방지)
|
||
|
||
// 🆕 Real-Time Updates: WebSocket 연결
|
||
const connectWebSocket = useCallback(() => {
|
||
if (!isRealTimeEnabled || !tableConfig.selectedTable) return;
|
||
|
||
const wsUrl =
|
||
(tableConfig as any).wsUrl ||
|
||
`${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws/table/${tableConfig.selectedTable}`;
|
||
|
||
try {
|
||
setWsConnectionStatus("connecting");
|
||
wsRef.current = new WebSocket(wsUrl);
|
||
|
||
wsRef.current.onopen = () => {
|
||
setWsConnectionStatus("connected");
|
||
console.log("✅ WebSocket 연결됨:", tableConfig.selectedTable);
|
||
};
|
||
|
||
wsRef.current.onmessage = (event) => {
|
||
try {
|
||
const message = JSON.parse(event.data);
|
||
console.log("📨 WebSocket 메시지 수신:", message);
|
||
|
||
switch (message.type) {
|
||
case "insert":
|
||
// 새 데이터 추가
|
||
setRefreshTrigger((prev) => prev + 1);
|
||
toast.info("새 데이터가 추가되었습니다.");
|
||
break;
|
||
case "update":
|
||
// 데이터 업데이트
|
||
setRefreshTrigger((prev) => prev + 1);
|
||
toast.info("데이터가 업데이트되었습니다.");
|
||
break;
|
||
case "delete":
|
||
// 데이터 삭제
|
||
setRefreshTrigger((prev) => prev + 1);
|
||
toast.info("데이터가 삭제되었습니다.");
|
||
break;
|
||
case "refresh":
|
||
// 전체 새로고침
|
||
setRefreshTrigger((prev) => prev + 1);
|
||
break;
|
||
default:
|
||
console.log("알 수 없는 메시지 타입:", message.type);
|
||
}
|
||
} catch (error) {
|
||
console.error("WebSocket 메시지 파싱 오류:", error);
|
||
}
|
||
};
|
||
|
||
wsRef.current.onclose = () => {
|
||
setWsConnectionStatus("disconnected");
|
||
console.log("🔌 WebSocket 연결 종료");
|
||
|
||
// 자동 재연결 (5초 후)
|
||
if (isRealTimeEnabled) {
|
||
reconnectTimeoutRef.current = setTimeout(() => {
|
||
console.log("🔄 WebSocket 재연결 시도...");
|
||
connectWebSocket();
|
||
}, 5000);
|
||
}
|
||
};
|
||
|
||
wsRef.current.onerror = (error) => {
|
||
console.error("❌ WebSocket 오류:", error);
|
||
setWsConnectionStatus("disconnected");
|
||
};
|
||
} catch (error) {
|
||
console.error("WebSocket 연결 실패:", error);
|
||
setWsConnectionStatus("disconnected");
|
||
}
|
||
}, [isRealTimeEnabled, tableConfig.selectedTable]);
|
||
|
||
// 🆕 Real-Time Updates: 연결 관리
|
||
useEffect(() => {
|
||
if (isRealTimeEnabled) {
|
||
connectWebSocket();
|
||
}
|
||
|
||
return () => {
|
||
// 정리
|
||
if (reconnectTimeoutRef.current) {
|
||
clearTimeout(reconnectTimeoutRef.current);
|
||
}
|
||
if (wsRef.current) {
|
||
wsRef.current.close();
|
||
wsRef.current = null;
|
||
}
|
||
};
|
||
}, [isRealTimeEnabled, tableConfig.selectedTable]);
|
||
|
||
// 🆕 State Persistence: 상태 변경 시 자동 저장 (디바운스)
|
||
useEffect(() => {
|
||
const timeoutId = setTimeout(() => {
|
||
saveTableState();
|
||
}, 1000); // 1초 후 저장 (디바운스)
|
||
|
||
return () => clearTimeout(timeoutId);
|
||
}, [
|
||
columnWidths,
|
||
columnOrder,
|
||
sortColumn,
|
||
sortDirection,
|
||
groupByColumns,
|
||
frozenColumns,
|
||
showGridLines,
|
||
headerFilters,
|
||
]);
|
||
|
||
// 🆕 Clipboard: 선택된 데이터 복사
|
||
const handleCopy = useCallback(async () => {
|
||
try {
|
||
// 선택된 행 데이터 가져오기
|
||
let copyData: any[];
|
||
|
||
if (selectedRows.size > 0) {
|
||
// 선택된 행만
|
||
copyData = filteredData.filter((row, index) => {
|
||
const rowKey = getRowKey(row, index);
|
||
return selectedRows.has(rowKey);
|
||
});
|
||
} else if (focusedCell) {
|
||
// 포커스된 셀만
|
||
const row = filteredData[focusedCell.rowIndex];
|
||
if (row) {
|
||
const column = visibleColumns[focusedCell.colIndex];
|
||
const value = row[column?.columnName];
|
||
await navigator.clipboard.writeText(String(value ?? ""));
|
||
toast.success("셀 복사됨");
|
||
return;
|
||
}
|
||
return;
|
||
} else {
|
||
toast.info("복사할 데이터를 선택해주세요.");
|
||
return;
|
||
}
|
||
|
||
// TSV 형식으로 변환 (탭으로 구분)
|
||
const exportColumns = visibleColumns.filter((c) => c.columnName !== "__checkbox__");
|
||
const headers = exportColumns.map((c) => columnLabels[c.columnName] || c.columnName);
|
||
const rows = copyData.map((row) =>
|
||
exportColumns
|
||
.map((c) => {
|
||
const value = row[c.columnName];
|
||
return value !== null && value !== undefined ? String(value).replace(/\t/g, " ").replace(/\n/g, " ") : "";
|
||
})
|
||
.join("\t"),
|
||
);
|
||
|
||
const tsvContent = [headers.join("\t"), ...rows].join("\n");
|
||
await navigator.clipboard.writeText(tsvContent);
|
||
|
||
toast.success(`${copyData.length}행 복사됨`);
|
||
console.log("✅ 클립보드 복사:", copyData.length, "행");
|
||
} catch (error) {
|
||
console.error("❌ 클립보드 복사 실패:", error);
|
||
toast.error("복사 실패");
|
||
}
|
||
}, [selectedRows, filteredData, focusedCell, visibleColumns, columnLabels, getRowKey]);
|
||
|
||
// 🆕 전체 행 선택
|
||
const handleSelectAllRows = useCallback(() => {
|
||
if (selectedRows.size === filteredData.length) {
|
||
// 전체 해제
|
||
setSelectedRows(new Set());
|
||
setIsAllSelected(false);
|
||
} else {
|
||
// 전체 선택
|
||
const allKeys = new Set(filteredData.map((row, index) => getRowKey(row, index)));
|
||
setSelectedRows(allKeys);
|
||
setIsAllSelected(true);
|
||
}
|
||
}, [selectedRows.size, filteredData, getRowKey]);
|
||
|
||
// 🆕 Context Menu: 열기
|
||
const handleContextMenu = useCallback((e: React.MouseEvent, rowIndex: number, colIndex: number, row: any) => {
|
||
e.preventDefault();
|
||
setContextMenu({
|
||
x: e.clientX,
|
||
y: e.clientY,
|
||
rowIndex,
|
||
colIndex,
|
||
row,
|
||
});
|
||
}, []);
|
||
|
||
// 🆕 Context Menu: 닫기
|
||
const closeContextMenu = useCallback(() => {
|
||
setContextMenu(null);
|
||
}, []);
|
||
|
||
// 🆕 Context Menu: 외부 클릭 시 닫기
|
||
useEffect(() => {
|
||
if (contextMenu) {
|
||
const handleClick = () => closeContextMenu();
|
||
document.addEventListener("click", handleClick);
|
||
return () => document.removeEventListener("click", handleClick);
|
||
}
|
||
}, [contextMenu, closeContextMenu]);
|
||
|
||
// 🆕 Search Panel: 통합 검색 실행
|
||
const executeGlobalSearch = useCallback(
|
||
(term: string) => {
|
||
if (!term.trim()) {
|
||
setSearchHighlights(new Set());
|
||
return;
|
||
}
|
||
|
||
const lowerTerm = term.toLowerCase();
|
||
const highlights = new Set<string>();
|
||
|
||
filteredData.forEach((row, rowIndex) => {
|
||
visibleColumns.forEach((col, colIndex) => {
|
||
const value = row[col.columnName];
|
||
if (value !== null && value !== undefined) {
|
||
const strValue = String(value).toLowerCase();
|
||
if (strValue.includes(lowerTerm)) {
|
||
highlights.add(`${rowIndex}-${colIndex}`);
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
setSearchHighlights(highlights);
|
||
|
||
// 첫 번째 검색 결과로 포커스 이동
|
||
if (highlights.size > 0) {
|
||
const firstHighlight = Array.from(highlights)[0];
|
||
const [rowIdx, colIdx] = firstHighlight.split("-").map(Number);
|
||
setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx });
|
||
toast.success(`${highlights.size}개 검색 결과`);
|
||
} else {
|
||
toast.info("검색 결과가 없습니다");
|
||
}
|
||
},
|
||
[filteredData, visibleColumns],
|
||
);
|
||
|
||
// 🆕 Search Panel: 다음 검색 결과로 이동
|
||
const goToNextSearchResult = useCallback(() => {
|
||
if (searchHighlights.size === 0) return;
|
||
|
||
const highlightArray = Array.from(searchHighlights).sort((a, b) => {
|
||
const [aRow, aCol] = a.split("-").map(Number);
|
||
const [bRow, bCol] = b.split("-").map(Number);
|
||
if (aRow !== bRow) return aRow - bRow;
|
||
return aCol - bCol;
|
||
});
|
||
|
||
if (!focusedCell) {
|
||
const [rowIdx, colIdx] = highlightArray[0].split("-").map(Number);
|
||
setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx });
|
||
return;
|
||
}
|
||
|
||
const currentKey = `${focusedCell.rowIndex}-${focusedCell.colIndex}`;
|
||
const currentIndex = highlightArray.indexOf(currentKey);
|
||
const nextIndex = (currentIndex + 1) % highlightArray.length;
|
||
const [rowIdx, colIdx] = highlightArray[nextIndex].split("-").map(Number);
|
||
setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx });
|
||
}, [searchHighlights, focusedCell]);
|
||
|
||
// 🆕 Search Panel: 이전 검색 결과로 이동
|
||
const goToPrevSearchResult = useCallback(() => {
|
||
if (searchHighlights.size === 0) return;
|
||
|
||
const highlightArray = Array.from(searchHighlights).sort((a, b) => {
|
||
const [aRow, aCol] = a.split("-").map(Number);
|
||
const [bRow, bCol] = b.split("-").map(Number);
|
||
if (aRow !== bRow) return aRow - bRow;
|
||
return aCol - bCol;
|
||
});
|
||
|
||
if (!focusedCell) {
|
||
const lastIdx = highlightArray.length - 1;
|
||
const [rowIdx, colIdx] = highlightArray[lastIdx].split("-").map(Number);
|
||
setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx });
|
||
return;
|
||
}
|
||
|
||
const currentKey = `${focusedCell.rowIndex}-${focusedCell.colIndex}`;
|
||
const currentIndex = highlightArray.indexOf(currentKey);
|
||
const prevIndex = currentIndex <= 0 ? highlightArray.length - 1 : currentIndex - 1;
|
||
const [rowIdx, colIdx] = highlightArray[prevIndex].split("-").map(Number);
|
||
setFocusedCell({ rowIndex: rowIdx, colIndex: colIdx });
|
||
}, [searchHighlights, focusedCell]);
|
||
|
||
// 🆕 Search Panel: 검색 초기화
|
||
const clearGlobalSearch = useCallback(() => {
|
||
setGlobalSearchTerm("");
|
||
setSearchHighlights(new Set());
|
||
setIsSearchPanelOpen(false);
|
||
}, []);
|
||
|
||
// 🆕 Filter Builder: 조건 추가
|
||
const addFilterCondition = useCallback((groupId: string, defaultColumn?: string) => {
|
||
setFilterGroups((prev) =>
|
||
prev.map((group) =>
|
||
group.id === groupId
|
||
? {
|
||
...group,
|
||
conditions: [
|
||
...group.conditions,
|
||
{
|
||
id: `cond-${Date.now()}`,
|
||
column: defaultColumn || "",
|
||
operator: "contains" as const,
|
||
value: "",
|
||
},
|
||
],
|
||
}
|
||
: group,
|
||
),
|
||
);
|
||
}, []);
|
||
|
||
// 🆕 Filter Builder: 조건 삭제
|
||
const removeFilterCondition = useCallback((groupId: string, conditionId: string) => {
|
||
setFilterGroups((prev) =>
|
||
prev.map((group) =>
|
||
group.id === groupId
|
||
? {
|
||
...group,
|
||
conditions: group.conditions.filter((c) => c.id !== conditionId),
|
||
}
|
||
: group,
|
||
),
|
||
);
|
||
}, []);
|
||
|
||
// 🆕 Filter Builder: 조건 업데이트
|
||
const updateFilterCondition = useCallback(
|
||
(groupId: string, conditionId: string, field: keyof FilterCondition, value: string) => {
|
||
setFilterGroups((prev) =>
|
||
prev.map((group) =>
|
||
group.id === groupId
|
||
? {
|
||
...group,
|
||
conditions: group.conditions.map((c) => (c.id === conditionId ? { ...c, [field]: value } : c)),
|
||
}
|
||
: group,
|
||
),
|
||
);
|
||
},
|
||
[],
|
||
);
|
||
|
||
// 🆕 Filter Builder: 그룹 추가
|
||
const addFilterGroup = useCallback((defaultColumn?: string) => {
|
||
setFilterGroups((prev) => [
|
||
...prev,
|
||
{
|
||
id: `group-${Date.now()}`,
|
||
logic: "AND" as const,
|
||
conditions: [
|
||
{
|
||
id: `cond-${Date.now()}`,
|
||
column: defaultColumn || "",
|
||
operator: "contains" as const,
|
||
value: "",
|
||
},
|
||
],
|
||
},
|
||
]);
|
||
}, []);
|
||
|
||
// 🆕 Filter Builder: 그룹 삭제
|
||
const removeFilterGroup = useCallback((groupId: string) => {
|
||
setFilterGroups((prev) => prev.filter((g) => g.id !== groupId));
|
||
}, []);
|
||
|
||
// 🆕 Filter Builder: 그룹 로직 변경
|
||
const updateGroupLogic = useCallback((groupId: string, logic: "AND" | "OR") => {
|
||
setFilterGroups((prev) => prev.map((group) => (group.id === groupId ? { ...group, logic } : group)));
|
||
}, []);
|
||
|
||
// 🆕 Filter Builder: 필터 적용
|
||
const applyFilterBuilder = useCallback(() => {
|
||
// 유효한 조건 개수 계산
|
||
let validConditions = 0;
|
||
filterGroups.forEach((group) => {
|
||
group.conditions.forEach((cond) => {
|
||
if (cond.column && (cond.operator === "isEmpty" || cond.operator === "isNotEmpty" || cond.value)) {
|
||
validConditions++;
|
||
}
|
||
});
|
||
});
|
||
setActiveFilterCount(validConditions);
|
||
setIsFilterBuilderOpen(false);
|
||
toast.success(`${validConditions}개 필터 조건 적용됨`);
|
||
}, [filterGroups]);
|
||
|
||
// 🆕 Filter Builder: 필터 초기화
|
||
const clearFilterBuilder = useCallback(() => {
|
||
setFilterGroups([]);
|
||
setActiveFilterCount(0);
|
||
toast.info("필터 초기화됨");
|
||
}, []);
|
||
|
||
// 🆕 Filter Builder: 조건 평가 함수
|
||
const evaluateCondition = useCallback((value: any, condition: FilterCondition): boolean => {
|
||
const strValue = value !== null && value !== undefined ? String(value).toLowerCase() : "";
|
||
const condValue = condition.value.toLowerCase();
|
||
|
||
switch (condition.operator) {
|
||
case "equals":
|
||
return strValue === condValue;
|
||
case "notEquals":
|
||
return strValue !== condValue;
|
||
case "contains":
|
||
return strValue.includes(condValue);
|
||
case "notContains":
|
||
return !strValue.includes(condValue);
|
||
case "startsWith":
|
||
return strValue.startsWith(condValue);
|
||
case "endsWith":
|
||
return strValue.endsWith(condValue);
|
||
case "greaterThan":
|
||
return parseFloat(strValue) > parseFloat(condValue);
|
||
case "lessThan":
|
||
return parseFloat(strValue) < parseFloat(condValue);
|
||
case "greaterOrEqual":
|
||
return parseFloat(strValue) >= parseFloat(condValue);
|
||
case "lessOrEqual":
|
||
return parseFloat(strValue) <= parseFloat(condValue);
|
||
case "isEmpty":
|
||
return strValue === "" || value === null || value === undefined;
|
||
case "isNotEmpty":
|
||
return strValue !== "" && value !== null && value !== undefined;
|
||
default:
|
||
return true;
|
||
}
|
||
}, []);
|
||
|
||
// 🆕 Filter Builder: 행이 필터 조건을 만족하는지 확인
|
||
const rowPassesFilterBuilder = useCallback(
|
||
(row: any): boolean => {
|
||
if (filterGroups.length === 0) return true;
|
||
|
||
// 모든 그룹이 AND로 연결됨 (그룹 간)
|
||
return filterGroups.every((group) => {
|
||
const validConditions = group.conditions.filter(
|
||
(c) => c.column && (c.operator === "isEmpty" || c.operator === "isNotEmpty" || c.value),
|
||
);
|
||
if (validConditions.length === 0) return true;
|
||
|
||
if (group.logic === "AND") {
|
||
return validConditions.every((cond) => evaluateCondition(row[cond.column], cond));
|
||
} else {
|
||
return validConditions.some((cond) => evaluateCondition(row[cond.column], cond));
|
||
}
|
||
});
|
||
},
|
||
[filterGroups, evaluateCondition],
|
||
);
|
||
|
||
// 🆕 컬럼 드래그 시작
|
||
const handleColumnDragStart = useCallback(
|
||
(e: React.DragEvent<HTMLTableCellElement>, index: number) => {
|
||
if (!isColumnDragEnabled) return;
|
||
|
||
setDraggedColumnIndex(index);
|
||
e.dataTransfer.effectAllowed = "move";
|
||
e.dataTransfer.setData("text/plain", `col-${index}`);
|
||
},
|
||
[isColumnDragEnabled],
|
||
);
|
||
|
||
// 🆕 컬럼 드래그 오버
|
||
const handleColumnDragOver = useCallback(
|
||
(e: React.DragEvent<HTMLTableCellElement>, index: number) => {
|
||
if (!isColumnDragEnabled || draggedColumnIndex === null) return;
|
||
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = "move";
|
||
|
||
if (index !== draggedColumnIndex) {
|
||
setDropTargetColumnIndex(index);
|
||
}
|
||
},
|
||
[isColumnDragEnabled, draggedColumnIndex],
|
||
);
|
||
|
||
// 🆕 컬럼 드래그 종료
|
||
const handleColumnDragEnd = useCallback(() => {
|
||
setDraggedColumnIndex(null);
|
||
setDropTargetColumnIndex(null);
|
||
}, []);
|
||
|
||
// 🆕 컬럼 드롭
|
||
const handleColumnDrop = useCallback(
|
||
(e: React.DragEvent<HTMLTableCellElement>, targetIndex: number) => {
|
||
e.preventDefault();
|
||
|
||
if (!isColumnDragEnabled || draggedColumnIndex === null || draggedColumnIndex === targetIndex) {
|
||
handleColumnDragEnd();
|
||
return;
|
||
}
|
||
|
||
// 컬럼 순서 변경
|
||
const newOrder = [...(columnOrder.length > 0 ? columnOrder : visibleColumns.map((c) => c.columnName))];
|
||
const [movedColumn] = newOrder.splice(draggedColumnIndex, 1);
|
||
newOrder.splice(targetIndex, 0, movedColumn);
|
||
|
||
setColumnOrder(newOrder);
|
||
toast.info("컬럼 순서가 변경되었습니다.");
|
||
console.log("✅ 컬럼 순서 변경:", { from: draggedColumnIndex, to: targetIndex });
|
||
|
||
handleColumnDragEnd();
|
||
},
|
||
[isColumnDragEnabled, draggedColumnIndex, columnOrder, visibleColumns, handleColumnDragEnd],
|
||
);
|
||
|
||
// 🆕 행 드래그 시작
|
||
const handleRowDragStart = useCallback(
|
||
(e: React.DragEvent<HTMLTableRowElement>, index: number) => {
|
||
if (!isDragEnabled) return;
|
||
|
||
setDraggedRowIndex(index);
|
||
e.dataTransfer.effectAllowed = "move";
|
||
e.dataTransfer.setData("text/plain", String(index));
|
||
|
||
// 드래그 이미지 설정 (반투명)
|
||
const dragImage = e.currentTarget.cloneNode(true) as HTMLElement;
|
||
dragImage.style.opacity = "0.5";
|
||
dragImage.style.position = "absolute";
|
||
dragImage.style.top = "-1000px";
|
||
document.body.appendChild(dragImage);
|
||
e.dataTransfer.setDragImage(dragImage, 0, 0);
|
||
setTimeout(() => document.body.removeChild(dragImage), 0);
|
||
},
|
||
[isDragEnabled],
|
||
);
|
||
|
||
// 🆕 행 드래그 오버
|
||
const handleRowDragOver = useCallback(
|
||
(e: React.DragEvent<HTMLTableRowElement>, index: number) => {
|
||
if (!isDragEnabled || draggedRowIndex === null) return;
|
||
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = "move";
|
||
|
||
if (index !== draggedRowIndex) {
|
||
setDropTargetIndex(index);
|
||
}
|
||
},
|
||
[isDragEnabled, draggedRowIndex],
|
||
);
|
||
|
||
// 🆕 행 드래그 종료
|
||
const handleRowDragEnd = useCallback(() => {
|
||
setDraggedRowIndex(null);
|
||
setDropTargetIndex(null);
|
||
}, []);
|
||
|
||
// 🆕 행 드롭
|
||
const handleRowDrop = useCallback(
|
||
async (e: React.DragEvent<HTMLTableRowElement>, targetIndex: number) => {
|
||
e.preventDefault();
|
||
|
||
if (!isDragEnabled || draggedRowIndex === null || draggedRowIndex === targetIndex) {
|
||
handleRowDragEnd();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 로컬 데이터 재정렬
|
||
const newData = [...filteredData];
|
||
const [movedRow] = newData.splice(draggedRowIndex, 1);
|
||
newData.splice(targetIndex, 0, movedRow);
|
||
|
||
// 서버에 순서 저장 (order_index 필드가 있는 경우)
|
||
const orderField = (tableConfig as any).orderField || "order_index";
|
||
const hasOrderField = newData[0] && orderField in newData[0];
|
||
|
||
if (hasOrderField && tableConfig.selectedTable) {
|
||
const { apiClient } = await import("@/lib/api/client");
|
||
const primaryKeyField = tableConfig.primaryKey || "id";
|
||
|
||
// 영향받는 행들의 순서 업데이트
|
||
const updates = newData.map((row, idx) => ({
|
||
tableName: tableConfig.selectedTable,
|
||
keyField: primaryKeyField,
|
||
keyValue: row[primaryKeyField],
|
||
updateField: orderField,
|
||
updateValue: idx + 1,
|
||
}));
|
||
|
||
// 배치 업데이트
|
||
await Promise.all(updates.map((update) => apiClient.put(`/dynamic-form/update-field`, update)));
|
||
|
||
toast.success("순서가 변경되었습니다.");
|
||
setRefreshTrigger((prev) => prev + 1);
|
||
} else {
|
||
// 로컬에서만 순서 변경 (저장 안함)
|
||
toast.info("순서가 변경되었습니다. (로컬만)");
|
||
}
|
||
|
||
console.log("✅ 행 순서 변경:", { from: draggedRowIndex, to: targetIndex });
|
||
} catch (error) {
|
||
console.error("❌ 행 순서 변경 실패:", error);
|
||
toast.error("순서 변경 중 오류가 발생했습니다.");
|
||
}
|
||
|
||
handleRowDragEnd();
|
||
},
|
||
[isDragEnabled, draggedRowIndex, filteredData, tableConfig, handleRowDragEnd],
|
||
);
|
||
|
||
// 🆕 PDF 내보내기 (인쇄용 HTML 생성)
|
||
const exportToPdf = useCallback(
|
||
(exportAll: boolean = true) => {
|
||
try {
|
||
// 내보낼 데이터 선택
|
||
let exportData: any[];
|
||
if (exportAll) {
|
||
exportData = filteredData;
|
||
} else {
|
||
exportData = filteredData.filter((row, index) => {
|
||
const rowKey = getRowKey(row, index);
|
||
return selectedRows.has(rowKey);
|
||
});
|
||
}
|
||
|
||
if (exportData.length === 0) {
|
||
toast.error(exportAll ? "내보낼 데이터가 없습니다." : "선택된 행이 없습니다.");
|
||
return;
|
||
}
|
||
|
||
// 컬럼 정보 가져오기 (체크박스 제외)
|
||
const exportColumns = visibleColumns.filter((col) => col.columnName !== "__checkbox__");
|
||
|
||
// 인쇄용 HTML 생성
|
||
const printContent = `
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<title>${tableLabel || tableConfig.selectedTable || "데이터"}</title>
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body { font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif; padding: 20px; }
|
||
h1 { font-size: 18px; margin-bottom: 10px; text-align: center; }
|
||
.info { font-size: 12px; color: #666; margin-bottom: 20px; text-align: center; }
|
||
table { width: 100%; border-collapse: collapse; font-size: 11px; }
|
||
th, td { border: 1px solid #ddd; padding: 6px 8px; text-align: left; }
|
||
th { background-color: #f5f5f5; font-weight: 600; }
|
||
tr:nth-child(even) { background-color: #fafafa; }
|
||
.number { text-align: right; }
|
||
@media print {
|
||
body { padding: 0; }
|
||
table { page-break-inside: auto; }
|
||
tr { page-break-inside: avoid; page-break-after: auto; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>${tableLabel || tableConfig.selectedTable || "데이터 목록"}</h1>
|
||
<div class="info">
|
||
출력일: ${new Date().toLocaleDateString("ko-KR")} |
|
||
총 ${exportData.length}건
|
||
</div>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
${exportColumns.map((col) => `<th>${columnLabels[col.columnName] || col.columnName}</th>`).join("")}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${exportData
|
||
.map(
|
||
(row) => `
|
||
<tr>
|
||
${exportColumns
|
||
.map((col) => {
|
||
const mappedColumnName = joinColumnMapping[col.columnName] || col.columnName;
|
||
let value = row[mappedColumnName];
|
||
|
||
// 카테고리 매핑
|
||
if (categoryMappings[col.columnName] && value !== null && value !== undefined) {
|
||
const mapping = categoryMappings[col.columnName][String(value)];
|
||
if (mapping) value = mapping.label;
|
||
}
|
||
|
||
const meta = columnMeta[col.columnName];
|
||
const inputType = meta?.inputType || (col as any).inputType;
|
||
const isNumeric = inputType === "number" || inputType === "decimal";
|
||
|
||
return `<td class="${isNumeric ? "number" : ""}">${value ?? ""}</td>`;
|
||
})
|
||
.join("")}
|
||
</tr>
|
||
`,
|
||
)
|
||
.join("")}
|
||
</tbody>
|
||
</table>
|
||
</body>
|
||
</html>
|
||
`;
|
||
|
||
// 새 창에서 인쇄
|
||
const printWindow = window.open("", "_blank");
|
||
if (printWindow) {
|
||
printWindow.document.write(printContent);
|
||
printWindow.document.close();
|
||
printWindow.onload = () => {
|
||
printWindow.print();
|
||
};
|
||
toast.success("인쇄 창이 열렸습니다.");
|
||
} else {
|
||
toast.error("팝업이 차단되었습니다. 팝업을 허용해주세요.");
|
||
}
|
||
} catch (error) {
|
||
console.error("❌ PDF 내보내기 실패:", error);
|
||
toast.error("PDF 내보내기 중 오류가 발생했습니다.");
|
||
}
|
||
},
|
||
[
|
||
filteredData,
|
||
selectedRows,
|
||
visibleColumns,
|
||
columnLabels,
|
||
joinColumnMapping,
|
||
categoryMappings,
|
||
columnMeta,
|
||
tableLabel,
|
||
tableConfig.selectedTable,
|
||
getRowKey,
|
||
],
|
||
);
|
||
|
||
// 🆕 편집 중 키보드 핸들러 (간단 버전 - Tab 이동은 visibleColumns 정의 후 처리)
|
||
const handleEditKeyDown = useCallback(
|
||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||
switch (e.key) {
|
||
case "Enter":
|
||
e.preventDefault();
|
||
saveEditing();
|
||
break;
|
||
case "Escape":
|
||
e.preventDefault();
|
||
cancelEditing();
|
||
break;
|
||
case "Tab":
|
||
e.preventDefault();
|
||
saveEditing();
|
||
// Tab 이동은 편집 저장 후 테이블 키보드 핸들러에서 처리
|
||
break;
|
||
}
|
||
},
|
||
[saveEditing, cancelEditing],
|
||
);
|
||
|
||
// 🆕 편집 입력 필드가 나타나면 자동 포커스
|
||
useEffect(() => {
|
||
if (editingCell && editInputRef.current) {
|
||
editInputRef.current.focus();
|
||
// select()는 input 요소에서만 사용 가능 (select 요소에서는 사용 불가)
|
||
if (typeof editInputRef.current.select === "function") {
|
||
editInputRef.current.select();
|
||
}
|
||
}
|
||
}, [editingCell]);
|
||
|
||
// 🆕 포커스된 셀로 스크롤
|
||
useEffect(() => {
|
||
if (focusedCell && tableContainerRef.current) {
|
||
const focusedCellElement = tableContainerRef.current.querySelector(
|
||
`[data-row="${focusedCell.rowIndex}"][data-col="${focusedCell.colIndex}"]`,
|
||
) as HTMLElement;
|
||
|
||
if (focusedCellElement) {
|
||
focusedCellElement.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||
}
|
||
}
|
||
}, [focusedCell]);
|
||
|
||
// 컬럼 드래그앤드롭 기능 제거됨 (테이블 옵션 모달에서 컬럼 순서 변경 가능)
|
||
|
||
const handleClick = (e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
onClick?.();
|
||
};
|
||
|
||
// ========================================
|
||
// 컬럼 관련 (visibleColumns는 상단에서 정의됨)
|
||
// ========================================
|
||
|
||
// 🆕 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(",")]); // 의존성 단순화
|
||
|
||
// 🆕 키보드 네비게이션 핸들러 (visibleColumns 정의 후에 배치)
|
||
const handleTableKeyDown = useCallback(
|
||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||
// 편집 중일 때는 테이블 키보드 핸들러 무시 (편집 입력에서 처리)
|
||
if (editingCell) return;
|
||
|
||
if (!focusedCell || data.length === 0) return;
|
||
|
||
const { rowIndex, colIndex } = focusedCell;
|
||
const maxRowIndex = data.length - 1;
|
||
const maxColIndex = visibleColumns.length - 1;
|
||
|
||
switch (e.key) {
|
||
case "ArrowUp":
|
||
e.preventDefault();
|
||
if (rowIndex > 0) {
|
||
setFocusedCell({ rowIndex: rowIndex - 1, colIndex });
|
||
}
|
||
break;
|
||
case "ArrowDown":
|
||
e.preventDefault();
|
||
if (rowIndex < maxRowIndex) {
|
||
setFocusedCell({ rowIndex: rowIndex + 1, colIndex });
|
||
}
|
||
break;
|
||
case "ArrowLeft":
|
||
e.preventDefault();
|
||
if (colIndex > 0) {
|
||
setFocusedCell({ rowIndex, colIndex: colIndex - 1 });
|
||
}
|
||
break;
|
||
case "ArrowRight":
|
||
e.preventDefault();
|
||
if (colIndex < maxColIndex) {
|
||
setFocusedCell({ rowIndex, colIndex: colIndex + 1 });
|
||
}
|
||
break;
|
||
case "Enter":
|
||
e.preventDefault();
|
||
// 현재 행 선택/해제
|
||
const enterRow = data[rowIndex];
|
||
if (enterRow) {
|
||
const rowKey = getRowKey(enterRow, rowIndex);
|
||
const isCurrentlySelected = selectedRows.has(rowKey);
|
||
handleRowSelection(rowKey, !isCurrentlySelected);
|
||
}
|
||
break;
|
||
case " ": // Space
|
||
e.preventDefault();
|
||
// 체크박스 토글
|
||
const spaceRow = data[rowIndex];
|
||
if (spaceRow) {
|
||
const currentRowKey = getRowKey(spaceRow, rowIndex);
|
||
const isChecked = selectedRows.has(currentRowKey);
|
||
handleRowSelection(currentRowKey, !isChecked);
|
||
}
|
||
break;
|
||
case "F2":
|
||
// 🆕 F2: 편집 모드 진입
|
||
e.preventDefault();
|
||
{
|
||
const col = visibleColumns[colIndex];
|
||
if (col && col.columnName !== "__checkbox__") {
|
||
// 🆕 편집 불가 컬럼 체크
|
||
if (col.editable === false) {
|
||
toast.warning(`'${col.displayName || col.columnName}' 컬럼은 편집할 수 없습니다.`);
|
||
break;
|
||
}
|
||
const row = data[rowIndex];
|
||
const mappedCol = joinColumnMapping[col.columnName] || col.columnName;
|
||
const val = row?.[mappedCol];
|
||
setEditingCell({
|
||
rowIndex,
|
||
colIndex,
|
||
columnName: col.columnName,
|
||
originalValue: val,
|
||
});
|
||
setEditingValue(val !== null && val !== undefined ? String(val) : "");
|
||
}
|
||
}
|
||
break;
|
||
case "b":
|
||
case "B":
|
||
// 🆕 Ctrl+B: 배치 편집 모드 토글
|
||
if (e.ctrlKey) {
|
||
e.preventDefault();
|
||
setEditMode((prev) => {
|
||
const newMode = prev === "immediate" ? "batch" : "immediate";
|
||
if (newMode === "immediate" && pendingChanges.size > 0) {
|
||
// 즉시 모드로 전환 시 저장되지 않은 변경사항 경고
|
||
const confirmDiscard = window.confirm(
|
||
`저장되지 않은 ${pendingChanges.size}개의 변경사항이 있습니다. 취소하시겠습니까?`,
|
||
);
|
||
if (confirmDiscard) {
|
||
setPendingChanges(new Map());
|
||
setLocalEditedData({});
|
||
toast.info("배치 편집 모드 종료");
|
||
return "immediate";
|
||
}
|
||
return "batch";
|
||
}
|
||
toast.info(newMode === "batch" ? "배치 편집 모드 시작 (Ctrl+B로 종료)" : "즉시 저장 모드");
|
||
return newMode;
|
||
});
|
||
}
|
||
break;
|
||
case "s":
|
||
case "S":
|
||
// 🆕 Ctrl+S: 배치 저장
|
||
if (e.ctrlKey && editMode === "batch") {
|
||
e.preventDefault();
|
||
saveBatchChanges();
|
||
}
|
||
break;
|
||
case "c":
|
||
case "C":
|
||
// 🆕 Ctrl+C: 선택된 행/셀 복사
|
||
if (e.ctrlKey) {
|
||
e.preventDefault();
|
||
handleCopy();
|
||
}
|
||
break;
|
||
case "v":
|
||
case "V":
|
||
// 🆕 Ctrl+V: 붙여넣기 (편집 중인 경우만)
|
||
if (e.ctrlKey && editingCell) {
|
||
// 기본 동작 허용 (input에서 처리)
|
||
}
|
||
break;
|
||
case "a":
|
||
case "A":
|
||
// 🆕 Ctrl+A: 전체 선택
|
||
if (e.ctrlKey) {
|
||
e.preventDefault();
|
||
handleSelectAllRows();
|
||
}
|
||
break;
|
||
case "f":
|
||
case "F":
|
||
// 🆕 Ctrl+F: 통합 검색 패널 열기
|
||
if (e.ctrlKey) {
|
||
e.preventDefault();
|
||
setIsSearchPanelOpen(true);
|
||
}
|
||
break;
|
||
case "F3":
|
||
// 🆕 F3: 다음 검색 결과 / Shift+F3: 이전 검색 결과
|
||
e.preventDefault();
|
||
if (e.shiftKey) {
|
||
goToPrevSearchResult();
|
||
} else {
|
||
goToNextSearchResult();
|
||
}
|
||
break;
|
||
case "Home":
|
||
e.preventDefault();
|
||
if (e.ctrlKey) {
|
||
// Ctrl+Home: 첫 번째 셀로
|
||
setFocusedCell({ rowIndex: 0, colIndex: 0 });
|
||
} else {
|
||
// Home: 현재 행의 첫 번째 셀로
|
||
setFocusedCell({ rowIndex, colIndex: 0 });
|
||
}
|
||
break;
|
||
case "End":
|
||
e.preventDefault();
|
||
if (e.ctrlKey) {
|
||
// Ctrl+End: 마지막 셀로
|
||
setFocusedCell({ rowIndex: maxRowIndex, colIndex: maxColIndex });
|
||
} else {
|
||
// End: 현재 행의 마지막 셀로
|
||
setFocusedCell({ rowIndex, colIndex: maxColIndex });
|
||
}
|
||
break;
|
||
case "PageUp":
|
||
e.preventDefault();
|
||
// 10행 위로
|
||
setFocusedCell({ rowIndex: Math.max(0, rowIndex - 10), colIndex });
|
||
break;
|
||
case "PageDown":
|
||
e.preventDefault();
|
||
// 10행 아래로
|
||
setFocusedCell({ rowIndex: Math.min(maxRowIndex, rowIndex + 10), colIndex });
|
||
break;
|
||
case "Escape":
|
||
e.preventDefault();
|
||
// 포커스 해제
|
||
setFocusedCell(null);
|
||
break;
|
||
case "Tab":
|
||
e.preventDefault();
|
||
if (e.shiftKey) {
|
||
// Shift+Tab: 이전 셀
|
||
if (colIndex > 0) {
|
||
setFocusedCell({ rowIndex, colIndex: colIndex - 1 });
|
||
} else if (rowIndex > 0) {
|
||
setFocusedCell({ rowIndex: rowIndex - 1, colIndex: maxColIndex });
|
||
}
|
||
} else {
|
||
// Tab: 다음 셀
|
||
if (colIndex < maxColIndex) {
|
||
setFocusedCell({ rowIndex, colIndex: colIndex + 1 });
|
||
} else if (rowIndex < maxRowIndex) {
|
||
setFocusedCell({ rowIndex: rowIndex + 1, colIndex: 0 });
|
||
}
|
||
}
|
||
break;
|
||
default:
|
||
// 🆕 직접 타이핑으로 편집 모드 진입 (영문자, 숫자, 한글 등)
|
||
if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
||
const column = visibleColumns[colIndex];
|
||
if (column && column.columnName !== "__checkbox__") {
|
||
// 🆕 편집 불가 컬럼 체크
|
||
if (column.editable === false) {
|
||
toast.warning(`'${column.displayName || column.columnName}' 컬럼은 편집할 수 없습니다.`);
|
||
break;
|
||
}
|
||
e.preventDefault();
|
||
// 편집 시작 (현재 키를 초기값으로)
|
||
const row = data[rowIndex];
|
||
const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName;
|
||
const value = row?.[mappedColumnName];
|
||
|
||
setEditingCell({
|
||
rowIndex,
|
||
colIndex,
|
||
columnName: column.columnName,
|
||
originalValue: value,
|
||
});
|
||
setEditingValue(e.key); // 입력한 키로 시작
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
},
|
||
[editingCell, focusedCell, data, visibleColumns, joinColumnMapping, selectedRows, getRowKey, handleRowSelection],
|
||
);
|
||
|
||
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>) => {
|
||
// 🎯 엔티티 컬럼 표시 설정이 있는 경우 - value가 null이어도 rowData에서 조합 가능
|
||
// 이 체크를 가장 먼저 수행 (null 체크보다 앞에)
|
||
if (column.entityDisplayConfig && rowData) {
|
||
const displayColumns =
|
||
column.entityDisplayConfig.displayColumns || (column.entityDisplayConfig as any).selectedColumns;
|
||
const separator = column.entityDisplayConfig.separator;
|
||
|
||
if (displayColumns && displayColumns.length > 0) {
|
||
// 선택된 컬럼들의 값을 구분자로 조합
|
||
const values = displayColumns
|
||
.map((colName: string) => {
|
||
// 1. 먼저 직접 컬럼명으로 시도 (기본 테이블 컬럼인 경우)
|
||
let cellValue = rowData[colName];
|
||
|
||
// 2. 없으면 ${sourceColumn}_${colName} 형식으로 시도 (조인 테이블 컬럼인 경우)
|
||
if (cellValue === null || cellValue === undefined) {
|
||
const joinedKey = `${column.columnName}_${colName}`;
|
||
cellValue = rowData[joinedKey];
|
||
}
|
||
|
||
if (cellValue === null || cellValue === undefined) return "";
|
||
return String(cellValue);
|
||
})
|
||
.filter((v: string) => v !== ""); // 빈 값 제외
|
||
|
||
const result = values.join(separator || " - ");
|
||
if (result) {
|
||
return result; // 결과가 있으면 반환
|
||
}
|
||
// 결과가 비어있으면 아래로 계속 진행 (원래 값 사용)
|
||
}
|
||
}
|
||
|
||
// value가 null/undefined면 "-" 반환
|
||
if (value === null || value === undefined) return "-";
|
||
|
||
// 🎯 writer 컬럼 자동 변환: user_id -> user_name
|
||
if (column.columnName === "writer" && rowData && rowData.writer_name) {
|
||
return rowData.writer_name;
|
||
}
|
||
|
||
// 🆕 메인 테이블 메타 또는 조인 테이블 메타에서 정보 가져오기
|
||
const meta = columnMeta[column.columnName] || joinedColumnMeta[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";
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
|
||
// 📎 첨부파일 타입: 파일 아이콘과 개수 표시
|
||
// 컬럼명이 'attachments'를 포함하거나, inputType이 file/attachment인 경우
|
||
const isAttachmentColumn =
|
||
inputType === "file" ||
|
||
inputType === "attachment" ||
|
||
column.columnName === "attachments" ||
|
||
column.columnName?.toLowerCase().includes("attachment") ||
|
||
column.columnName?.toLowerCase().includes("file");
|
||
|
||
if (isAttachmentColumn) {
|
||
// JSONB 배열 또는 JSON 문자열 파싱
|
||
let files: any[] = [];
|
||
try {
|
||
if (typeof value === "string" && value.trim()) {
|
||
const parsed = JSON.parse(value);
|
||
files = Array.isArray(parsed) ? parsed : [];
|
||
} else if (Array.isArray(value)) {
|
||
files = value;
|
||
} else if (value && typeof value === "object") {
|
||
// 단일 객체인 경우 배열로 변환
|
||
files = [value];
|
||
}
|
||
} catch (e) {
|
||
// 파싱 실패 시 빈 배열
|
||
console.warn("📎 [TableList] 첨부파일 파싱 실패:", { columnName: column.columnName, value, error: e });
|
||
}
|
||
|
||
if (!files || files.length === 0) {
|
||
return <span className="text-muted-foreground text-xs">-</span>;
|
||
}
|
||
|
||
// 파일 이름 표시 (여러 개면 쉼표로 구분)
|
||
const { Paperclip } = require("lucide-react");
|
||
const fileNames = files.map((f: any) => f.realFileName || f.real_file_name || f.name || "파일").join(", ");
|
||
|
||
return (
|
||
<div className="flex max-w-full items-center gap-1.5 text-sm">
|
||
<Paperclip className="h-4 w-4 flex-shrink-0 text-gray-500" />
|
||
<span className="truncate text-blue-600" title={fileNames}>
|
||
{fileNames}
|
||
</span>
|
||
{files.length > 1 && <span className="text-muted-foreground flex-shrink-0 text-xs">({files.length})</span>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원, 다중 값 지원)
|
||
if (inputType === "category") {
|
||
if (!value) return "";
|
||
|
||
// 🆕 엔티티 조인 컬럼의 경우 여러 형태로 매핑 찾기
|
||
// 1. 원래 컬럼명 (item_info.material)
|
||
// 2. 점(.) 뒤의 컬럼명만 (material)
|
||
let mapping = categoryMappings[column.columnName];
|
||
|
||
if (!mapping && column.columnName.includes(".")) {
|
||
const simpleColumnName = column.columnName.split(".").pop();
|
||
if (simpleColumnName) {
|
||
mapping = categoryMappings[simpleColumnName];
|
||
}
|
||
}
|
||
|
||
const { Badge } = require("@/components/ui/badge");
|
||
|
||
// 다중 값 처리: 콤마로 구분된 값들을 분리
|
||
const valueStr = String(value);
|
||
const values = valueStr.includes(",")
|
||
? valueStr
|
||
.split(",")
|
||
.map((v) => v.trim())
|
||
.filter((v) => v)
|
||
: [valueStr];
|
||
|
||
// 단일 값인 경우 (기존 로직)
|
||
if (values.length === 1) {
|
||
const categoryData = mapping?.[values[0]];
|
||
const displayLabel = categoryData?.label || values[0];
|
||
const displayColor = categoryData?.color;
|
||
|
||
// 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시
|
||
if (!displayColor || displayColor === "none" || !categoryData) {
|
||
return <span className="text-sm">{displayLabel}</span>;
|
||
}
|
||
|
||
return (
|
||
<Badge
|
||
style={{
|
||
backgroundColor: displayColor,
|
||
borderColor: displayColor,
|
||
}}
|
||
className="text-white"
|
||
>
|
||
{displayLabel}
|
||
</Badge>
|
||
);
|
||
}
|
||
|
||
// 다중 값인 경우: 여러 배지 렌더링
|
||
return (
|
||
<div className="flex flex-wrap gap-1">
|
||
{values.map((val, idx) => {
|
||
const categoryData = mapping?.[val];
|
||
const displayLabel = categoryData?.label || val;
|
||
const displayColor = categoryData?.color;
|
||
|
||
// 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시
|
||
if (!displayColor || displayColor === "none" || !categoryData) {
|
||
return (
|
||
<span key={idx} className="text-sm">
|
||
{displayLabel}
|
||
{idx < values.length - 1 && ", "}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Badge
|
||
key={idx}
|
||
style={{
|
||
backgroundColor: displayColor,
|
||
borderColor: displayColor,
|
||
}}
|
||
className="text-white"
|
||
>
|
||
{displayLabel}
|
||
</Badge>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 코드 타입: 코드 값 → 코드명 변환
|
||
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)) {
|
||
// thousandSeparator가 false가 아닌 경우(기본값 true) 천단위 구분자 적용
|
||
if (column.thousandSeparator !== false) {
|
||
return numValue.toLocaleString("ko-KR");
|
||
}
|
||
return String(numValue);
|
||
}
|
||
}
|
||
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)) {
|
||
// thousandSeparator가 false가 아닌 경우(기본값 true) 천단위 구분자 적용
|
||
if (column.thousandSeparator !== false) {
|
||
return numValue.toLocaleString("ko-KR");
|
||
}
|
||
return String(numValue);
|
||
}
|
||
}
|
||
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 "currency":
|
||
if (typeof value === "number") {
|
||
// thousandSeparator가 false가 아닌 경우(기본값 true) 천단위 구분자 적용
|
||
if (column.thousandSeparator !== false) {
|
||
return `₩${value.toLocaleString()}`;
|
||
}
|
||
return `₩${value}`;
|
||
}
|
||
return value;
|
||
case "boolean":
|
||
return value ? "예" : "아니오";
|
||
default:
|
||
return String(value);
|
||
}
|
||
},
|
||
[columnMeta, joinedColumnMeta, optimizedConvertCode, categoryMappings],
|
||
);
|
||
|
||
// ========================================
|
||
// useEffect 훅
|
||
// ========================================
|
||
|
||
// 필터 설정 localStorage 키 생성 (화면별로 독립적)
|
||
const filterSettingKey = useMemo(() => {
|
||
if (!tableConfig.selectedTable) return null;
|
||
return screenId
|
||
? `tableList_filterSettings_${tableConfig.selectedTable}_screen_${screenId}`
|
||
: `tableList_filterSettings_${tableConfig.selectedTable}`;
|
||
}, [tableConfig.selectedTable, screenId]);
|
||
|
||
// 그룹 설정 localStorage 키 생성 (화면별로 독립적)
|
||
const groupSettingKey = useMemo(() => {
|
||
if (!tableConfig.selectedTable) return null;
|
||
return screenId
|
||
? `tableList_groupSettings_${tableConfig.selectedTable}_screen_${screenId}`
|
||
: `tableList_groupSettings_${tableConfig.selectedTable}`;
|
||
}, [tableConfig.selectedTable, screenId]);
|
||
|
||
// 저장된 필터 설정 불러오기
|
||
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 || filteredData.length === 0) return [];
|
||
|
||
const grouped = new Map<string, any[]>();
|
||
|
||
filteredData.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];
|
||
});
|
||
|
||
// 🆕 그룹별 소계 계산
|
||
const groupSummary: Record<string, { sum: number; avg: number; count: number }> = {};
|
||
|
||
// 숫자형 컬럼에 대해 소계 계산
|
||
(tableConfig.columns || []).forEach((col: { columnName: string }) => {
|
||
if (col.columnName === "__checkbox__") return;
|
||
|
||
const colMeta = columnMeta?.[col.columnName];
|
||
const inputType = colMeta?.inputType;
|
||
const isNumeric = inputType === "number" || inputType === "decimal";
|
||
|
||
if (isNumeric) {
|
||
const values = items.map((item) => parseFloat(item[col.columnName])).filter((v) => !isNaN(v));
|
||
|
||
if (values.length > 0) {
|
||
const sum = values.reduce((a, b) => a + b, 0);
|
||
groupSummary[col.columnName] = {
|
||
sum,
|
||
avg: sum / values.length,
|
||
count: values.length,
|
||
};
|
||
}
|
||
}
|
||
});
|
||
|
||
return {
|
||
groupKey,
|
||
groupValues,
|
||
items,
|
||
count: items.length,
|
||
summary: groupSummary, // 🆕 그룹별 소계
|
||
};
|
||
});
|
||
}, [data, groupByColumns, columnLabels, columnMeta, tableConfig.columns]);
|
||
|
||
// 🆕 그룹별 합산된 데이터 계산 (FilterPanel에서 설정한 경우)
|
||
const summedData = useMemo(() => {
|
||
// 그룹핑이 비활성화되었거나 그룹 기준 컬럼이 없으면 원본 데이터 반환
|
||
if (!groupSumConfig?.enabled || !groupSumConfig?.groupByColumn) {
|
||
return filteredData;
|
||
}
|
||
|
||
console.log("🔍 [테이블리스트] 그룹합산 적용:", groupSumConfig);
|
||
|
||
const groupByColumn = groupSumConfig.groupByColumn;
|
||
const groupMap = new Map<string, any>();
|
||
|
||
// 조인 컬럼인지 확인하고 실제 키 추론
|
||
const getActualKey = (columnName: string, item: any): string => {
|
||
if (columnName.includes(".")) {
|
||
const [refTable, fieldName] = columnName.split(".");
|
||
const inferredSourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id");
|
||
const exactKey = `${inferredSourceColumn}_${fieldName}`;
|
||
if (item[exactKey] !== undefined) return exactKey;
|
||
if (fieldName === "item_name" || fieldName === "name") {
|
||
const aliasKey = `${inferredSourceColumn}_name`;
|
||
if (item[aliasKey] !== undefined) return aliasKey;
|
||
}
|
||
}
|
||
return columnName;
|
||
};
|
||
|
||
// 숫자 타입인지 확인하는 함수
|
||
const isNumericValue = (value: any): boolean => {
|
||
if (value === null || value === undefined || value === "") return false;
|
||
const num = parseFloat(String(value));
|
||
return !isNaN(num) && isFinite(num);
|
||
};
|
||
|
||
// 그룹핑 수행
|
||
filteredData.forEach((item) => {
|
||
const actualKey = getActualKey(groupByColumn, item);
|
||
const groupValue = String(item[actualKey] || item[groupByColumn] || "");
|
||
|
||
if (!groupMap.has(groupValue)) {
|
||
// 첫 번째 항목을 기준으로 초기화
|
||
groupMap.set(groupValue, { ...item, _groupCount: 1 });
|
||
} else {
|
||
const existing = groupMap.get(groupValue);
|
||
existing._groupCount += 1;
|
||
|
||
// 모든 키에 대해 숫자면 합산
|
||
Object.keys(item).forEach((key) => {
|
||
const value = item[key];
|
||
if (isNumericValue(value) && key !== groupByColumn && !key.endsWith("_id") && !key.includes("code")) {
|
||
const numValue = parseFloat(String(value));
|
||
const existingValue = parseFloat(String(existing[key] || 0));
|
||
existing[key] = existingValue + numValue;
|
||
}
|
||
});
|
||
|
||
groupMap.set(groupValue, existing);
|
||
}
|
||
});
|
||
|
||
const result = Array.from(groupMap.values());
|
||
console.log("🔗 [테이블리스트] 그룹별 합산 결과:", {
|
||
원본개수: filteredData.length,
|
||
그룹개수: result.length,
|
||
그룹기준: groupByColumn,
|
||
});
|
||
|
||
return result;
|
||
}, [filteredData, groupSumConfig]);
|
||
|
||
// 🆕 표시할 데이터: 합산 모드면 summedData, 아니면 filteredData
|
||
const displayData = useMemo(() => {
|
||
return groupSumConfig?.enabled ? summedData : filteredData;
|
||
}, [groupSumConfig?.enabled, summedData, filteredData]);
|
||
|
||
// 🆕 Virtual Scrolling: 보이는 행 범위 계산 (displayData 정의 이후에 위치)
|
||
const virtualScrollInfo = useMemo(() => {
|
||
const dataSource = displayData;
|
||
if (!isVirtualScrollEnabled || dataSource.length === 0) {
|
||
return {
|
||
startIndex: 0,
|
||
endIndex: dataSource.length,
|
||
visibleData: dataSource,
|
||
topSpacerHeight: 0,
|
||
bottomSpacerHeight: 0,
|
||
totalHeight: dataSource.length * ROW_HEIGHT,
|
||
};
|
||
}
|
||
|
||
const containerHeight = scrollContainerRef.current?.clientHeight || 600;
|
||
const totalRows = dataSource.length;
|
||
const totalHeight = totalRows * ROW_HEIGHT;
|
||
|
||
// 현재 보이는 행 범위 계산
|
||
const startIndex = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN);
|
||
const visibleRowCount = Math.ceil(containerHeight / ROW_HEIGHT) + OVERSCAN * 2;
|
||
const endIndex = Math.min(totalRows, startIndex + visibleRowCount);
|
||
|
||
return {
|
||
startIndex,
|
||
endIndex,
|
||
visibleData: dataSource.slice(startIndex, endIndex),
|
||
topSpacerHeight: startIndex * ROW_HEIGHT,
|
||
bottomSpacerHeight: (totalRows - endIndex) * ROW_HEIGHT,
|
||
totalHeight,
|
||
};
|
||
}, [isVirtualScrollEnabled, displayData, scrollTop, ROW_HEIGHT, OVERSCAN]);
|
||
|
||
// 저장된 그룹 설정 불러오기
|
||
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]);
|
||
|
||
// 🆕 우측 화면일 때만 selectedLeftData 변경에 반응하도록 변수 생성
|
||
const isRightPanel = splitPanelPosition === "right" || currentSplitPosition === "right";
|
||
const selectedLeftDataForRightPanel = isRightPanel ? splitPanelContext?.selectedLeftData : null;
|
||
|
||
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,
|
||
selectedLeftDataForRightPanel, // 🆕 우측 화면일 때만 좌측 데이터 선택 변경 시 데이터 새로고침
|
||
// fetchTableDataDebounced 제거: useCallback 재생성으로 인한 무한 루프 방지
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (tableConfig.refreshInterval && !isDesignMode) {
|
||
const interval = setInterval(() => {
|
||
fetchTableDataDebounced();
|
||
}, tableConfig.refreshInterval * 1000);
|
||
|
||
return () => clearInterval(interval);
|
||
}
|
||
}, [tableConfig.refreshInterval, isDesignMode]);
|
||
|
||
// 🆕 전역 테이블 새로고침 이벤트 리스너
|
||
useEffect(() => {
|
||
const handleRefreshTable = () => {
|
||
if (tableConfig.selectedTable && !isDesignMode) {
|
||
console.log("🔄 [TableList] refreshTable 이벤트 수신 - 데이터 새로고침");
|
||
setRefreshTrigger((prev) => prev + 1);
|
||
}
|
||
};
|
||
|
||
window.addEventListener("refreshTable", handleRefreshTable);
|
||
|
||
return () => {
|
||
window.removeEventListener("refreshTable", handleRefreshTable);
|
||
};
|
||
}, [tableConfig.selectedTable, isDesignMode]);
|
||
|
||
// 🆕 테이블명 변경 시 전역 레지스트리에서 확인
|
||
useEffect(() => {
|
||
if (typeof window !== "undefined" && window.__relatedButtonsTargetTables && tableConfig.selectedTable) {
|
||
const isTarget = window.__relatedButtonsTargetTables.has(tableConfig.selectedTable);
|
||
if (isTarget) {
|
||
console.log("📝 [TableList] 전역 레지스트리에서 RelatedDataButtons 대상 확인:", tableConfig.selectedTable);
|
||
setIsRelatedButtonTarget(true);
|
||
}
|
||
}
|
||
}, [tableConfig.selectedTable]);
|
||
|
||
// 🆕 RelatedDataButtons 등록/해제 이벤트 리스너
|
||
useEffect(() => {
|
||
const handleRelatedButtonRegister = (event: CustomEvent) => {
|
||
const { targetTable } = event.detail || {};
|
||
if (targetTable === tableConfig.selectedTable) {
|
||
console.log("📝 [TableList] RelatedDataButtons 대상으로 등록됨:", tableConfig.selectedTable);
|
||
setIsRelatedButtonTarget(true);
|
||
}
|
||
};
|
||
|
||
const handleRelatedButtonUnregister = (event: CustomEvent) => {
|
||
const { targetTable } = event.detail || {};
|
||
if (targetTable === tableConfig.selectedTable) {
|
||
console.log("📝 [TableList] RelatedDataButtons 대상에서 해제됨:", tableConfig.selectedTable);
|
||
setIsRelatedButtonTarget(false);
|
||
setRelatedButtonFilter(null);
|
||
}
|
||
};
|
||
|
||
window.addEventListener("related-button-register" as any, handleRelatedButtonRegister);
|
||
window.addEventListener("related-button-unregister" as any, handleRelatedButtonUnregister);
|
||
|
||
return () => {
|
||
window.removeEventListener("related-button-register" as any, handleRelatedButtonRegister);
|
||
window.removeEventListener("related-button-unregister" as any, handleRelatedButtonUnregister);
|
||
};
|
||
}, [tableConfig.selectedTable]);
|
||
|
||
// 🆕 RelatedDataButtons 선택 이벤트 리스너 (버튼 선택 시 테이블 필터링)
|
||
useEffect(() => {
|
||
const handleRelatedButtonSelect = (event: CustomEvent) => {
|
||
const { targetTable, filterColumn, filterValue } = event.detail || {};
|
||
|
||
// 이 테이블이 대상 테이블인지 확인
|
||
if (targetTable === tableConfig.selectedTable) {
|
||
// filterValue가 null이면 선택 해제 (빈 상태)
|
||
if (filterValue === null || filterValue === undefined) {
|
||
console.log("📌 [TableList] RelatedDataButtons 선택 해제 (빈 상태):", tableConfig.selectedTable);
|
||
setRelatedButtonFilter(null);
|
||
setIsRelatedButtonTarget(true); // 대상으로 등록은 유지
|
||
} else {
|
||
console.log("📌 [TableList] RelatedDataButtons 필터 적용:", {
|
||
tableName: tableConfig.selectedTable,
|
||
filterColumn,
|
||
filterValue,
|
||
});
|
||
setRelatedButtonFilter({ filterColumn, filterValue });
|
||
setIsRelatedButtonTarget(true);
|
||
}
|
||
}
|
||
};
|
||
|
||
window.addEventListener("related-button-select" as any, handleRelatedButtonSelect);
|
||
|
||
return () => {
|
||
window.removeEventListener("related-button-select" as any, handleRelatedButtonSelect);
|
||
};
|
||
}, [tableConfig.selectedTable]);
|
||
|
||
// 🆕 relatedButtonFilter 변경 시 데이터 다시 로드
|
||
useEffect(() => {
|
||
if (!isDesignMode) {
|
||
// relatedButtonFilter가 있으면 데이터 로드, null이면 빈 상태 (setRefreshTrigger로 트리거)
|
||
console.log("🔄 [TableList] RelatedDataButtons 상태 변경:", {
|
||
relatedButtonFilter,
|
||
isRelatedButtonTarget
|
||
});
|
||
setRefreshTrigger((prev) => prev + 1);
|
||
}
|
||
}, [relatedButtonFilter, isDesignMode]);
|
||
|
||
// 🎯 컬럼 너비 자동 계산 (내용 기반)
|
||
const calculateOptimalColumnWidth = useCallback(
|
||
(columnName: string, displayName: string): number => {
|
||
// 기본 너비 설정
|
||
const MIN_WIDTH = 100;
|
||
const MAX_WIDTH = 400;
|
||
const PADDING = 48; // 좌우 패딩 + 여유 공간
|
||
const HEADER_PADDING = 60; // 헤더 추가 여유 (정렬 아이콘 등)
|
||
|
||
// 헤더 텍스트 너비 계산 (대략 8px per character)
|
||
const headerWidth = (displayName?.length || columnName.length) * 10 + HEADER_PADDING;
|
||
|
||
// 데이터 셀 너비 계산 (상위 50개 샘플링)
|
||
const sampleSize = Math.min(50, data.length);
|
||
let maxDataWidth = headerWidth;
|
||
|
||
for (let i = 0; i < sampleSize; i++) {
|
||
const cellValue = data[i]?.[columnName];
|
||
if (cellValue !== null && cellValue !== undefined) {
|
||
const cellText = String(cellValue);
|
||
// 숫자는 좁게, 텍스트는 넓게 계산
|
||
const isNumber = !isNaN(Number(cellValue)) && cellValue !== "";
|
||
const charWidth = isNumber ? 8 : 9;
|
||
const cellWidth = cellText.length * charWidth + PADDING;
|
||
maxDataWidth = Math.max(maxDataWidth, cellWidth);
|
||
}
|
||
}
|
||
|
||
// 최소/최대 범위 내로 제한
|
||
return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, Math.ceil(maxDataWidth)));
|
||
},
|
||
[data],
|
||
);
|
||
|
||
// 🎯 localStorage에서 컬럼 너비 불러오기 및 초기 계산
|
||
useEffect(() => {
|
||
if (!hasInitializedWidths.current && visibleColumns.length > 0 && data.length > 0) {
|
||
const timer = setTimeout(() => {
|
||
const storageKey =
|
||
tableConfig.selectedTable && userId ? `table_column_widths_${tableConfig.selectedTable}_${userId}` : null;
|
||
|
||
// 1. localStorage에서 저장된 너비 불러오기
|
||
let savedWidths: Record<string, number> = {};
|
||
if (storageKey) {
|
||
try {
|
||
const saved = localStorage.getItem(storageKey);
|
||
if (saved) {
|
||
savedWidths = JSON.parse(saved);
|
||
}
|
||
} catch (error) {
|
||
console.error("컬럼 너비 불러오기 실패:", error);
|
||
}
|
||
}
|
||
|
||
// 2. 자동 계산 또는 저장된 너비 적용
|
||
const newWidths: Record<string, number> = {};
|
||
let hasAnyWidth = false;
|
||
|
||
visibleColumns.forEach((column) => {
|
||
// 체크박스 컬럼은 제외 (고정 48px)
|
||
if (column.columnName === "__checkbox__") return;
|
||
|
||
// 저장된 너비가 있으면 우선 사용
|
||
if (savedWidths[column.columnName]) {
|
||
newWidths[column.columnName] = savedWidths[column.columnName];
|
||
hasAnyWidth = true;
|
||
} else {
|
||
// 저장된 너비가 없으면 자동 계산
|
||
const optimalWidth = calculateOptimalColumnWidth(
|
||
column.columnName,
|
||
columnLabels[column.columnName] || column.displayName,
|
||
);
|
||
newWidths[column.columnName] = optimalWidth;
|
||
hasAnyWidth = true;
|
||
}
|
||
});
|
||
|
||
if (hasAnyWidth) {
|
||
setColumnWidths(newWidths);
|
||
hasInitializedWidths.current = true;
|
||
}
|
||
}, 150); // DOM 렌더링 대기
|
||
|
||
return () => clearTimeout(timer);
|
||
}
|
||
}, [visibleColumns, data, tableConfig.selectedTable, userId, calculateOptimalColumnWidth, columnLabels]);
|
||
|
||
// ========================================
|
||
// 페이지네이션 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>
|
||
|
||
{/* 우측 버튼 그룹 */}
|
||
<div className="absolute right-2 flex items-center gap-1 sm:right-6">
|
||
{/* 🆕 내보내기 버튼 (Excel/PDF) */}
|
||
<Popover>
|
||
<PopoverTrigger asChild>
|
||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 sm:h-9 sm:w-auto sm:px-3" title="내보내기">
|
||
<Download className="h-3 w-3" />
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent className="w-56 p-2" align="end">
|
||
<div className="flex flex-col gap-1">
|
||
<div className="text-muted-foreground px-2 py-1 text-xs font-medium">Excel</div>
|
||
<Button variant="ghost" size="sm" className="justify-start text-xs" onClick={() => exportToExcel(true)}>
|
||
<FileSpreadsheet className="mr-2 h-3 w-3 text-green-600" />
|
||
전체 Excel 내보내기
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="justify-start text-xs"
|
||
onClick={() => exportToExcel(false)}
|
||
disabled={selectedRows.size === 0}
|
||
>
|
||
<FileSpreadsheet className="mr-2 h-3 w-3 text-green-600" />
|
||
선택 항목만 ({selectedRows.size}개)
|
||
</Button>
|
||
<div className="border-border my-1 border-t" />
|
||
<div className="text-muted-foreground px-2 py-1 text-xs font-medium">PDF/인쇄</div>
|
||
<Button variant="ghost" size="sm" className="justify-start text-xs" onClick={() => exportToPdf(true)}>
|
||
<FileText className="mr-2 h-3 w-3 text-red-600" />
|
||
전체 PDF 내보내기
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="justify-start text-xs"
|
||
onClick={() => exportToPdf(false)}
|
||
disabled={selectedRows.size === 0}
|
||
>
|
||
<FileText className="mr-2 h-3 w-3 text-red-600" />
|
||
선택 항목만 ({selectedRows.size}개)
|
||
</Button>
|
||
</div>
|
||
</PopoverContent>
|
||
</Popover>
|
||
|
||
{/* 새로고침 버튼 (하단 페이지네이션) */}
|
||
{(tableConfig.toolbar?.showPaginationRefresh ?? true) && (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={handleRefresh}
|
||
disabled={loading}
|
||
className="h-8 w-8 p-0 sm:h-9 sm:w-auto sm:px-3"
|
||
>
|
||
<RefreshCw className={cn("h-3 w-3", loading && "animate-spin")} />
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}, [
|
||
tableConfig.pagination,
|
||
tableConfig.toolbar?.showPaginationRefresh,
|
||
isDesignMode,
|
||
currentPage,
|
||
totalPages,
|
||
totalItems,
|
||
loading,
|
||
selectedRows.size,
|
||
exportToExcel,
|
||
exportToPdf,
|
||
]);
|
||
|
||
// ========================================
|
||
// 렌더링
|
||
// ========================================
|
||
|
||
const domProps = {
|
||
onClick: handleClick,
|
||
onDragStart: isDesignMode ? onDragStart : undefined,
|
||
onDragEnd: isDesignMode ? onDragEnd : undefined,
|
||
draggable: isDesignMode,
|
||
className: cn("w-full h-full", className, isDesignMode && "cursor-move"), // customer-item-mapping과 동일
|
||
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={{ 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으로 이동 */}
|
||
|
||
{/* 🆕 DevExpress 스타일 기능 툴바 */}
|
||
<div className="border-border bg-muted/20 flex flex-wrap items-center gap-1 border-b px-2 py-1.5 sm:gap-2 sm:px-4 sm:py-2">
|
||
{/* 편집 모드 토글 */}
|
||
{(tableConfig.toolbar?.showEditMode ?? true) && (
|
||
<div className="border-border flex items-center gap-1 border-r pr-2">
|
||
<Button
|
||
variant={editMode === "batch" ? "default" : "ghost"}
|
||
size="sm"
|
||
onClick={() => setEditMode(editMode === "batch" ? "immediate" : "batch")}
|
||
className="h-7 text-xs"
|
||
title="배치 편집 모드 (Ctrl+B)"
|
||
>
|
||
<Edit className="mr-1 h-3 w-3" />
|
||
{editMode === "batch" ? "배치 모드" : "즉시 저장"}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{/* 내보내기 버튼들 */}
|
||
{((tableConfig.toolbar?.showExcel ?? true) || (tableConfig.toolbar?.showPdf ?? true)) && (
|
||
<div className="border-border flex items-center gap-1 border-r pr-2">
|
||
{(tableConfig.toolbar?.showExcel ?? true) && (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => exportToExcel(true)}
|
||
className="h-7 text-xs"
|
||
title="Excel 내보내기"
|
||
>
|
||
<FileSpreadsheet className="mr-1 h-3 w-3 text-green-600" />
|
||
Excel
|
||
</Button>
|
||
)}
|
||
{(tableConfig.toolbar?.showPdf ?? true) && (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => exportToPdf(true)}
|
||
className="h-7 text-xs"
|
||
title="PDF 내보내기"
|
||
>
|
||
<FileText className="mr-1 h-3 w-3 text-red-600" />
|
||
PDF
|
||
</Button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 복사 버튼 */}
|
||
{(tableConfig.toolbar?.showCopy ?? true) && (
|
||
<div className="border-border flex items-center gap-1 border-r pr-2">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={handleCopy}
|
||
disabled={selectedRows.size === 0 && !focusedCell}
|
||
className="h-7 text-xs"
|
||
title="복사 (Ctrl+C)"
|
||
>
|
||
<Copy className="mr-1 h-3 w-3" />
|
||
복사
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{/* 선택 정보 */}
|
||
{selectedRows.size > 0 && (
|
||
<div className="border-border flex items-center gap-1 border-r pr-2">
|
||
<span className="bg-primary/10 text-primary rounded px-2 py-0.5 text-xs">
|
||
{selectedRows.size}개 선택됨
|
||
</span>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => setSelectedRows(new Set())}
|
||
className="h-6 w-6 p-0"
|
||
title="선택 해제"
|
||
>
|
||
<X className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{/* 🆕 통합 검색 패널 */}
|
||
{(tableConfig.toolbar?.showSearch ?? true) && (
|
||
<div className="border-border flex items-center gap-1 border-r pr-2">
|
||
{isSearchPanelOpen ? (
|
||
<div className="flex items-center gap-1">
|
||
<input
|
||
type="text"
|
||
value={globalSearchTerm}
|
||
onChange={(e) => setGlobalSearchTerm(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") {
|
||
executeGlobalSearch(globalSearchTerm);
|
||
} else if (e.key === "Escape") {
|
||
clearGlobalSearch();
|
||
} else if (e.key === "F3" || (e.key === "g" && (e.ctrlKey || e.metaKey))) {
|
||
e.preventDefault();
|
||
if (e.shiftKey) {
|
||
goToPrevSearchResult();
|
||
} else {
|
||
goToNextSearchResult();
|
||
}
|
||
}
|
||
}}
|
||
placeholder="검색어 입력... (Enter)"
|
||
className="border-input bg-background focus:ring-primary h-7 w-32 rounded border px-2 text-xs focus:ring-1 focus:outline-none sm:w-48"
|
||
autoFocus
|
||
/>
|
||
{searchHighlights.size > 0 && (
|
||
<span className="text-muted-foreground text-xs">{searchHighlights.size}개</span>
|
||
)}
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={goToPrevSearchResult}
|
||
disabled={searchHighlights.size === 0}
|
||
className="h-6 w-6 p-0"
|
||
title="이전 (Shift+F3)"
|
||
>
|
||
<ChevronLeft className="h-3 w-3" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={goToNextSearchResult}
|
||
disabled={searchHighlights.size === 0}
|
||
className="h-6 w-6 p-0"
|
||
title="다음 (F3)"
|
||
>
|
||
<ChevronRight className="h-3 w-3" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={clearGlobalSearch}
|
||
className="h-6 w-6 p-0"
|
||
title="닫기 (Esc)"
|
||
>
|
||
<X className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => setIsSearchPanelOpen(true)}
|
||
className="h-7 text-xs"
|
||
title="통합 검색 (Ctrl+F)"
|
||
>
|
||
<Filter className="mr-1 h-3 w-3" />
|
||
검색
|
||
</Button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 🆕 Filter Builder (고급 필터) 버튼 */}
|
||
{(tableConfig.toolbar?.showFilter ?? true) && (
|
||
<div className="border-border flex items-center gap-1 border-r pr-2">
|
||
<Button
|
||
variant={activeFilterCount > 0 ? "default" : "ghost"}
|
||
size="sm"
|
||
onClick={() => setIsFilterBuilderOpen(true)}
|
||
className="h-7 text-xs"
|
||
title="고급 필터"
|
||
>
|
||
<Layers className="mr-1 h-3 w-3" />
|
||
필터
|
||
{activeFilterCount > 0 && (
|
||
<span className="ml-1 rounded-full bg-white/20 px-1.5 text-[10px]">{activeFilterCount}</span>
|
||
)}
|
||
</Button>
|
||
{activeFilterCount > 0 && (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={clearFilterBuilder}
|
||
className="h-6 w-6 p-0"
|
||
title="필터 초기화"
|
||
>
|
||
<X className="h-3 w-3" />
|
||
</Button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 새로고침 */}
|
||
{(tableConfig.toolbar?.showRefresh ?? true) && (
|
||
<div className="ml-auto flex items-center gap-1">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={handleRefresh}
|
||
disabled={loading}
|
||
className="h-7 text-xs"
|
||
title="새로고침"
|
||
>
|
||
<RefreshCw className={cn("mr-1 h-3 w-3", loading && "animate-spin")} />
|
||
새로고침
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 🆕 배치 편집 툴바 */}
|
||
{(editMode === "batch" || pendingChanges.size > 0) && (
|
||
<div className="border-border flex items-center justify-between border-b bg-amber-50 px-4 py-2 sm:px-6 dark:bg-amber-950/30">
|
||
<div className="flex items-center gap-3 text-xs sm:text-sm">
|
||
<span className="rounded bg-amber-100 px-2 py-1 font-medium text-amber-700 dark:bg-amber-900/50 dark:text-amber-300">
|
||
배치 편집 모드
|
||
</span>
|
||
{pendingChanges.size > 0 && (
|
||
<span className="text-amber-600 dark:text-amber-400">{pendingChanges.size}개 변경사항</span>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={cancelBatchChanges}
|
||
disabled={pendingChanges.size === 0}
|
||
className="h-7 text-xs"
|
||
>
|
||
취소
|
||
</Button>
|
||
<Button
|
||
variant="default"
|
||
size="sm"
|
||
onClick={saveBatchChanges}
|
||
disabled={pendingChanges.size === 0}
|
||
className="h-7 text-xs"
|
||
>
|
||
저장 ({pendingChanges.size})
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 그룹 표시 배지 */}
|
||
{groupByColumns.length > 0 && (
|
||
<div className="border-border bg-muted/30 border-b px-4 py-2 sm:px-6">
|
||
<div className="flex items-center gap-2 text-xs sm:text-sm">
|
||
<span className="text-muted-foreground">그룹:</span>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
{groupByColumns.map((col, idx) => (
|
||
<span key={col} className="flex items-center">
|
||
{idx > 0 && <span className="text-muted-foreground mx-1">→</span>}
|
||
<span className="bg-primary/10 text-primary rounded px-2 py-1 text-xs font-medium">
|
||
{columnLabels[col] || col}
|
||
</span>
|
||
</span>
|
||
))}
|
||
</div>
|
||
<button
|
||
onClick={clearGrouping}
|
||
className="hover:bg-destructive/10 text-destructive ml-auto rounded p-1"
|
||
title="그룹 해제"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 테이블 컨테이너 - 키보드 네비게이션 지원 */}
|
||
<div
|
||
ref={tableContainerRef}
|
||
className="flex flex-1 flex-col focus:outline-none"
|
||
style={{
|
||
width: "100%",
|
||
height: "100%",
|
||
overflow: "hidden",
|
||
}}
|
||
tabIndex={0}
|
||
onKeyDown={handleTableKeyDown}
|
||
role="grid"
|
||
aria-label="데이터 테이블"
|
||
>
|
||
{/* 스크롤 영역 */}
|
||
<div
|
||
ref={scrollContainerRef}
|
||
className="bg-background flex-1"
|
||
style={{
|
||
position: "relative",
|
||
width: "100%",
|
||
height: "100%",
|
||
overflow: "auto",
|
||
}}
|
||
onScroll={handleVirtualScroll}
|
||
>
|
||
{/* 테이블 */}
|
||
<table
|
||
className={cn("table-mobile-fixed", !showGridLines && "hide-grid")}
|
||
style={{
|
||
borderCollapse: "collapse",
|
||
width: "100%",
|
||
tableLayout: "fixed",
|
||
}}
|
||
>
|
||
{/* 헤더 (sticky) */}
|
||
<thead
|
||
className="sticky z-50"
|
||
style={{
|
||
position: "sticky",
|
||
top: 0,
|
||
zIndex: 50,
|
||
backgroundColor: "hsl(var(--background))",
|
||
}}
|
||
>
|
||
{/* 🆕 Multi-Level Headers (Column Bands) */}
|
||
{columnBandsInfo?.hasBands && (
|
||
<tr
|
||
className="border-primary/10 bg-muted/70 h-8 border-b sm:h-10"
|
||
style={{ backgroundColor: "hsl(var(--muted) / 0.7)" }}
|
||
>
|
||
{visibleColumns.map((column, colIdx) => {
|
||
// 이 컬럼이 속한 band 찾기
|
||
const band = columnBandsInfo.bands.find(
|
||
(b) => b.columns.includes(column.columnName) && b.startIndex === colIdx,
|
||
);
|
||
|
||
// band의 첫 번째 컬럼인 경우에만 렌더링
|
||
if (band) {
|
||
return (
|
||
<th
|
||
key={`band-${column.columnName}`}
|
||
colSpan={band.colSpan}
|
||
className="border-primary/10 border-r px-2 py-1 text-center text-xs font-semibold sm:px-4 sm:text-sm"
|
||
>
|
||
{band.caption}
|
||
</th>
|
||
);
|
||
}
|
||
|
||
// band에 속하지 않은 컬럼 (개별 표시)
|
||
const isInAnyBand = columnBandsInfo.bands.some((b) => b.columns.includes(column.columnName));
|
||
if (!isInAnyBand) {
|
||
return (
|
||
<th
|
||
key={`noband-${column.columnName}`}
|
||
rowSpan={2}
|
||
className="border-primary/10 border-r px-2 py-1 text-center text-xs font-semibold sm:px-4 sm:text-sm"
|
||
>
|
||
{columnLabels[column.columnName] || column.columnName}
|
||
</th>
|
||
);
|
||
}
|
||
|
||
// band의 중간 컬럼은 렌더링하지 않음
|
||
return null;
|
||
})}
|
||
</tr>
|
||
)}
|
||
<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;
|
||
}
|
||
}
|
||
|
||
// 🆕 Column Reordering 상태
|
||
const isColumnDragging = draggedColumnIndex === columnIndex;
|
||
const isColumnDropTarget = dropTargetColumnIndex === columnIndex;
|
||
|
||
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)]",
|
||
// 🆕 Column Reordering 스타일
|
||
isColumnDragEnabled &&
|
||
column.columnName !== "__checkbox__" &&
|
||
"cursor-grab active:cursor-grabbing",
|
||
isColumnDragging && "bg-primary/20 opacity-50",
|
||
isColumnDropTarget && "border-l-primary border-l-4",
|
||
)}
|
||
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` }),
|
||
}}
|
||
// 🆕 Column Reordering 이벤트
|
||
draggable={isColumnDragEnabled && column.columnName !== "__checkbox__"}
|
||
onDragStart={(e) => handleColumnDragStart(e, columnIndex)}
|
||
onDragOver={(e) => handleColumnDragOver(e, columnIndex)}
|
||
onDragEnd={handleColumnDragEnd}
|
||
onDrop={(e) => handleColumnDrop(e, columnIndex)}
|
||
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: "4px", justifyContent: "center" }}>
|
||
{/* 🆕 편집 불가 컬럼 표시 */}
|
||
{column.editable === false && (
|
||
<span title="편집 불가">
|
||
<Lock className="text-muted-foreground h-3 w-3" />
|
||
</span>
|
||
)}
|
||
<span>{columnLabels[column.columnName] || column.displayName}</span>
|
||
{column.sortable !== false && sortColumn === column.columnName && (
|
||
<span>{sortDirection === "asc" ? "↑" : "↓"}</span>
|
||
)}
|
||
{/* 🆕 헤더 필터 버튼 */}
|
||
{tableConfig.headerFilter !== false &&
|
||
columnUniqueValues[column.columnName]?.length > 0 && (
|
||
<Popover
|
||
open={openFilterColumn === column.columnName}
|
||
onOpenChange={(open) => setOpenFilterColumn(open ? column.columnName : null)}
|
||
>
|
||
<PopoverTrigger asChild>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setOpenFilterColumn(
|
||
openFilterColumn === column.columnName ? null : column.columnName,
|
||
);
|
||
}}
|
||
className={cn(
|
||
"hover:bg-primary/20 ml-1 rounded p-0.5 transition-colors",
|
||
headerFilters[column.columnName]?.size > 0 && "text-primary bg-primary/10",
|
||
)}
|
||
title="필터"
|
||
>
|
||
<Filter className="h-3 w-3" />
|
||
</button>
|
||
</PopoverTrigger>
|
||
<PopoverContent
|
||
className="w-48 p-2"
|
||
align="start"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="space-y-2">
|
||
<div className="flex items-center justify-between border-b pb-2">
|
||
<span className="text-xs font-medium">
|
||
필터: {columnLabels[column.columnName] || column.displayName}
|
||
</span>
|
||
{headerFilters[column.columnName]?.size > 0 && (
|
||
<button
|
||
onClick={() => clearHeaderFilter(column.columnName)}
|
||
className="text-destructive text-xs hover:underline"
|
||
>
|
||
초기화
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="max-h-48 space-y-1 overflow-y-auto">
|
||
{columnUniqueValues[column.columnName]?.slice(0, 50).map((val) => {
|
||
const isSelected = headerFilters[column.columnName]?.has(val);
|
||
return (
|
||
<div
|
||
key={val}
|
||
className={cn(
|
||
"hover:bg-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-xs",
|
||
isSelected && "bg-primary/10",
|
||
)}
|
||
onClick={() => toggleHeaderFilter(column.columnName, val)}
|
||
>
|
||
<div
|
||
className={cn(
|
||
"flex h-4 w-4 items-center justify-center rounded border",
|
||
isSelected ? "bg-primary border-primary" : "border-input",
|
||
)}
|
||
>
|
||
{isSelected && <Check className="text-primary-foreground h-3 w-3" />}
|
||
</div>
|
||
<span className="truncate">{val || "(빈 값)"}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
{(columnUniqueValues[column.columnName]?.length || 0) > 50 && (
|
||
<div className="text-muted-foreground px-2 py-1 text-xs">
|
||
...외 {(columnUniqueValues[column.columnName]?.length || 0) - 50}개
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</PopoverContent>
|
||
</Popover>
|
||
)}
|
||
</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) => {
|
||
const newWidths = { ...prev, [column.columnName]: finalWidth };
|
||
|
||
// 🎯 localStorage에 컬럼 너비 저장 (사용자별)
|
||
if (tableConfig.selectedTable && userId) {
|
||
const storageKey = `table_column_widths_${tableConfig.selectedTable}_${userId}`;
|
||
try {
|
||
localStorage.setItem(storageKey, JSON.stringify(newWidths));
|
||
} catch (error) {
|
||
console.error("컬럼 너비 저장 실패:", error);
|
||
}
|
||
}
|
||
|
||
return newWidths;
|
||
});
|
||
}
|
||
|
||
// 텍스트 선택 복원
|
||
document.body.style.userSelect = "";
|
||
document.body.style.cursor = "";
|
||
|
||
// 약간의 지연 후 리사이즈 플래그 해제 (클릭 이벤트가 먼저 처리되지 않도록)
|
||
setTimeout(() => {
|
||
isResizing.current = false;
|
||
}, 100);
|
||
|
||
document.removeEventListener("mousemove", handleMouseMove);
|
||
document.removeEventListener("mouseup", handleMouseUp);
|
||
};
|
||
|
||
document.addEventListener("mousemove", handleMouseMove);
|
||
document.addEventListener("mouseup", handleMouseUp);
|
||
}}
|
||
/>
|
||
)}
|
||
</th>
|
||
);
|
||
})}
|
||
</tr>
|
||
</thead>
|
||
|
||
{/* 바디 (스크롤) */}
|
||
<tbody key={`tbody-${categoryMappingsKey}`} style={{ position: "relative" }}>
|
||
{loading ? (
|
||
<tr>
|
||
<td colSpan={visibleColumns.length} className="p-12 text-center">
|
||
<div className="flex flex-col items-center gap-3">
|
||
<RefreshCw className="text-muted-foreground h-8 w-8 animate-spin" />
|
||
<div className="text-muted-foreground text-sm font-medium">로딩 중...</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
) : error ? (
|
||
<tr>
|
||
<td colSpan={visibleColumns.length} className="p-12 text-center">
|
||
<div className="flex flex-col items-center gap-3">
|
||
<div className="text-destructive text-sm font-medium">오류 발생</div>
|
||
<div className="text-muted-foreground text-xs">{error}</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
) : data.length === 0 ? (
|
||
<tr>
|
||
<td colSpan={visibleColumns.length} className="p-12 text-center">
|
||
<div className="flex flex-col items-center gap-3">
|
||
<TableIcon className="text-muted-foreground/50 h-12 w-12" />
|
||
<div className="text-muted-foreground text-sm font-medium">데이터가 없습니다</div>
|
||
<div className="text-muted-foreground text-xs">
|
||
조건을 변경하거나 새로운 데이터를 추가해보세요
|
||
</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
) : groupByColumns.length > 0 && groupedData.length > 0 ? (
|
||
// 그룹화된 렌더링
|
||
groupedData.map((group) => {
|
||
const isCollapsed = collapsedGroups.has(group.groupKey);
|
||
return (
|
||
<React.Fragment key={group.groupKey}>
|
||
{/* 그룹 헤더 */}
|
||
<tr>
|
||
<td
|
||
colSpan={visibleColumns.length}
|
||
className="bg-muted/50 border-border sticky top-[48px] z-[5] border-b"
|
||
style={{ top: "48px" }}
|
||
>
|
||
<div
|
||
className="hover:bg-muted flex cursor-pointer items-center gap-3 p-3"
|
||
onClick={() => toggleGroupCollapse(group.groupKey)}
|
||
>
|
||
{isCollapsed ? (
|
||
<ChevronRight className="h-4 w-4 flex-shrink-0" />
|
||
) : (
|
||
<ChevronDown className="h-4 w-4 flex-shrink-0" />
|
||
)}
|
||
<span className="flex-1 text-sm font-medium">{group.groupKey}</span>
|
||
<span className="text-muted-foreground text-xs">({group.count}건)</span>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
{/* 그룹 데이터 */}
|
||
{!isCollapsed &&
|
||
group.items.map((row, index) => (
|
||
<tr
|
||
key={index}
|
||
className={cn(
|
||
"bg-background hover:bg-muted/50 cursor-pointer border-b transition-colors",
|
||
)}
|
||
onClick={(e) => handleRowClick(row, index, e)}
|
||
>
|
||
{visibleColumns.map((column, colIndex) => {
|
||
const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName;
|
||
const cellValue = row[mappedColumnName];
|
||
|
||
const meta = columnMeta[column.columnName];
|
||
const inputType = meta?.inputType || column.inputType;
|
||
const isNumeric = inputType === "number" || inputType === "decimal";
|
||
|
||
const isFrozen = frozenColumns.includes(column.columnName);
|
||
const frozenIndex = frozenColumns.indexOf(column.columnName);
|
||
|
||
// 틀고정된 컬럼의 left 위치 계산
|
||
let leftPosition = 0;
|
||
if (isFrozen && frozenIndex > 0) {
|
||
for (let i = 0; i < frozenIndex; i++) {
|
||
const frozenCol = frozenColumns[i];
|
||
const frozenColWidth = columnWidths[frozenCol] || 150;
|
||
leftPosition += frozenColWidth;
|
||
}
|
||
}
|
||
|
||
return (
|
||
<td
|
||
key={column.columnName}
|
||
className={cn(
|
||
"text-foreground overflow-hidden text-xs font-normal text-ellipsis whitespace-nowrap 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>
|
||
))}
|
||
{/* 🆕 그룹별 소계 행 */}
|
||
{!isCollapsed && group.summary && Object.keys(group.summary).length > 0 && (
|
||
<tr className="bg-muted/30 border-primary/20 border-b-2">
|
||
{visibleColumns.map((column, colIndex) => {
|
||
const summary = group.summary?.[column.columnName];
|
||
const meta = columnMeta[column.columnName];
|
||
const inputType = meta?.inputType || (column as any).inputType;
|
||
const isNumeric = inputType === "number" || inputType === "decimal";
|
||
|
||
if (colIndex === 0 && column.columnName === "__checkbox__") {
|
||
return (
|
||
<td key={column.columnName} className="px-2 py-1 sm:px-4">
|
||
<span className="text-muted-foreground text-xs font-medium">소계</span>
|
||
</td>
|
||
);
|
||
}
|
||
|
||
if (colIndex === 0 && column.columnName !== "__checkbox__") {
|
||
return (
|
||
<td key={column.columnName} className="px-2 py-1 sm:px-4">
|
||
<span className="text-muted-foreground text-xs font-medium">
|
||
소계 ({group.count}건)
|
||
</span>
|
||
</td>
|
||
);
|
||
}
|
||
|
||
if (summary) {
|
||
return (
|
||
<td
|
||
key={column.columnName}
|
||
className="px-2 py-1 text-xs font-semibold sm:px-4"
|
||
style={{ textAlign: isNumeric ? "right" : "left" }}
|
||
>
|
||
{summary.sum.toLocaleString()}
|
||
</td>
|
||
);
|
||
}
|
||
|
||
return <td key={column.columnName} className="px-2 py-1 sm:px-4" />;
|
||
})}
|
||
</tr>
|
||
)}
|
||
</React.Fragment>
|
||
);
|
||
})
|
||
) : (
|
||
// 일반 렌더링 (그룹 없음) - 키보드 네비게이션 지원
|
||
<>
|
||
{/* 🆕 Virtual Scrolling: Top Spacer */}
|
||
{isVirtualScrollEnabled && virtualScrollInfo.topSpacerHeight > 0 && (
|
||
<tr style={{ height: `${virtualScrollInfo.topSpacerHeight}px` }}>
|
||
<td colSpan={visibleColumns.length} />
|
||
</tr>
|
||
)}
|
||
{/* 데이터 행 렌더링 - 🆕 합산 모드면 displayData 사용 */}
|
||
{(isVirtualScrollEnabled ? virtualScrollInfo.visibleData : displayData).map((row, idx) => {
|
||
// Virtual Scrolling에서는 실제 인덱스 계산
|
||
const index = isVirtualScrollEnabled ? virtualScrollInfo.startIndex + idx : idx;
|
||
const rowKey = getRowKey(row, index);
|
||
const isRowSelected = selectedRows.has(rowKey);
|
||
const isRowFocused = focusedCell?.rowIndex === index;
|
||
|
||
// 🆕 Drag & Drop 상태
|
||
const isDragging = draggedRowIndex === index;
|
||
const isDropTarget = dropTargetIndex === index;
|
||
|
||
return (
|
||
<tr
|
||
key={index}
|
||
className={cn(
|
||
"bg-background hover:bg-muted/50 cursor-pointer border-b transition-colors",
|
||
isRowSelected && "bg-primary/10 hover:bg-primary/15",
|
||
isRowFocused && "ring-primary/50 ring-1 ring-inset",
|
||
// 🆕 Drag & Drop 스타일
|
||
isDragEnabled && "cursor-grab active:cursor-grabbing",
|
||
isDragging && "bg-muted opacity-50",
|
||
isDropTarget && "border-t-primary border-t-2",
|
||
)}
|
||
onClick={(e) => handleRowClick(row, index, e)}
|
||
role="row"
|
||
aria-selected={isRowSelected}
|
||
// 🆕 Drag & Drop 이벤트
|
||
draggable={isDragEnabled}
|
||
onDragStart={(e) => handleRowDragStart(e, index)}
|
||
onDragOver={(e) => handleRowDragOver(e, index)}
|
||
onDragEnd={handleRowDragEnd}
|
||
onDrop={(e) => handleRowDrop(e, index)}
|
||
>
|
||
{visibleColumns.map((column, colIndex) => {
|
||
const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName;
|
||
// 🆕 배치 편집: 로컬 수정 데이터 우선 표시
|
||
const cellValue =
|
||
editMode === "batch"
|
||
? getDisplayValue(row, index, mappedColumnName)
|
||
: 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);
|
||
|
||
// 셀 포커스 상태
|
||
const isCellFocused = focusedCell?.rowIndex === index && focusedCell?.colIndex === colIndex;
|
||
|
||
// 🆕 배치 편집: 수정된 셀 여부
|
||
const isModified = isCellModified(index, mappedColumnName);
|
||
|
||
// 🆕 유효성 검사 에러
|
||
const cellValidationError = getCellValidationError(index, mappedColumnName);
|
||
|
||
// 🆕 검색 하이라이트 여부
|
||
const isSearchHighlighted = searchHighlights.has(`${index}-${colIndex}`);
|
||
|
||
// 틀고정된 컬럼의 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}
|
||
data-row={index}
|
||
data-col={colIndex}
|
||
className={cn(
|
||
"text-foreground overflow-hidden text-xs font-normal text-ellipsis whitespace-nowrap 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)]",
|
||
// 🆕 포커스된 셀 스타일
|
||
isCellFocused && !editingCell && "ring-primary bg-primary/5 ring-2 ring-inset",
|
||
// 🆕 편집 중인 셀 스타일
|
||
editingCell?.rowIndex === index && editingCell?.colIndex === colIndex && "p-0",
|
||
// 🆕 배치 편집: 수정된 셀 스타일 (노란 배경)
|
||
isModified && !cellValidationError && "bg-amber-100 dark:bg-amber-900/40",
|
||
// 🆕 유효성 에러: 빨간 테두리 및 배경
|
||
cellValidationError && "bg-red-50 ring-2 ring-red-500 ring-inset dark:bg-red-950/40",
|
||
// 🆕 검색 하이라이트 스타일 (노란 배경)
|
||
isSearchHighlighted && !isCellFocused && "bg-yellow-200 dark:bg-yellow-700/50",
|
||
// 🆕 편집 불가 컬럼 스타일 (연한 회색 배경)
|
||
column.editable === false && "bg-gray-50 dark:bg-gray-900/30",
|
||
)}
|
||
// 🆕 유효성 에러 툴팁
|
||
title={cellValidationError || undefined}
|
||
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` }),
|
||
}}
|
||
onClick={(e) => handleCellClick(index, colIndex, e)}
|
||
onDoubleClick={() =>
|
||
handleCellDoubleClick(index, colIndex, column.columnName, cellValue)
|
||
}
|
||
onContextMenu={(e) => handleContextMenu(e, index, colIndex, row)}
|
||
role="gridcell"
|
||
tabIndex={isCellFocused ? 0 : -1}
|
||
>
|
||
{/* 🆕 인라인 편집 모드 */}
|
||
{editingCell?.rowIndex === index && editingCell?.colIndex === colIndex
|
||
? // 🆕 Cascading Lookups: 드롭다운 또는 일반 입력
|
||
(() => {
|
||
const cascadingConfig = (tableConfig as any).cascadingLookups?.[
|
||
column.columnName
|
||
];
|
||
const options = cascadingConfig
|
||
? getCascadingOptions(column.columnName, row)
|
||
: [];
|
||
|
||
// 부모 값이 변경되면 옵션 로딩
|
||
if (cascadingConfig && options.length === 0) {
|
||
const parentValue = row[cascadingConfig.parentColumn];
|
||
if (parentValue !== undefined && parentValue !== null) {
|
||
loadCascadingOptions(
|
||
column.columnName,
|
||
cascadingConfig.parentColumn,
|
||
parentValue,
|
||
);
|
||
}
|
||
}
|
||
|
||
// 카테고리/코드 타입이거나 Cascading Lookup인 경우 드롭다운
|
||
const colMeta = columnMeta[column.columnName];
|
||
const isCategoryType =
|
||
colMeta?.inputType === "category" || colMeta?.inputType === "code";
|
||
const hasCategoryOptions =
|
||
categoryMappings[column.columnName] &&
|
||
Object.keys(categoryMappings[column.columnName]).length > 0;
|
||
|
||
if (cascadingConfig || (isCategoryType && hasCategoryOptions)) {
|
||
const selectOptions = cascadingConfig
|
||
? options
|
||
: Object.entries(categoryMappings[column.columnName] || {}).map(
|
||
([value, info]) => ({
|
||
value,
|
||
label: info.label,
|
||
}),
|
||
);
|
||
|
||
return (
|
||
<select
|
||
ref={editInputRef as any}
|
||
value={editingValue}
|
||
onChange={(e) => setEditingValue(e.target.value)}
|
||
onKeyDown={handleEditKeyDown}
|
||
onBlur={saveEditing}
|
||
className="border-primary bg-background h-full w-full border-2 px-2 py-1 text-xs focus:outline-none sm:px-4 sm:py-1.5 sm:text-sm"
|
||
autoFocus
|
||
>
|
||
<option value="">선택하세요</option>
|
||
{selectOptions.map((opt) => (
|
||
<option key={opt.value} value={opt.value}>
|
||
{opt.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
);
|
||
}
|
||
|
||
// 일반 입력 필드
|
||
return (
|
||
<input
|
||
ref={editInputRef}
|
||
type={isNumeric ? "number" : "text"}
|
||
value={editingValue}
|
||
onChange={(e) => setEditingValue(e.target.value)}
|
||
onKeyDown={handleEditKeyDown}
|
||
onBlur={saveEditing}
|
||
className="border-primary bg-background h-full w-full border-2 px-2 py-1 text-xs focus:outline-none sm:px-4 sm:py-1.5 sm:text-sm"
|
||
style={{
|
||
textAlign: isNumeric ? "right" : column.align || "left",
|
||
}}
|
||
/>
|
||
);
|
||
})()
|
||
: column.columnName === "__checkbox__"
|
||
? renderCheckboxCell(row, index)
|
||
: formatCellValue(cellValue, column, row)}
|
||
</td>
|
||
);
|
||
})}
|
||
</tr>
|
||
);
|
||
})}
|
||
{/* 🆕 Virtual Scrolling: Bottom Spacer */}
|
||
{isVirtualScrollEnabled && virtualScrollInfo.bottomSpacerHeight > 0 && (
|
||
<tr style={{ height: `${virtualScrollInfo.bottomSpacerHeight}px` }}>
|
||
<td colSpan={visibleColumns.length} />
|
||
</tr>
|
||
)}
|
||
</>
|
||
)}
|
||
</tbody>
|
||
|
||
{/* 🆕 데이터 요약 (Total Summaries) */}
|
||
{summaryData && Object.keys(summaryData).length > 0 && (
|
||
<tfoot className="bg-muted/80 border-primary/20 sticky bottom-0 z-10 border-t-2">
|
||
<tr>
|
||
{visibleColumns.map((column, colIndex) => {
|
||
const summary = summaryData[column.columnName];
|
||
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;
|
||
}
|
||
}
|
||
|
||
const meta = columnMeta[column.columnName];
|
||
const inputType = meta?.inputType || (column as any).inputType;
|
||
const isNumeric = inputType === "number" || inputType === "decimal";
|
||
|
||
return (
|
||
<td
|
||
key={column.columnName}
|
||
className={cn(
|
||
"text-foreground text-xs font-semibold sm:text-sm",
|
||
column.columnName === "__checkbox__" ? "px-0 py-2" : "px-2 py-2 sm:px-4",
|
||
isFrozen && "bg-muted/80 sticky z-10 shadow-[2px_0_4px_rgba(0,0,0,0.05)]",
|
||
)}
|
||
style={{
|
||
textAlign: isNumeric ? "right" : column.align || "left",
|
||
width:
|
||
column.columnName === "__checkbox__"
|
||
? "48px"
|
||
: columnWidth
|
||
? `${columnWidth}px`
|
||
: undefined,
|
||
...(isFrozen && { left: `${leftPosition}px` }),
|
||
}}
|
||
>
|
||
{summary ? (
|
||
<div className="flex flex-col">
|
||
<span className="text-muted-foreground text-[10px] sm:text-xs">{summary.label}</span>
|
||
<span className="text-primary font-bold">
|
||
{typeof summary.value === "number"
|
||
? summary.value.toLocaleString("ko-KR", {
|
||
maximumFractionDigits: 2,
|
||
})
|
||
: summary.value}
|
||
</span>
|
||
</div>
|
||
) : colIndex === 0 ? (
|
||
<span className="text-muted-foreground text-xs">요약</span>
|
||
) : null}
|
||
</td>
|
||
);
|
||
})}
|
||
</tr>
|
||
</tfoot>
|
||
)}
|
||
</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>
|
||
|
||
{/* 🆕 Context Menu (우클릭 메뉴) */}
|
||
{contextMenu && (
|
||
<div
|
||
className="bg-popover text-popover-foreground fixed z-[9999] min-w-[160px] rounded-md border shadow-md"
|
||
style={{
|
||
left: contextMenu.x,
|
||
top: contextMenu.y,
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="p-1">
|
||
{/* 셀 복사 */}
|
||
<button
|
||
className="hover:bg-accent hover:text-accent-foreground flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm"
|
||
onClick={() => {
|
||
const col = visibleColumns[contextMenu.colIndex];
|
||
if (col) {
|
||
const value = contextMenu.row[col.columnName];
|
||
navigator.clipboard.writeText(String(value ?? ""));
|
||
toast.success("셀 값이 복사되었습니다");
|
||
}
|
||
closeContextMenu();
|
||
}}
|
||
>
|
||
<Copy className="h-4 w-4" />셀 복사
|
||
</button>
|
||
|
||
{/* 행 복사 */}
|
||
<button
|
||
className="hover:bg-accent hover:text-accent-foreground flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm"
|
||
onClick={() => {
|
||
const rowData = visibleColumns.map((col) => String(contextMenu.row[col.columnName] ?? "")).join("\t");
|
||
navigator.clipboard.writeText(rowData);
|
||
toast.success("행 데이터가 복사되었습니다");
|
||
closeContextMenu();
|
||
}}
|
||
>
|
||
<ClipboardCopy className="h-4 w-4" />행 복사
|
||
</button>
|
||
|
||
<div className="bg-border my-1 h-px" />
|
||
|
||
{/* 셀 편집 */}
|
||
{(() => {
|
||
const col = visibleColumns[contextMenu.colIndex];
|
||
const isEditable = col?.editable !== false && col?.columnName !== "__checkbox__";
|
||
return (
|
||
<button
|
||
className={`flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm ${
|
||
isEditable ? "hover:bg-accent hover:text-accent-foreground" : "cursor-not-allowed opacity-50"
|
||
}`}
|
||
onClick={() => {
|
||
if (!isEditable) {
|
||
toast.warning(`'${col?.displayName || col?.columnName}' 컬럼은 편집할 수 없습니다.`);
|
||
closeContextMenu();
|
||
return;
|
||
}
|
||
if (col) {
|
||
setEditingCell({
|
||
rowIndex: contextMenu.rowIndex,
|
||
colIndex: contextMenu.colIndex,
|
||
columnName: col.columnName,
|
||
originalValue: contextMenu.row[col.columnName],
|
||
});
|
||
setEditingValue(String(contextMenu.row[col.columnName] ?? ""));
|
||
}
|
||
closeContextMenu();
|
||
}}
|
||
>
|
||
<Edit className="h-4 w-4" />셀 편집 {!isEditable && "(잠김)"}
|
||
</button>
|
||
);
|
||
})()}
|
||
|
||
{/* 행 선택/해제 */}
|
||
<button
|
||
className="hover:bg-accent hover:text-accent-foreground flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm"
|
||
onClick={() => {
|
||
const rowKey = getRowKey(contextMenu.row, contextMenu.rowIndex);
|
||
setSelectedRows((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(rowKey)) {
|
||
next.delete(rowKey);
|
||
} else {
|
||
next.add(rowKey);
|
||
}
|
||
return next;
|
||
});
|
||
closeContextMenu();
|
||
}}
|
||
>
|
||
<CheckSquare className="h-4 w-4" />
|
||
{selectedRows.has(getRowKey(contextMenu.row, contextMenu.rowIndex)) ? "선택 해제" : "행 선택"}
|
||
</button>
|
||
|
||
<div className="bg-border my-1 h-px" />
|
||
|
||
{/* 행 삭제 */}
|
||
<button
|
||
className="hover:bg-destructive hover:text-destructive-foreground flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-red-600"
|
||
onClick={async () => {
|
||
if (confirm("이 행을 삭제하시겠습니까?")) {
|
||
try {
|
||
const rowId = contextMenu.row.id || contextMenu.row.uuid;
|
||
if (!rowId) {
|
||
toast.error("삭제할 행의 ID를 찾을 수 없습니다");
|
||
closeContextMenu();
|
||
return;
|
||
}
|
||
const tableName = tableConfig.selectedTable;
|
||
if (!tableName) {
|
||
toast.error("테이블명을 찾을 수 없습니다");
|
||
closeContextMenu();
|
||
return;
|
||
}
|
||
await tableTypeApi.deleteTableData(tableName, { ids: [String(rowId)] });
|
||
toast.success("행이 삭제되었습니다");
|
||
handleRefresh();
|
||
} catch (error) {
|
||
console.error("삭제 오류:", error);
|
||
toast.error("삭제 중 오류가 발생했습니다");
|
||
}
|
||
}
|
||
closeContextMenu();
|
||
}}
|
||
>
|
||
<Trash2 className="h-4 w-4" />행 삭제
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 🆕 Filter Builder 모달 */}
|
||
<Dialog open={isFilterBuilderOpen} onOpenChange={setIsFilterBuilderOpen}>
|
||
<DialogContent className="max-w-[95vw] sm:max-w-[700px]">
|
||
<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-4 overflow-y-auto">
|
||
{filterGroups.length === 0 ? (
|
||
<div className="text-muted-foreground py-8 text-center text-sm">
|
||
필터 조건이 없습니다. 아래 버튼을 클릭하여 조건을 추가하세요.
|
||
</div>
|
||
) : (
|
||
filterGroups.map((group, groupIndex) => (
|
||
<div key={group.id} className="rounded-lg border p-4">
|
||
<div className="mb-3 flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-muted-foreground text-xs">조건 그룹 {groupIndex + 1}</span>
|
||
<select
|
||
value={group.logic}
|
||
onChange={(e) => updateGroupLogic(group.id, e.target.value as "AND" | "OR")}
|
||
className="border-input bg-background h-7 rounded border px-2 text-xs"
|
||
>
|
||
<option value="AND">AND (모두 만족)</option>
|
||
<option value="OR">OR (하나라도 만족)</option>
|
||
</select>
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => removeFilterGroup(group.id)}
|
||
className="h-6 w-6 p-0 text-red-500 hover:text-red-700"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
{group.conditions.map((condition) => (
|
||
<div key={condition.id} className="flex items-center gap-2">
|
||
{/* 컬럼 선택 */}
|
||
<select
|
||
value={condition.column}
|
||
onChange={(e) => updateFilterCondition(group.id, condition.id, "column", e.target.value)}
|
||
className="border-input bg-background h-8 w-32 rounded border px-2 text-xs sm:w-40"
|
||
>
|
||
{visibleColumns
|
||
.filter((c) => c.columnName !== "__checkbox__")
|
||
.map((col) => (
|
||
<option key={col.columnName} value={col.columnName}>
|
||
{columnLabels[col.columnName] || col.displayName || col.columnName}
|
||
</option>
|
||
))}
|
||
</select>
|
||
|
||
{/* 연산자 선택 */}
|
||
<select
|
||
value={condition.operator}
|
||
onChange={(e) => updateFilterCondition(group.id, condition.id, "operator", e.target.value)}
|
||
className="border-input bg-background h-8 w-28 rounded border px-2 text-xs sm:w-36"
|
||
>
|
||
<option value="contains">포함</option>
|
||
<option value="notContains">포함하지 않음</option>
|
||
<option value="equals">같음</option>
|
||
<option value="notEquals">같지 않음</option>
|
||
<option value="startsWith">시작</option>
|
||
<option value="endsWith">끝</option>
|
||
<option value="greaterThan">보다 큼</option>
|
||
<option value="lessThan">보다 작음</option>
|
||
<option value="greaterOrEqual">이상</option>
|
||
<option value="lessOrEqual">이하</option>
|
||
<option value="isEmpty">비어있음</option>
|
||
<option value="isNotEmpty">비어있지 않음</option>
|
||
</select>
|
||
|
||
{/* 값 입력 (isEmpty/isNotEmpty가 아닌 경우만) */}
|
||
{condition.operator !== "isEmpty" && condition.operator !== "isNotEmpty" && (
|
||
<input
|
||
type="text"
|
||
value={condition.value}
|
||
onChange={(e) => updateFilterCondition(group.id, condition.id, "value", e.target.value)}
|
||
placeholder="값 입력"
|
||
className="border-input bg-background h-8 flex-1 rounded border px-2 text-xs"
|
||
/>
|
||
)}
|
||
|
||
{/* 조건 삭제 */}
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => removeFilterCondition(group.id, condition.id)}
|
||
className="h-6 w-6 p-0 text-red-500 hover:text-red-700"
|
||
disabled={group.conditions.length === 1}
|
||
>
|
||
<X className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* 조건 추가 버튼 */}
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => addFilterCondition(group.id, visibleColumns[0]?.columnName)}
|
||
className="mt-2 h-7 text-xs"
|
||
>
|
||
+ 조건 추가
|
||
</Button>
|
||
</div>
|
||
))
|
||
)}
|
||
|
||
{/* 그룹 추가 버튼 */}
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => addFilterGroup(visibleColumns[0]?.columnName)}
|
||
className="w-full"
|
||
>
|
||
+ 필터 그룹 추가
|
||
</Button>
|
||
</div>
|
||
|
||
<DialogFooter className="gap-2 sm:gap-0">
|
||
<Button
|
||
variant="outline"
|
||
onClick={clearFilterBuilder}
|
||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||
>
|
||
초기화
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => setIsFilterBuilderOpen(false)}
|
||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||
>
|
||
취소
|
||
</Button>
|
||
<Button onClick={applyFilterBuilder} 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} />;
|
||
};
|