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