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

1832 lines
71 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { TableListConfig, ColumnConfig } from "./types";
import { WebType } from "@/types/common";
import { tableTypeApi } from "@/lib/api/screen";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { codeCache } from "@/lib/caching/codeCache";
// 전역 테이블 캐시
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);
}
}
};
// 주기적으로 캐시 정리 (10분마다)
if (typeof window !== "undefined") {
setInterval(cleanupTableCache, 10 * 60 * 1000);
}
// 요청 디바운싱을 위한 전역 타이머
const debounceTimers = new Map<string, NodeJS.Timeout>();
// 진행 중인 요청 추적 (중복 요청 방지)
const activeRequests = new Map<string, Promise<any>>();
// 디바운싱된 API 호출 함수 (중복 요청 방지 포함)
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) {
console.log(`🔄 진행 중인 요청 재사용: ${key}`);
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);
});
};
};
import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
RefreshCw,
ArrowUpDown,
ArrowUp,
ArrowDown,
TableIcon,
} from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import { AdvancedSearchFilters } from "@/components/screen/filters/AdvancedSearchFilters";
import { SingleTableWithSticky } from "./SingleTableWithSticky";
import { CardModeRenderer } from "./CardModeRenderer";
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;
// 추가 props (DOM에 전달되지 않음)
size?: { width: number; height: number };
position?: { x: number; y: number; z?: number };
componentConfig?: any;
selectedScreen?: any;
onZoneComponentDrop?: any;
onZoneClick?: any;
tableName?: string;
onRefresh?: () => void;
onClose?: () => void;
screenId?: string;
// 선택된 행 정보 전달 핸들러
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void;
// 설정 변경 핸들러 (상세설정과 연동)
onConfigChange?: (config: any) => void;
// 테이블 새로고침 키
refreshKey?: number;
}
/**
* TableList 컴포넌트
* 데이터베이스 테이블의 데이터를 목록으로 표시하는 컴포넌트
*/
export const TableListComponent: React.FC<TableListComponentProps> = ({
component,
isDesignMode = false,
isSelected = false,
onClick,
onDragStart,
onDragEnd,
config,
className,
style,
onFormDataChange,
componentConfig,
onSelectedRowsChange,
onConfigChange,
refreshKey,
tableName, // 화면의 기본 테이블명 (screenInfo에서 전달)
}) => {
// 컴포넌트 설정
const tableConfig = {
...config,
...component.config,
...componentConfig,
// selectedTable이 없으면 화면의 기본 테이블 사용
selectedTable:
componentConfig?.selectedTable || component.config?.selectedTable || config?.selectedTable || tableName,
} as TableListConfig;
console.log("🔍 TableListComponent 초기화:", {
componentConfigSelectedTable: componentConfig?.selectedTable,
componentConfigSelectedTable2: component.config?.selectedTable,
configSelectedTable: config?.selectedTable,
screenTableName: tableName,
finalSelectedTable: tableConfig.selectedTable,
});
// 🎨 동적 색상 설정 (속성편집 모달의 "색상" 필드와 연동)
const buttonColor = component.style?.labelColor || "#212121"; // 기본 파란색
const buttonTextColor = component.config?.buttonTextColor || "#ffffff";
const buttonStyle = {
backgroundColor: buttonColor,
color: buttonTextColor,
borderColor: buttonColor,
};
// 디버깅 로그 제거 (성능상 이유로)
// 상태 관리
const [data, setData] = useState<Record<string, any>[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [totalItems, setTotalItems] = useState(0);
const [searchTerm, setSearchTerm] = useState("");
const [sortColumn, setSortColumn] = useState<string | null>(null);
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({});
const [tableLabel, setTableLabel] = useState<string>("");
const [localPageSize, setLocalPageSize] = useState<number>(tableConfig.pagination?.pageSize || 20); // 로컬 페이지 크기 상태
const [displayColumns, setDisplayColumns] = useState<ColumnConfig[]>([]); // 🎯 표시할 컬럼 (Entity 조인 적용됨)
// 🎯 조인 컬럼 매핑 상태
const [joinColumnMapping, setJoinColumnMapping] = useState<Record<string, string>>({});
const [columnMeta, setColumnMeta] = useState<Record<string, { webType?: string; codeCategory?: string }>>({}); // 🎯 컬럼 메타정보 (웹타입, 코드카테고리)
// 컬럼 정보 메모이제이션
const memoizedColumnInfo = useMemo(() => {
return {
labels: columnLabels,
meta: columnMeta,
visibleColumns: (tableConfig.columns || []).filter((col) => col.visible !== false),
};
}, [columnLabels, columnMeta, tableConfig.columns]);
// 고급 필터 관련 state
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
// 체크박스 상태 관리
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
// 드래그 상태 관리
const [isDragging, setIsDragging] = useState(false);
const [draggedRowIndex, setDraggedRowIndex] = useState<number | null>(null); // 선택된 행들의 키 집합
const [isAllSelected, setIsAllSelected] = useState(false); // 전체 선택 상태
// 🎯 Entity 조인 최적화 훅 사용
const { optimizedConvertCode } = useEntityJoinOptimization(columnMeta, {
enableBatchLoading: true,
preloadCommonCodes: true,
maxBatchSize: 5,
});
// 높이 계산 함수 (메모이제이션)
const optimalHeight = useMemo(() => {
// 실제 데이터 개수에 맞춰서 높이 계산 (최소 5개, 최대 20개)
const actualDataCount = data.length;
const displayPageSize = Math.min(Math.max(actualDataCount, 5), 20);
const headerHeight = 50; // 테이블 헤더
const rowHeight = 42; // 각 행 높이
const searchHeight = tableConfig.filter?.enabled ? 80 : 0; // 검색 영역
const footerHeight = tableConfig.showFooter ? 60 : 0; // 페이지네이션
const titleHeight = tableConfig.showHeader ? 60 : 0; // 제목 영역
const padding = 40; // 여백
const calculatedHeight =
titleHeight + searchHeight + headerHeight + displayPageSize * rowHeight + footerHeight + padding;
console.log("🔍 테이블 높이 계산:", {
actualDataCount,
localPageSize,
displayPageSize,
isDesignMode,
titleHeight,
searchHeight,
headerHeight,
rowHeight,
footerHeight,
padding,
calculatedHeight,
finalHeight: `${calculatedHeight}px`,
});
// 추가 디버깅: 실제 데이터 상황
console.log("🔍 실제 데이터 상황:", {
actualDataLength: data.length,
localPageSize,
currentPage,
totalItems,
totalPages,
});
return calculatedHeight;
}, []);
// 🎯 강제로 그리드 컬럼수에 맞는 크기 적용 (디자인 모드에서는 더 큰 크기 허용)
const gridColumns = component.gridColumns || 1;
let calculatedWidth: string;
if (isDesignMode) {
// 디자인 모드에서는 더 큰 최소 크기 적용
if (gridColumns === 1) {
calculatedWidth = "400px"; // 1컬럼일 때 400px (디자인 모드)
} else if (gridColumns === 2) {
calculatedWidth = "600px"; // 2컬럼일 때 600px (디자인 모드)
} else if (gridColumns <= 6) {
calculatedWidth = `${gridColumns * 250}px`; // 컬럼당 250px (디자인 모드)
} else {
calculatedWidth = "100%"; // 7컬럼 이상은 전체
}
} else {
// 일반 모드는 기존 크기 유지
if (gridColumns === 1) {
calculatedWidth = "200px"; // 1컬럼일 때 200px 고정
} else if (gridColumns === 2) {
calculatedWidth = "400px"; // 2컬럼일 때 400px
} else if (gridColumns <= 6) {
calculatedWidth = `${gridColumns * 200}px`; // 컬럼당 200px
} else {
calculatedWidth = "100%"; // 7컬럼 이상은 전체
}
}
// 디버깅 로그 제거 (성능상 이유로)
// 스타일 계산 (컨테이너에 맞춤)
const componentStyle: React.CSSProperties = {
width: "100%", // 컨테이너 전체 너비 사용
maxWidth: "100%", // 최대 너비 제한
height: "auto", // 항상 자동 높이로 테이블 크기에 맞춤
minHeight: isDesignMode ? "200px" : "300px", // 최소 높이만 보장
maxHeight: isDesignMode ? "600px" : "800px", // 최대 높이 제한으로 스크롤 활성화
...component.style,
...style,
display: "flex",
flexDirection: "column",
boxSizing: "border-box", // 패딩/보더 포함한 크기 계산
// overflow는 CSS 클래스로 처리
};
// 🎯 tableContainerStyle 제거 - componentStyle만 사용
// 디자인 모드 스타일
if (isDesignMode) {
componentStyle.border = "2px dashed #cbd5e1";
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
componentStyle.borderRadius = "8px";
componentStyle.padding = "4px"; // 약간의 패딩으로 구분감 확보
componentStyle.margin = "2px"; // 외부 여백으로 레이아웃과 구분
// 🎯 컨테이너에 맞춤
componentStyle.width = "calc(100% - 12px)"; // margin + padding 보정
componentStyle.maxWidth = "calc(100% - 12px)";
componentStyle.minWidth = "calc(100% - 12px)";
componentStyle.overflow = "hidden !important"; // 넘치는 부분 숨김 (강제)
componentStyle.boxSizing = "border-box"; // 패딩 포함 크기 계산
componentStyle.position = "relative"; // 위치 고정
// 자동 높이로 테이블 전체를 감쌈
}
// 컬럼 라벨 정보 가져오기 (캐싱 적용)
const fetchColumnLabels = async () => {
if (!tableConfig.selectedTable) return;
// 캐시 확인
const cacheKey = tableConfig.selectedTable;
const cached = tableColumnCache.get(cacheKey);
const now = Date.now();
let columns: any[] = [];
if (cached && now - cached.timestamp < TABLE_CACHE_TTL) {
console.log(`🚀 테이블 컬럼 캐시 사용: ${cacheKey}`);
columns = cached.columns;
} else {
try {
console.log(`🔄 테이블 컬럼 API 호출: ${cacheKey}`);
const response = await tableTypeApi.getColumns(tableConfig.selectedTable);
// API 응답 구조 확인 및 컬럼 배열 추출
columns = Array.isArray(response) ? response : (response as any).columns || [];
// 캐시 저장
tableColumnCache.set(cacheKey, { columns, timestamp: now });
console.log(`✅ 테이블 컬럼 캐시 저장: ${cacheKey} (${columns.length}개 컬럼)`);
} catch (error) {
console.log("컬럼 라벨 정보를 가져올 수 없습니다:", error);
return;
}
}
const labels: Record<string, string> = {};
const meta: Record<string, { webType?: string; codeCategory?: string }> = {};
columns.forEach((column: any) => {
// 🎯 Entity 조인된 컬럼의 경우 표시 컬럼명 사용
let displayLabel = column.displayName || column.columnName;
// Entity 타입인 경우
if (column.webType === "entity") {
// 우선 기준 테이블의 컬럼 라벨을 사용
displayLabel = column.displayName || column.columnName;
console.log(`🎯 Entity 조인 컬럼 라벨 설정: ${column.columnName} → "${displayLabel}" (기준 테이블 라벨 사용)`);
}
labels[column.columnName] = displayLabel;
// 🎯 웹타입과 코드카테고리 정보 저장
meta[column.columnName] = {
webType: column.webType,
codeCategory: column.codeCategory,
};
});
setColumnLabels(labels);
setColumnMeta(meta);
console.log("🔍 컬럼 라벨 설정 완료:", labels);
console.log("🔍 컬럼 메타정보 설정 완료:", meta);
};
// 🎯 전역 코드 캐시 사용으로 함수 제거 (codeCache.convertCodeToName 사용)
// 테이블 라벨명 가져오기 (캐싱 적용)
const fetchTableLabel = async () => {
if (!tableConfig.selectedTable) return;
// 캐시 확인
const cacheKey = "all_tables";
const cached = tableInfoCache.get(cacheKey);
const now = Date.now();
let tables: any[] = [];
if (cached && now - cached.timestamp < TABLE_CACHE_TTL) {
console.log(`🚀 테이블 정보 캐시 사용: ${cacheKey}`);
tables = cached.tables;
} else {
try {
console.log(`🔄 테이블 정보 API 호출: ${cacheKey}`);
tables = await tableTypeApi.getTables();
// 캐시 저장
tableInfoCache.set(cacheKey, { tables, timestamp: now });
console.log(`✅ 테이블 정보 캐시 저장: ${cacheKey} (${tables.length}개 테이블)`);
} catch (error) {
console.log("테이블 라벨 정보를 가져올 수 없습니다:", error);
setTableLabel(tableConfig.selectedTable);
return;
}
}
const table = tables.find((t: any) => t.tableName === tableConfig.selectedTable);
if (table && table.displayName && table.displayName !== table.tableName) {
setTableLabel(table.displayName);
} else {
setTableLabel(tableConfig.selectedTable);
}
};
// 실제 테이블 데이터 가져오기 함수
const fetchTableDataInternal = useCallback(async () => {
if (!tableConfig.selectedTable) {
setData([]);
return;
}
setLoading(true);
setError(null);
try {
// 🎯 Entity 조인 API 사용 - Entity 조인이 포함된 데이터 조회
// Entity 조인 컬럼 추출 (isEntityJoin === true인 컬럼들)
const entityJoinColumns = tableConfig.columns?.filter((col) => col.isEntityJoin && col.entityJoinInfo) || [];
// 🎯 조인 탭에서 추가한 컬럼들 추출 (additionalJoinInfo가 있는 컬럼들)
const manualJoinColumns =
tableConfig.columns?.filter((col) => {
return col.additionalJoinInfo !== undefined;
}) || [];
console.log(
"🔗 수동 조인 컬럼 감지:",
manualJoinColumns.map((c) => ({
columnName: c.columnName,
additionalJoinInfo: c.additionalJoinInfo,
})),
);
// 🎯 추가 조인 컬럼 정보 구성
const additionalJoinColumns: Array<{
sourceTable: string;
sourceColumn: string;
joinAlias: string;
referenceTable?: string;
}> = [];
// Entity 조인 컬럼들
entityJoinColumns.forEach((col) => {
additionalJoinColumns.push({
sourceTable: col.entityJoinInfo!.sourceTable,
sourceColumn: col.entityJoinInfo!.sourceColumn,
joinAlias: col.entityJoinInfo!.joinAlias,
});
});
// 수동 조인 컬럼들 - 저장된 조인 정보 사용
manualJoinColumns.forEach((col) => {
if (col.additionalJoinInfo) {
additionalJoinColumns.push({
sourceTable: col.additionalJoinInfo.sourceTable,
sourceColumn: col.additionalJoinInfo.sourceColumn,
joinAlias: col.additionalJoinInfo.joinAlias,
referenceTable: col.additionalJoinInfo.referenceTable,
});
}
});
console.log("🔗 최종 추가 조인 컬럼:", additionalJoinColumns);
const result = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
page: currentPage,
size: localPageSize,
search: (() => {
// 고급 필터 값이 있으면 우선 사용
const hasAdvancedFilters = Object.values(searchValues).some((value) => {
if (typeof value === "string") return value.trim() !== "";
if (typeof value === "object" && value !== null) {
return Object.values(value).some((v) => v !== "" && v !== null && v !== undefined);
}
return value !== null && value !== undefined && value !== "";
});
if (hasAdvancedFilters) {
console.log("🔍 고급 검색 필터 사용:", searchValues);
console.log("🔍 고급 검색 필터 상세:", JSON.stringify(searchValues, null, 2));
return searchValues;
}
// 고급 필터가 없으면 기존 단순 검색 사용
if (searchTerm?.trim()) {
// 스마트한 단일 컬럼 선택 로직 (서버가 OR 검색을 지원하지 않음)
let searchColumn = sortColumn; // 정렬된 컬럼 우선
if (!searchColumn) {
// 1순위: name 관련 컬럼 (가장 검색에 적합)
const nameColumns = visibleColumns.filter(
(col) =>
col.columnName.toLowerCase().includes("name") ||
col.columnName.toLowerCase().includes("title") ||
col.columnName.toLowerCase().includes("subject"),
);
// 2순위: text/varchar 타입 컬럼
const textColumns = visibleColumns.filter((col) => col.dataType === "text" || col.dataType === "varchar");
// 3순위: description 관련 컬럼
const descColumns = visibleColumns.filter(
(col) =>
col.columnName.toLowerCase().includes("desc") ||
col.columnName.toLowerCase().includes("comment") ||
col.columnName.toLowerCase().includes("memo"),
);
// 우선순위에 따라 선택
if (nameColumns.length > 0) {
searchColumn = nameColumns[0].columnName;
} else if (textColumns.length > 0) {
searchColumn = textColumns[0].columnName;
} else if (descColumns.length > 0) {
searchColumn = descColumns[0].columnName;
} else {
// 마지막 대안: 첫 번째 컬럼
searchColumn = visibleColumns[0]?.columnName || "id";
}
}
console.log("🔍 기존 검색 방식 사용:", { [searchColumn]: searchTerm });
return { [searchColumn]: searchTerm };
}
return undefined;
})(),
sortBy: sortColumn || undefined,
sortOrder: sortDirection,
enableEntityJoin: true, // 🎯 Entity 조인 활성화
additionalJoinColumns: additionalJoinColumns.length > 0 ? additionalJoinColumns : undefined, // 추가 조인 컬럼
});
if (result) {
// console.log("🎯 API 응답 결과:", result);
// console.log("🎯 데이터 개수:", result.data?.length || 0);
// console.log("🎯 전체 페이지:", result.totalPages);
// console.log("🎯 총 아이템:", result.total);
setData(result.data || []);
setTotalPages(result.totalPages || 1);
setTotalItems(result.total || 0);
// 🎯 Entity 조인 정보 로깅
if (result.entityJoinInfo) {
console.log("🔗 Entity 조인 적용됨:", {
strategy: result.entityJoinInfo.strategy,
joinConfigs: result.entityJoinInfo.joinConfigs,
performance: result.entityJoinInfo.performance,
});
} else {
console.log("🔗 Entity 조인 없음");
}
// 🎯 코드 컬럼들의 캐시 미리 로드 (전역 캐시 사용)
const codeColumns = Object.entries(columnMeta).filter(
([_, meta]) => meta.webType === "code" && meta.codeCategory,
);
if (codeColumns.length > 0) {
console.log(
"📋 코드 컬럼 감지:",
codeColumns.map(([col, meta]) => `${col}(${meta.codeCategory})`),
);
// 필요한 코드 카테고리들을 추출하여 배치 로드 (중복 제거)
const categoryList = [
...new Set(codeColumns.map(([, meta]) => meta.codeCategory).filter(Boolean)),
] as string[];
// 이미 캐시된 카테고리는 제외
const uncachedCategories = categoryList.filter((category) => !codeCache.getCodeSync(category));
if (uncachedCategories.length > 0) {
try {
console.log(`📋 새로운 코드 카테고리 로딩: ${uncachedCategories.join(", ")}`);
await codeCache.preloadCodes(uncachedCategories);
console.log("📋 모든 코드 캐시 로드 완료 (전역 캐시)");
} catch (error) {
console.error("❌ 코드 캐시 로드 중 오류:", error);
}
} else {
console.log("📋 모든 코드 카테고리가 이미 캐시됨");
}
}
// 🎯 Entity 조인된 컬럼 처리 - 사용자가 설정한 컬럼들만 사용
let processedColumns = [...(tableConfig.columns || [])];
// 초기 컬럼이 있으면 먼저 설정
if (processedColumns.length > 0) {
console.log(
"🔍 사용자 설정 컬럼들:",
processedColumns.map((c) => c.columnName),
);
// 🎯 API 응답과 비교하여 존재하지 않는 컬럼 필터링
if (result.data.length > 0) {
const actualApiColumns = Object.keys(result.data[0]);
console.log("🔍 API 응답의 실제 컬럼들:", actualApiColumns);
// 🎯 조인 컬럼 매핑 테이블 - 동적 생성
// API 응답에 실제로 존재하는 컬럼과 사용자 설정 컬럼을 비교하여 자동 매핑
const newJoinColumnMapping: Record<string, string> = {};
processedColumns.forEach((col) => {
// API 응답에 정확히 일치하는 컬럼이 있으면 그대로 사용
if (actualApiColumns.includes(col.columnName)) {
newJoinColumnMapping[col.columnName] = col.columnName;
}
});
// 🎯 조인 컬럼 매핑 상태 업데이트
setJoinColumnMapping(newJoinColumnMapping);
console.log("🔍 조인 컬럼 매핑 테이블:", newJoinColumnMapping);
console.log("🔍 실제 API 응답 컬럼들:", actualApiColumns);
// 🎯 컬럼명 매핑 및 유효성 검사
const validColumns = processedColumns
.map((col) => {
// 체크박스는 그대로 유지
if (col.columnName === "__checkbox__") return col;
// 조인 컬럼 매핑 적용
const mappedColumnName = newJoinColumnMapping[col.columnName] || col.columnName;
console.log(`🔍 컬럼 매핑 처리: ${col.columnName}${mappedColumnName}`);
// API 응답에 존재하는지 확인
const existsInApi = actualApiColumns.includes(mappedColumnName);
if (!existsInApi) {
console.log(`🔍 제거될 컬럼: ${col.columnName}${mappedColumnName} (API에 존재하지 않음)`);
return null;
}
// 컬럼명이 변경된 경우 업데이트
if (mappedColumnName !== col.columnName) {
console.log(`🔄 컬럼명 매핑: ${col.columnName}${mappedColumnName}`);
return {
...col,
columnName: mappedColumnName,
};
}
console.log(`✅ 컬럼 유지: ${col.columnName}`);
return col;
})
.filter((col) => col !== null) as ColumnConfig[];
if (validColumns.length !== processedColumns.length) {
console.log(
"🔍 필터링된 컬럼들:",
validColumns.map((c) => c.columnName),
);
console.log(
"🔍 제거된 컬럼들:",
processedColumns
.filter((col) => {
const mappedName = newJoinColumnMapping[col.columnName] || col.columnName;
return !actualApiColumns.includes(mappedName) && col.columnName !== "__checkbox__";
})
.map((c) => c.columnName),
);
processedColumns = validColumns;
}
}
}
if (result.entityJoinInfo?.joinConfigs) {
result.entityJoinInfo.joinConfigs.forEach((joinConfig) => {
// 원본 컬럼을 조인된 컬럼으로 교체
const originalColumnIndex = processedColumns.findIndex((col) => col.columnName === joinConfig.sourceColumn);
if (originalColumnIndex !== -1) {
console.log(`🔄 컬럼 교체: ${joinConfig.sourceColumn}${joinConfig.aliasColumn}`);
const originalColumn = processedColumns[originalColumnIndex];
processedColumns[originalColumnIndex] = {
...originalColumn,
columnName: joinConfig.aliasColumn, // dept_code → dept_code_name
displayName:
columnLabels[originalColumn.columnName] || originalColumn.displayName || originalColumn.columnName, // 올바른 라벨 사용
// isEntityJoined: true, // 🎯 임시 주석 처리 (타입 에러 해결 후 복원)
} as ColumnConfig;
console.log(
`✅ 조인 컬럼 라벨 유지: ${joinConfig.sourceColumn} → "${columnLabels[originalColumn.columnName] || originalColumn.displayName || originalColumn.columnName}"`,
);
}
});
}
// 🎯 컬럼 설정이 없으면 API 응답 기반으로 생성
if ((!processedColumns || processedColumns.length === 0) && result.data.length > 0) {
const autoColumns: ColumnConfig[] = Object.keys(result.data[0]).map((key, index) => ({
columnName: key,
displayName: columnLabels[key] || key,
visible: true,
sortable: true,
searchable: true,
align: "left",
format: "text",
order: index,
}));
console.log(
"🎯 자동 생성된 컬럼들:",
autoColumns.map((c) => c.columnName),
);
// 컴포넌트 설정 업데이트 (부모 컴포넌트에 알림)
if (onFormDataChange) {
onFormDataChange({
...component,
config: {
...tableConfig,
columns: autoColumns,
},
});
}
processedColumns = autoColumns;
}
// 🎯 표시할 컬럼 상태 업데이트
setDisplayColumns(processedColumns);
// console.log("🎯 displayColumns 업데이트됨:", processedColumns);
// console.log("🎯 데이터 개수:", result.data?.length || 0);
// console.log("🎯 전체 데이터:", result.data);
}
} catch (err) {
console.error("테이블 데이터 로딩 오류:", err);
setError(err instanceof Error ? err.message : "데이터를 불러오는 중 오류가 발생했습니다.");
setData([]);
} finally {
setLoading(false);
}
}, [
tableConfig.selectedTable,
tableConfig.columns,
currentPage,
localPageSize,
searchTerm,
sortColumn,
sortDirection,
searchValues,
]);
// 디바운싱된 테이블 데이터 가져오기
const fetchTableDataDebounced = useCallback(
debouncedApiCall(
`fetchTableData_${tableConfig.selectedTable}_${currentPage}_${localPageSize}`,
async () => {
return fetchTableDataInternal();
},
200, // 200ms 디바운스
),
[
tableConfig.selectedTable,
currentPage,
localPageSize,
searchTerm,
sortColumn,
sortDirection,
searchValues,
fetchTableDataInternal,
],
);
// 페이지 변경
const handlePageChange = (newPage: number) => {
setCurrentPage(newPage);
// 상세설정에 현재 페이지 정보 알림 (필요한 경우)
if (onConfigChange && tableConfig.pagination) {
// console.log("📤 테이블에서 페이지 변경을 상세설정에 알림:", newPage);
onConfigChange({
...tableConfig,
pagination: {
...tableConfig.pagination,
currentPage: newPage, // 현재 페이지 정보 추가
},
});
} else if (!onConfigChange) {
// console.warn("⚠️ onConfigChange가 정의되지 않음 - 페이지 변경 상세설정과 연동 불가");
}
};
// 정렬 변경
const handleSort = (column: string) => {
if (sortColumn === column) {
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
} else {
setSortColumn(column);
setSortDirection("asc");
}
};
// 고급 필터 핸들러
const handleSearchValueChange = (columnName: string, value: any) => {
setSearchValues((prev) => ({
...prev,
[columnName]: value,
}));
};
const handleAdvancedSearch = () => {
setCurrentPage(1);
fetchTableDataDebounced();
};
const handleClearAdvancedFilters = () => {
setSearchValues({});
setCurrentPage(1);
fetchTableDataDebounced();
};
// 새로고침
const handleRefresh = () => {
fetchTableDataDebounced();
};
// 체크박스 핸들러들
const getRowKey = (row: any, index: number) => {
// 기본키가 있으면 사용, 없으면 인덱스 사용
return row.id || row.objid || row.pk || index.toString();
};
const handleRowSelection = (rowKey: string, checked: boolean) => {
const newSelectedRows = new Set(selectedRows);
if (checked) {
newSelectedRows.add(rowKey);
} else {
newSelectedRows.delete(rowKey);
}
setSelectedRows(newSelectedRows);
setIsAllSelected(newSelectedRows.size === data.length && data.length > 0);
// 선택된 실제 데이터를 상위 컴포넌트로 전달
const selectedKeys = Array.from(newSelectedRows);
const selectedData = selectedKeys
.map((key) => {
// rowKey를 사용하여 데이터 찾기 (ID 기반 또는 인덱스 기반)
return data.find((row, index) => {
const currentRowKey = getRowKey(row, index);
return currentRowKey === key;
});
})
.filter(Boolean);
console.log("🔍 handleRowSelection 디버그:", {
rowKey,
checked,
selectedKeys,
selectedData,
dataCount: data.length,
});
onSelectedRowsChange?.(selectedKeys, selectedData);
if (tableConfig.onSelectionChange) {
tableConfig.onSelectionChange(selectedData);
}
};
const handleSelectAll = (checked: boolean) => {
if (checked) {
const allKeys = data.map((row, index) => getRowKey(row, index));
setSelectedRows(new Set(allKeys));
setIsAllSelected(true);
// 선택된 실제 데이터를 상위 컴포넌트로 전달
onSelectedRowsChange?.(allKeys, data);
if (tableConfig.onSelectionChange) {
tableConfig.onSelectionChange(data);
}
} else {
setSelectedRows(new Set());
setIsAllSelected(false);
// 빈 선택을 상위 컴포넌트로 전달
onSelectedRowsChange?.([], []);
if (tableConfig.onSelectionChange) {
tableConfig.onSelectionChange([]);
}
}
};
// 효과
useEffect(() => {
if (tableConfig.selectedTable) {
fetchColumnLabels();
fetchTableLabel();
}
}, [tableConfig.selectedTable]);
// 컬럼 라벨이 로드되면 기존 컬럼의 displayName을 업데이트
useEffect(() => {
if (Object.keys(columnLabels).length > 0 && tableConfig.columns && tableConfig.columns.length > 0) {
const updatedColumns = tableConfig.columns.map((col) => ({
...col,
displayName: columnLabels[col.columnName] || col.displayName,
}));
// 부모 컴포넌트에 업데이트된 컬럼 정보 전달
if (onFormDataChange) {
onFormDataChange({
...component,
componentConfig: {
...tableConfig,
columns: updatedColumns,
},
});
}
}
}, [columnLabels]);
// 🎯 컬럼 개수와 컬럼명을 문자열로 변환하여 의존성 추적
const columnsKey = useMemo(() => {
if (!tableConfig.columns) return "";
return tableConfig.columns.map((col) => col.columnName).join(",");
}, [tableConfig.columns]);
useEffect(() => {
// autoLoad가 undefined거나 true일 때 자동 로드 (기본값: true)
const shouldAutoLoad = tableConfig.autoLoad !== false;
console.log("🔍 TableList 데이터 로드 조건 체크:", {
shouldAutoLoad,
isDesignMode,
selectedTable: tableConfig.selectedTable,
autoLoadSetting: tableConfig.autoLoad,
willLoad: shouldAutoLoad && !isDesignMode,
});
if (shouldAutoLoad && !isDesignMode) {
console.log("✅ 테이블 데이터 로드 시작:", tableConfig.selectedTable);
fetchTableDataInternal();
} else {
console.warn("⚠️ 테이블 데이터 로드 차단:", {
reason: !shouldAutoLoad ? "autoLoad=false" : "isDesignMode=true",
shouldAutoLoad,
isDesignMode,
});
}
}, [
tableConfig.selectedTable,
columnsKey, // 🎯 컬럼이 추가/변경될 때 데이터 다시 로드 (문자열 비교)
localPageSize,
currentPage,
searchTerm,
sortColumn,
sortDirection,
columnLabels,
searchValues,
fetchTableDataInternal, // 의존성 배열에 추가
]);
// refreshKey 변경 시 테이블 데이터 새로고침
useEffect(() => {
if (refreshKey && refreshKey > 0 && !isDesignMode) {
console.log("🔄 refreshKey 변경 감지, 테이블 데이터 새로고침:", refreshKey);
// 선택된 행 상태 초기화
setSelectedRows(new Set());
setIsAllSelected(false);
// 부모 컴포넌트에 빈 선택 상태 전달
console.log("🔄 선택 상태 초기화 - 빈 배열 전달");
onSelectedRowsChange?.([], []);
// 테이블 데이터 새로고침
fetchTableDataDebounced();
}
}, [refreshKey]);
// 🆕 전역 테이블 새로고침 이벤트 리스너
useEffect(() => {
const handleRefreshTable = () => {
console.log("🔄 TableListComponent: 전역 새로고침 이벤트 수신");
if (tableConfig.selectedTable && !isDesignMode) {
// 선택된 행 상태 초기화
setSelectedRows(new Set());
setIsAllSelected(false);
onSelectedRowsChange?.([], []);
// 테이블 데이터 새로고침
fetchTableDataDebounced();
}
};
window.addEventListener("refreshTable", handleRefreshTable);
return () => {
window.removeEventListener("refreshTable", handleRefreshTable);
};
}, [tableConfig.selectedTable, isDesignMode, fetchTableDataDebounced, onSelectedRowsChange]);
// 상세설정에서 페이지네이션 설정 변경 시 로컬 상태 동기화
useEffect(() => {
// 페이지 크기 동기화
if (tableConfig.pagination?.pageSize && tableConfig.pagination.pageSize !== localPageSize) {
// console.log("🔄 상세설정에서 페이지 크기 변경 감지:", tableConfig.pagination.pageSize);
setLocalPageSize(tableConfig.pagination.pageSize);
setCurrentPage(1); // 페이지를 1로 리셋
}
// 현재 페이지 동기화 (상세설정에서 페이지를 직접 변경한 경우)
if (tableConfig.pagination?.currentPage && tableConfig.pagination.currentPage !== currentPage) {
// console.log("🔄 상세설정에서 현재 페이지 변경 감지:", tableConfig.pagination.currentPage);
setCurrentPage(tableConfig.pagination.currentPage);
}
}, [tableConfig.pagination?.pageSize, tableConfig.pagination?.currentPage]);
// 표시할 컬럼 계산 (Entity 조인 적용됨 + 체크박스 컬럼 추가 + 숨김 기능)
const visibleColumns = useMemo(() => {
// 기본값 처리: checkbox 설정이 없으면 기본값 사용
const checkboxConfig = tableConfig.checkbox || {
enabled: true,
multiple: true,
position: "left",
selectAll: true,
};
let columns: ColumnConfig[] = [];
// displayColumns가 있으면 우선 사용 (Entity 조인 적용된 컬럼들)
if (displayColumns && displayColumns.length > 0) {
// 디버깅 로그 제거 (성능상 이유로)
const filteredColumns = displayColumns.filter((col) => {
// 디자인 모드에서는 숨김 컬럼도 표시 (연하게), 실제 화면에서는 완전히 숨김
if (isDesignMode) {
return col.visible; // 디자인 모드에서는 visible만 체크
} else {
return col.visible && !col.hidden; // 실제 화면에서는 visible이면서 hidden이 아닌 것만
}
});
// 디버깅 로그 제거 (성능상 이유로)
columns = filteredColumns.sort((a, b) => a.order - b.order);
} else if (tableConfig.columns && tableConfig.columns.length > 0) {
// displayColumns가 없으면 기본 컬럼 사용
// 디버깅 로그 제거 (성능상 이유로)
columns = tableConfig.columns
.filter((col) => {
// 디자인 모드에서는 숨김 컬럼도 표시 (연하게), 실제 화면에서는 완전히 숨김
if (isDesignMode) {
return col.visible; // 디자인 모드에서는 visible만 체크
} else {
return col.visible && !col.hidden; // 실제 화면에서는 visible이면서 hidden이 아닌 것만
}
})
.sort((a, b) => a.order - b.order);
} else {
// console.log("🎯 사용할 컬럼이 없음");
return [];
}
// 체크박스가 활성화되고 실제 데이터 컬럼이 있는 경우에만 체크박스 컬럼을 추가
if (checkboxConfig.enabled && columns.length > 0) {
const checkboxColumn: ColumnConfig = {
columnName: "__checkbox__",
displayName: "",
visible: true,
sortable: false,
searchable: false,
width: 50,
align: "center",
order: -1, // 가장 앞에 위치
fixed: checkboxConfig.position === "left" ? "left" : false,
fixedOrder: 0, // 가장 앞에 고정
};
// 체크박스 위치에 따라 추가
if (checkboxConfig.position === "left") {
columns.unshift(checkboxColumn);
} else {
columns.push(checkboxColumn);
}
}
// 디버깅 로그 제거 (성능상 이유로)
return columns;
}, [displayColumns, tableConfig.columns, tableConfig.checkbox, isDesignMode]);
// columnsByPosition은 SingleTableWithSticky에서 사용하지 않으므로 제거
// 기존 테이블에서만 필요한 경우 다시 추가 가능
// 가로 스크롤이 필요한지 계산
const needsHorizontalScroll = useMemo(() => {
if (!tableConfig.horizontalScroll?.enabled) {
console.log("🚫 가로 스크롤 비활성화됨");
return false;
}
const maxVisible = tableConfig.horizontalScroll.maxVisibleColumns || 8;
const totalColumns = visibleColumns.length;
const result = totalColumns > maxVisible;
console.log(
`🔍 가로 스크롤 계산: ${totalColumns}개 컬럼 > ${maxVisible}개 최대 = ${result ? "스크롤 필요" : "스크롤 불필요"}`,
);
console.log("📊 가로 스크롤 설정:", tableConfig.horizontalScroll);
console.log(
"📋 현재 컬럼들:",
visibleColumns.map((c) => c.columnName),
);
return result;
}, [visibleColumns.length, tableConfig.horizontalScroll]);
// 컬럼 너비 계산 - 내용 길이에 맞게 자동 조정
const getColumnWidth = (column: ColumnConfig) => {
if (column.width) return column.width;
// 체크박스 컬럼인 경우 고정 너비
if (column.columnName === "__checkbox__") {
return 50;
}
// 컬럼 헤더 텍스트 길이 기반으로 계산
const headerText = columnLabels[column.columnName] || column.displayName || column.columnName;
const headerLength = headerText.length;
// 데이터 셀의 최대 길이 추정 (실제 데이터가 있다면 더 정확하게 계산 가능)
const estimatedContentLength = Math.max(headerLength, 10); // 최소 10자
// 문자당 약 8px 정도로 계산하고, 패딩 및 여백 고려
const calculatedWidth = estimatedContentLength * 8 + 40; // 40px는 패딩과 여백
// 최소 너비만 보장하고, 최대 너비 제한은 제거
const minWidth = 80;
return Math.max(minWidth, calculatedWidth);
};
// 체크박스 헤더 렌더링
const renderCheckboxHeader = () => {
// 기본값 처리: checkbox 설정이 없으면 기본값 사용
const checkboxConfig = tableConfig.checkbox || {
enabled: true,
multiple: true,
position: "left",
selectAll: true,
};
if (!checkboxConfig.enabled || !checkboxConfig.selectAll) {
return null;
}
return (
<Checkbox
checked={isAllSelected}
onCheckedChange={handleSelectAll}
aria-label="전체 선택"
style={{ zIndex: 1 }}
/>
);
};
// 체크박스 셀 렌더링
const renderCheckboxCell = (row: any, index: number) => {
// 기본값 처리: checkbox 설정이 없으면 기본값 사용
const checkboxConfig = tableConfig.checkbox || {
enabled: true,
multiple: true,
position: "left",
selectAll: true,
};
if (!checkboxConfig.enabled) {
return null;
}
const rowKey = getRowKey(row, index);
const isSelected = selectedRows.has(rowKey);
return (
<Checkbox
checked={isSelected}
onCheckedChange={(checked) => handleRowSelection(rowKey, checked as boolean)}
aria-label={`${index + 1} 선택`}
style={{ zIndex: 1 }}
/>
);
};
// 🎯 값 포맷팅 (전역 코드 캐시 사용)
const formatCellValue = useMemo(() => {
return (value: any, format?: string, columnName?: string) => {
if (value === null || value === undefined) return "";
// 디버깅 로그 제거 (성능상 이유로)
// 🎯 코드 컬럼인 경우 최적화된 코드명 변환 사용
if (columnName && columnMeta[columnName]?.webType === "code" && columnMeta[columnName]?.codeCategory) {
const categoryCode = columnMeta[columnName].codeCategory!;
const convertedValue = optimizedConvertCode(categoryCode, String(value));
// 코드 변환 로그 제거 (성능상 이유로)
value = convertedValue;
}
switch (format) {
case "number":
return typeof value === "number" ? value.toLocaleString() : value;
case "currency":
return typeof value === "number" ? `${value.toLocaleString()}` : value;
case "date":
return value instanceof Date ? value.toLocaleDateString() : value;
case "boolean":
return value ? "예" : "아니오";
default:
return String(value);
}
};
}, [columnMeta, optimizedConvertCode]); // 최적화된 변환 함수 의존성 추가
// 이벤트 핸들러
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.();
};
// 행 클릭 핸들러
const handleRowClick = (row: any) => {
if (tableConfig.onRowClick) {
tableConfig.onRowClick(row);
}
};
// 드래그 핸들러 (그리드 스냅 지원)
const handleRowDragStart = (e: React.DragEvent, row: any, index: number) => {
setIsDragging(true);
setDraggedRowIndex(index);
// 드래그 데이터에 그리드 정보 포함
const dragData = {
...row,
_dragType: "table-row",
_gridSize: { width: 4, height: 1 }, // 기본 그리드 크기 (4칸 너비, 1칸 높이)
_snapToGrid: true,
};
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
e.dataTransfer.effectAllowed = "copy"; // move 대신 copy로 변경
// 드래그 이미지를 더 깔끔하게
const dragElement = e.currentTarget as HTMLElement;
// 커스텀 드래그 이미지 생성 (저장 버튼과 어울리는 스타일)
const dragImage = document.createElement("div");
dragImage.style.position = "absolute";
dragImage.style.top = "-1000px";
dragImage.style.left = "-1000px";
dragImage.style.background = "linear-gradient(135deg, #212121 0%, #000000 100%)";
dragImage.style.color = "white";
dragImage.style.padding = "12px 16px";
dragImage.style.borderRadius = "8px";
dragImage.style.fontSize = "14px";
dragImage.style.fontWeight = "600";
dragImage.style.boxShadow = "0 4px 12px rgba(59, 130, 246, 0.4)";
dragImage.style.display = "flex";
dragImage.style.alignItems = "center";
dragImage.style.gap = "8px";
dragImage.style.minWidth = "200px";
dragImage.style.whiteSpace = "nowrap";
// 아이콘과 텍스트 추가
const firstValue = Object.values(row)[0] || "Row";
dragImage.innerHTML = `
<div style="
width: 20px;
height: 20px;
background: rgba(255,255,255,0.2);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
">📋</div>
<span>${firstValue}</span>
<div style="
background: rgba(255,255,255,0.2);
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
">4×1</div>
`;
document.body.appendChild(dragImage);
e.dataTransfer.setDragImage(dragImage, 20, 20);
// 정리
setTimeout(() => {
if (document.body.contains(dragImage)) {
document.body.removeChild(dragImage);
}
}, 0);
};
const handleRowDragEnd = (e: React.DragEvent) => {
setIsDragging(false);
setDraggedRowIndex(null);
};
// DOM에 전달할 수 있는 기본 props만 정의
const domProps = {
onClick: handleClick,
onDragStart,
onDragEnd,
};
// 플레이스홀더 제거 - 디자인 모드에서도 바로 테이블 표시
return (
<div
style={{ ...componentStyle, zIndex: 10 }} // 🎯 componentStyle + z-index 추가
className={cn(
"relative overflow-hidden",
"border border-gray-200/60 bg-white",
"rounded-2xl shadow-sm",
"backdrop-blur-sm",
"transition-all duration-300 ease-out",
isSelected && "shadow-lg ring-2 shadow-blue-500/10 ring-blue-500/20",
className,
)}
{...domProps}
>
{/* 헤더 */}
{tableConfig.showHeader && (
<div
className="bg-muted/30 flex items-center justify-between border-b px-6 py-5"
style={{
width: "100%",
maxWidth: "100%",
boxSizing: "border-box",
}}
>
<div className="flex items-center space-x-4">
{(tableConfig.title || tableLabel) && (
<h3 className="text-xl font-semibold tracking-tight text-gray-800">{tableConfig.title || tableLabel}</h3>
)}
</div>
<div className="flex items-center space-x-3">
{/* 선택된 항목 정보 표시 */}
{selectedRows.size > 0 && (
<div className="flex items-center space-x-2 rounded-full bg-blue-50/80 px-4 py-2 backdrop-blur-sm">
<span className="text-sm font-medium text-blue-700">{selectedRows.size} </span>
</div>
)}
{/* 새로고침 */}
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={loading}
className="group relative rounded-xl shadow-sm transition-all duration-200 hover:shadow-md"
>
<div className="flex items-center space-x-2">
<div className="relative">
<RefreshCw className={cn("h-4 w-4 text-gray-600", loading && "animate-spin")} />
{loading && <div className="absolute -inset-1 animate-pulse rounded-full bg-blue-100/40"></div>}
</div>
<span className="text-sm font-medium text-gray-700">{loading ? "새로고침 중..." : "새로고침"}</span>
</div>
</Button>
</div>
</div>
)}
{/* 고급 검색 필터 - 항상 표시 (컬럼 정보 기반 자동 생성) */}
{tableConfig.filter?.enabled && visibleColumns && visibleColumns.length > 0 && (
<>
<div className="h-px bg-gradient-to-r from-transparent via-slate-200 to-transparent"></div>
<div className="bg-white p-4">
<AdvancedSearchFilters
filters={tableConfig.filter?.filters || []} // 설정된 필터 사용, 없으면 자동 생성
searchValues={searchValues}
onSearchValueChange={handleSearchValueChange}
onSearch={handleAdvancedSearch}
onClearFilters={handleClearAdvancedFilters}
tableColumns={visibleColumns.map((col) => ({
columnName: col.columnName,
widgetType: (columnMeta[col.columnName]?.webType || "text") as WebType,
displayName: columnLabels[col.columnName] || col.displayName || col.columnName,
codeCategory: columnMeta[col.columnName]?.codeCategory,
isVisible: col.visible,
// 추가 메타데이터 전달 (필터 자동 생성용)
web_type: (columnMeta[col.columnName]?.webType || "text") as WebType,
column_name: col.columnName,
column_label: columnLabels[col.columnName] || col.displayName || col.columnName,
code_category: columnMeta[col.columnName]?.codeCategory,
}))}
tableName={tableConfig.selectedTable}
/>
</div>
</>
)}
{/* 테이블 컨텐츠 */}
<div
className={`w-full flex-1 overflow-auto`}
style={{
width: "100%",
maxWidth: "100%",
boxSizing: "border-box",
}}
>
{loading ? (
<div className="flex h-full items-center justify-center bg-gradient-to-br from-slate-50/50 to-blue-50/30">
<div className="p-8 text-center">
<div className="relative">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-blue-100 to-indigo-100">
<RefreshCw className="h-8 w-8 animate-spin text-blue-600" />
</div>
<div className="absolute -top-1 -right-1 h-4 w-4 animate-pulse rounded-full bg-gradient-to-br from-blue-400 to-indigo-500"></div>
</div>
<div className="text-sm font-medium text-slate-700"> ...</div>
<div className="mt-1 text-xs text-slate-500"> </div>
</div>
</div>
) : error ? (
<div className="flex h-full items-center justify-center bg-gradient-to-br from-red-50/50 to-orange-50/30">
<div className="p-8 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-red-100 to-orange-100">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-red-500 to-orange-500">
<span className="text-sm font-bold text-white">!</span>
</div>
</div>
<div className="text-sm font-medium text-red-700"> </div>
<div className="mt-1 rounded-full bg-red-50 px-3 py-1 text-xs text-red-500">{error}</div>
</div>
</div>
) : tableConfig.displayMode === "card" ? (
// 카드 모드 렌더링
<div className="h-full w-full overflow-y-auto">
<CardModeRenderer
data={data}
cardConfig={
tableConfig.cardConfig || {
idColumn: "id",
titleColumn: "name",
cardsPerRow: 3,
cardSpacing: 16,
showActions: true,
}
}
visibleColumns={visibleColumns}
onRowClick={handleRowClick}
onRowSelect={(row, selected) => {
const rowIndex = data.findIndex((d) => d === row);
const rowKey = getRowKey(row, rowIndex);
handleRowSelection(rowKey, selected);
}}
selectedRows={Array.from(selectedRows)}
showActions={tableConfig.actions?.showActions}
/>
</div>
) : needsHorizontalScroll ? (
// 가로 스크롤이 필요한 경우 - 단일 테이블에서 sticky 컬럼 사용
<div className="w-full overflow-hidden">
<SingleTableWithSticky
visibleColumns={visibleColumns}
data={data}
columnLabels={columnLabels}
sortColumn={sortColumn}
sortDirection={sortDirection}
tableConfig={tableConfig}
isDesignMode={isDesignMode}
isAllSelected={isAllSelected}
handleSort={handleSort}
handleSelectAll={handleSelectAll}
handleRowClick={handleRowClick}
renderCheckboxCell={renderCheckboxCell}
formatCellValue={formatCellValue}
getColumnWidth={getColumnWidth}
containerWidth={calculatedWidth}
/>
</div>
) : (
// 기존 테이블 (가로 스크롤이 필요한 경우)
<div className="w-full overflow-x-auto overflow-y-auto">
<Table
className="w-full"
style={{
width: "100%",
minWidth: "100%",
tableLayout: "auto", // 테이블 크기 자동 조정
}}
>
<TableHeader
className={cn(
tableConfig.stickyHeader ? "sticky top-0 z-20" : "",
"border-b border-gray-200/40 bg-gradient-to-r from-slate-50/90 to-gray-50/70 backdrop-blur-sm",
)}
>
<TableRow
style={{
minHeight: "48px !important",
height: "48px !important",
lineHeight: "1",
width: "100%",
maxWidth: "100%",
}}
className="border-none"
>
{visibleColumns.map((column, colIndex) => (
<TableHead
key={column.columnName}
style={{
width: column.width ? `${column.width}px` : "150px", // 기본 너비 설정
minWidth: "100px", // 최소 너비 보장
maxWidth: "300px", // 최대 너비 제한
minHeight: "48px !important",
height: "48px !important",
verticalAlign: "middle",
lineHeight: "1",
boxSizing: "border-box",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap", // 텍스트 줄바꿈 방지
}}
className={cn(
"h-12 px-6 py-4 align-middle text-sm font-semibold text-gray-700",
"transition-colors duration-200 ease-out",
column.columnName === "__checkbox__"
? "text-center"
: "cursor-pointer whitespace-nowrap select-none hover:text-gray-900",
`text-${column.align}`,
column.sortable && "hover:bg-orange-200/70",
)}
onClick={() => column.sortable && handleSort(column.columnName)}
>
{column.columnName === "__checkbox__" ? (
renderCheckboxHeader()
) : (
<div className="flex items-center space-x-2">
<span>{columnLabels[column.columnName] || column.displayName}</span>
{column.sortable && sortColumn === column.columnName && (
<div className="flex flex-col">
{sortDirection === "asc" ? (
<ArrowUp className="h-3 w-3 text-blue-600" />
) : (
<ArrowDown className="h-3 w-3 text-blue-600" />
)}
</div>
)}
</div>
)}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{data.length === 0 ? (
<TableRow>
<TableCell colSpan={visibleColumns.length} className="py-12 text-center">
<div className="flex flex-col items-center space-y-3">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-slate-100 to-slate-200">
<TableIcon className="h-6 w-6 text-slate-400" />
</div>
<div className="text-sm font-medium text-slate-600"> </div>
<div className="text-xs text-slate-400"> </div>
</div>
</TableCell>
</TableRow>
) : (
data.map((row, index) => (
<TableRow
key={index}
draggable={!isDesignMode}
onDragStart={(e) => handleRowDragStart(e, row, index)}
onDragEnd={handleRowDragEnd}
className={cn(
"group relative h-12 cursor-pointer border-b border-gray-100/60 transition-all duration-200",
// 기본 스타일
tableConfig.tableStyle?.hoverEffect &&
"hover:bg-gradient-to-r hover:from-orange-50/80 hover:to-orange-100/60 hover:shadow-sm",
tableConfig.tableStyle?.alternateRows && index % 2 === 1 && "bg-gray-50/40",
// 드래그 상태 스타일 (미묘하게)
draggedRowIndex === index &&
"border-blue-200/60 bg-gradient-to-r from-blue-50/60 to-indigo-50/40 shadow-sm",
isDragging && draggedRowIndex !== index && "opacity-70",
// 드래그 가능 표시
!isDesignMode && "hover:cursor-grab active:cursor-grabbing",
)}
style={{
minHeight: "48px",
height: "48px",
lineHeight: "1",
width: "100%",
maxWidth: "100%",
}}
onClick={() => handleRowClick(row)}
>
{visibleColumns.map((column, colIndex) => (
<TableCell
key={column.columnName}
className={cn(
"h-12 px-6 py-4 align-middle text-sm text-gray-600 transition-all duration-200",
`text-${column.align}`,
)}
style={{
minHeight: "48px",
height: "48px",
verticalAlign: "middle",
width: column.width ? `${column.width}px` : "150px", // 기본 너비 설정
minWidth: "100px", // 최소 너비 보장
maxWidth: "300px", // 최대 너비 제한
boxSizing: "border-box",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{column.columnName === "__checkbox__"
? renderCheckboxCell(row, index)
: (() => {
// 🎯 매핑된 컬럼명으로 데이터 찾기
const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName;
const cellValue = row[mappedColumnName];
if (index === 0) {
// 디버깅 로그 제거 (성능상 이유로)
}
const formattedValue =
formatCellValue(cellValue, column.format, column.columnName) || "\u00A0";
// 첫 번째 컬럼에 드래그 핸들과 아바타 추가
const isFirstColumn =
colIndex === (visibleColumns[0]?.columnName === "__checkbox__" ? 1 : 0);
return (
<div className="flex items-center space-x-2">
{isFirstColumn && !isDesignMode && (
<div className="mr-1 cursor-grab opacity-60 active:cursor-grabbing">
{/* 그리드 스냅 가이드 아이콘 */}
<div className="flex space-x-0.5">
<div className="flex flex-col space-y-0.5">
<div className="h-0.5 w-0.5 rounded-full bg-gray-400"></div>
<div className="h-0.5 w-0.5 rounded-full bg-gray-400"></div>
</div>
<div className="flex flex-col space-y-0.5">
<div className="h-0.5 w-0.5 rounded-full bg-gray-400"></div>
<div className="h-0.5 w-0.5 rounded-full bg-gray-400"></div>
</div>
</div>
</div>
)}
<span className="font-medium text-gray-700">{formattedValue}</span>
</div>
);
})()}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
)}
</div>
{/* 푸터/페이지네이션 */}
{/* showFooter와 pagination.enabled의 기본값은 true */}
{tableConfig.showFooter !== false && tableConfig.pagination?.enabled !== false && (
<div
className="bg-muted/30 flex flex-col items-center justify-center space-y-4 border-t p-6"
style={{
width: "100%",
maxWidth: "100%",
boxSizing: "border-box",
}}
>
{/* 페이지 정보 - 가운데 정렬 */}
{tableConfig.pagination?.showPageInfo && (
<div className="flex items-center justify-center space-x-2 text-sm text-slate-600">
<div className="h-2 w-2 rounded-full bg-blue-500"></div>
<span className="font-medium">
<span className="font-semibold text-blue-600">{totalItems.toLocaleString()}</span> {" "}
<span className="font-semibold text-slate-800">
{(currentPage - 1) * localPageSize + 1}-{Math.min(currentPage * localPageSize, totalItems)}
</span>{" "}
</span>
</div>
)}
{/* 페이지 크기 선택과 페이지네이션 버튼 - 가운데 정렬 */}
<div className="flex items-center justify-center space-x-4">
{/* 페이지 크기 선택 - 임시로 항상 표시 (테스트용) */}
{true && (
<select
value={localPageSize}
onChange={(e) => {
// console.log("🚀 페이지 크기 드롭다운 변경 감지:", e.target.value);
const newPageSize = parseInt(e.target.value);
// console.log("🎯 페이지 크기 변경 이벤트:", {
// from: localPageSize,
// to: newPageSize,
// hasOnConfigChange: !!onConfigChange,
// onConfigChangeType: typeof onConfigChange
// });
// 로컬 상태 업데이트
setLocalPageSize(newPageSize);
// 페이지를 1로 리셋
setCurrentPage(1);
// 상세설정에 변경사항 알림 (pagination 설정 업데이트)
if (onConfigChange) {
// console.log("📤 테이블에서 페이지 크기 변경을 상세설정에 알림:", newPageSize);
onConfigChange({
...tableConfig,
pagination: {
...tableConfig.pagination,
pageSize: newPageSize,
},
});
} else {
// console.warn("⚠️ onConfigChange가 정의되지 않음 - 상세설정과 연동 불가");
}
// 데이터는 useEffect에서 자동으로 다시 로드됨
}}
className="rounded-xl border px-4 py-2 text-sm font-medium shadow-sm transition-all duration-200 hover:shadow-md"
>
{(tableConfig.pagination?.pageSizeOptions || [10, 20, 50, 100]).map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
)}
{/* 페이지네이션 버튼 */}
<div className="flex items-center space-x-2 rounded-xl border p-1 shadow-sm">
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(1)}
disabled={currentPage === 1}
className="h-8 w-8 rounded-lg border-gray-200/60 p-0 transition-all duration-200 hover:border-gray-300/60 hover:bg-gray-50/80 hover:text-gray-700 disabled:opacity-50"
>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
className="h-8 w-8 rounded-lg border-gray-200/60 p-0 transition-all duration-200 hover:border-gray-300/60 hover:bg-gray-50/80 hover:text-gray-700 disabled:opacity-50"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="bg-muted/30 flex items-center rounded-lg border px-4 py-2">
<span className="text-sm font-semibold text-gray-800">{currentPage}</span>
<span className="mx-2 font-light text-gray-400">/</span>
<span className="text-sm font-medium text-gray-600">{totalPages}</span>
</div>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="h-8 w-8 rounded-lg border-gray-200/60 p-0 transition-all duration-200 hover:border-gray-300/60 hover:bg-gray-50/80 hover:text-gray-700 disabled:opacity-50"
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(totalPages)}
disabled={currentPage === totalPages}
className="h-8 w-8 rounded-lg border-gray-200/60 p-0 transition-all duration-200 hover:border-gray-300/60 hover:bg-gray-50/80 hover:text-gray-700 disabled:opacity-50"
>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)}
</div>
);
};
/**
* TableList 래퍼 컴포넌트
* 추가적인 로직이나 상태 관리가 필요한 경우 사용
*/
export const TableListWrapper: React.FC<TableListComponentProps> = (props) => {
return <TableListComponent {...props} />;
};