1795 lines
70 KiB
TypeScript
1795 lines
70 KiB
TypeScript
"use client";
|
||
|
||
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
||
import { TableListConfig, ColumnConfig } from "./types";
|
||
import { WebType } from "@/types/common";
|
||
import { tableTypeApi } from "@/lib/api/screen";
|
||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||
import { codeCache } from "@/lib/caching/codeCache";
|
||
|
||
// 전역 테이블 캐시
|
||
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,
|
||
}) => {
|
||
// 컴포넌트 설정
|
||
const tableConfig = {
|
||
...config,
|
||
...component.config,
|
||
...componentConfig,
|
||
} as TableListConfig;
|
||
|
||
// 🎨 동적 색상 설정 (속성편집 모달의 "색상" 필드와 연동)
|
||
const buttonColor = component.style?.labelColor || "#3b83f6"; // 기본 파란색
|
||
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 ? `${Math.min(optimalHeight, 400)}px` : `${optimalHeight}px`, // 최소 높이 보장
|
||
...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 fetchTableDataDebounced = useCallback(
|
||
debouncedApiCall(
|
||
`fetchTableData_${tableConfig.selectedTable}_${currentPage}_${localPageSize}`,
|
||
async () => {
|
||
return fetchTableDataInternal();
|
||
},
|
||
200, // 200ms 디바운스
|
||
),
|
||
[tableConfig.selectedTable, currentPage, localPageSize, searchTerm, sortColumn, sortDirection, searchValues],
|
||
);
|
||
|
||
// 실제 테이블 데이터 가져오기 함수
|
||
const fetchTableDataInternal = async () => {
|
||
if (!tableConfig.selectedTable) {
|
||
setData([]);
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
setError(null);
|
||
|
||
try {
|
||
// 🎯 Entity 조인 API 사용 - Entity 조인이 포함된 데이터 조회
|
||
console.log("🔗 Entity 조인 데이터 조회 시작:", tableConfig.selectedTable);
|
||
|
||
// Entity 조인 컬럼 추출 (isEntityJoin === true인 컬럼들)
|
||
const entityJoinColumns = tableConfig.columns?.filter((col) => col.isEntityJoin && col.entityJoinInfo) || [];
|
||
|
||
// 🎯 조인 탭에서 추가한 컬럼들도 포함 (실제로 존재하는 컬럼만)
|
||
const joinTabColumns =
|
||
tableConfig.columns?.filter(
|
||
(col) =>
|
||
!col.isEntityJoin &&
|
||
col.columnName.includes("_") &&
|
||
(col.columnName.includes("dept_code_") ||
|
||
col.columnName.includes("_dept_code") ||
|
||
col.columnName.includes("_company_") ||
|
||
col.columnName.includes("_user_")), // 조인 탭에서 추가한 컬럼 패턴들
|
||
) || [];
|
||
|
||
console.log(
|
||
"🔍 조인 탭 컬럼들:",
|
||
joinTabColumns.map((c) => c.columnName),
|
||
);
|
||
|
||
const additionalJoinColumns = [
|
||
...entityJoinColumns.map((col) => ({
|
||
sourceTable: col.entityJoinInfo!.sourceTable,
|
||
sourceColumn: col.entityJoinInfo!.sourceColumn,
|
||
joinAlias: col.entityJoinInfo!.joinAlias,
|
||
})),
|
||
// 🎯 조인 탭에서 추가한 컬럼들도 추가 (실제로 존재하는 컬럼만)
|
||
...joinTabColumns
|
||
.filter((col) => {
|
||
// 실제 API 응답에 존재하는 컬럼만 필터링
|
||
const validJoinColumns = ["dept_code_name", "dept_name"];
|
||
const isValid = validJoinColumns.includes(col.columnName);
|
||
if (!isValid) {
|
||
console.log(`🔍 조인 탭 컬럼 제외: ${col.columnName} (유효하지 않음)`);
|
||
}
|
||
return isValid;
|
||
})
|
||
.map((col) => {
|
||
// 실제 존재하는 조인 컬럼만 처리
|
||
let sourceTable = tableConfig.selectedTable;
|
||
let sourceColumn = col.columnName;
|
||
|
||
if (col.columnName === "dept_code_name" || col.columnName === "dept_name") {
|
||
sourceTable = "dept_info";
|
||
sourceColumn = "dept_code";
|
||
}
|
||
|
||
console.log(`🔍 조인 탭 컬럼 처리: ${col.columnName} -> ${sourceTable}.${sourceColumn}`);
|
||
|
||
return {
|
||
sourceTable: sourceTable || tableConfig.selectedTable || "",
|
||
sourceColumn: sourceColumn,
|
||
joinAlias: col.columnName,
|
||
};
|
||
}),
|
||
];
|
||
|
||
// 🎯 화면별 엔티티 표시 설정 생성
|
||
const screenEntityConfigs: Record<string, any> = {};
|
||
entityJoinColumns.forEach((col) => {
|
||
if (col.entityDisplayConfig) {
|
||
const sourceColumn = col.entityJoinInfo!.sourceColumn;
|
||
screenEntityConfigs[sourceColumn] = {
|
||
displayColumns: col.entityDisplayConfig.displayColumns,
|
||
separator: col.entityDisplayConfig.separator || " - ",
|
||
};
|
||
}
|
||
});
|
||
|
||
console.log("🔗 Entity 조인 컬럼:", entityJoinColumns);
|
||
console.log("🔗 조인 탭 컬럼:", joinTabColumns);
|
||
console.log("🔗 추가 Entity 조인 컬럼:", additionalJoinColumns);
|
||
// console.log("🎯 화면별 엔티티 설정:", screenEntityConfigs);
|
||
|
||
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, // 추가 조인 컬럼
|
||
screenEntityConfigs: Object.keys(screenEntityConfigs).length > 0 ? screenEntityConfigs : 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 응답)
|
||
// 실제 API 응답에 존재하는 컬럼만 매핑
|
||
const newJoinColumnMapping: Record<string, string> = {
|
||
dept_code_dept_code: "dept_code", // user_info.dept_code
|
||
dept_code_status: "status", // user_info.status (dept_info.status가 조인되지 않음)
|
||
dept_code_company_name: "dept_name", // dept_info.dept_name (company_name이 조인되지 않음)
|
||
dept_code_name: "dept_code_name", // dept_info.dept_name
|
||
dept_name: "dept_name", // dept_info.dept_name
|
||
status: "status", // user_info.status
|
||
};
|
||
|
||
// 🎯 조인 컬럼 매핑 상태 업데이트
|
||
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);
|
||
}
|
||
};
|
||
|
||
// 페이지 변경
|
||
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]);
|
||
|
||
useEffect(() => {
|
||
if (tableConfig.autoLoad && !isDesignMode) {
|
||
fetchTableDataDebounced();
|
||
}
|
||
}, [
|
||
tableConfig.selectedTable,
|
||
localPageSize,
|
||
currentPage,
|
||
searchTerm,
|
||
sortColumn,
|
||
sortDirection,
|
||
columnLabels,
|
||
searchValues,
|
||
]);
|
||
|
||
// refreshKey 변경 시 테이블 데이터 새로고침
|
||
useEffect(() => {
|
||
if (refreshKey && refreshKey > 0 && !isDesignMode) {
|
||
console.log("🔄 refreshKey 변경 감지, 테이블 데이터 새로고침:", refreshKey);
|
||
// 선택된 행 상태 초기화
|
||
setSelectedRows(new Set());
|
||
setIsAllSelected(false);
|
||
// 부모 컴포넌트에 빈 선택 상태 전달
|
||
console.log("🔄 선택 상태 초기화 - 빈 배열 전달");
|
||
onSelectedRowsChange?.([], []);
|
||
// 테이블 데이터 새로고침
|
||
fetchTableDataDebounced();
|
||
}
|
||
}, [refreshKey]);
|
||
|
||
// 상세설정에서 페이지네이션 설정 변경 시 로컬 상태 동기화
|
||
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, #3b82f6 0%, #1d4ed8 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,
|
||
};
|
||
|
||
// 디자인 모드에서의 플레이스홀더
|
||
if (isDesignMode && !tableConfig.selectedTable) {
|
||
return (
|
||
<div style={componentStyle} className={className} {...domProps}>
|
||
<div className="flex h-full items-center justify-center rounded-2xl border-2 border-dashed border-blue-200 bg-gradient-to-br from-blue-50/30 to-indigo-50/20">
|
||
<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-blue-100 to-indigo-100 shadow-sm">
|
||
<TableIcon className="h-8 w-8 text-blue-600" />
|
||
</div>
|
||
<div className="mb-2 text-lg font-semibold text-slate-700">테이블 리스트</div>
|
||
<div className="rounded-full bg-white/60 px-4 py-2 text-sm text-slate-500">
|
||
설정 패널에서 테이블을 선택해주세요
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div
|
||
style={{ ...componentStyle, zIndex: 10 }} // 🎯 componentStyle + z-index 추가
|
||
className={cn(
|
||
"rounded-lg border border-gray-200 bg-white shadow-md shadow-blue-100/50",
|
||
"relative overflow-hidden", // 🎯 항상 overflow-hidden 적용 + relative 추가
|
||
className,
|
||
)}
|
||
{...domProps}
|
||
>
|
||
{/* 헤더 */}
|
||
{tableConfig.showHeader && (
|
||
<div
|
||
className="flex items-center justify-between border-b border-gray-200 bg-gray-100/80 px-6 py-4"
|
||
style={{
|
||
width: "100%",
|
||
maxWidth: "100%",
|
||
boxSizing: "border-box",
|
||
}}
|
||
>
|
||
<div className="flex items-center space-x-4">
|
||
{(tableConfig.title || tableLabel) && (
|
||
<h3 className="text-lg font-semibold text-gray-900">{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-md bg-blue-50 px-3 py-1">
|
||
<span className="text-sm font-medium text-blue-700">{selectedRows.size}개 선택됨</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* 새로고침 */}
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={handleRefresh}
|
||
disabled={loading}
|
||
style={buttonStyle}
|
||
className="group relative rounded-lg shadow-sm [&:hover]:opacity-90"
|
||
>
|
||
<div className="flex items-center space-x-2">
|
||
<div className="relative">
|
||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} style={{ color: buttonTextColor }} />
|
||
{loading && <div className="absolute -inset-1 animate-pulse rounded-full bg-blue-200/30"></div>}
|
||
</div>
|
||
<span className="text-sm font-medium" style={{ color: buttonTextColor }}>
|
||
{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 overflow-hidden ${localPageSize >= 50 ? "flex-1" : ""}`}
|
||
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-hidden">
|
||
<Table
|
||
className="w-full"
|
||
style={{
|
||
width: "100%",
|
||
maxWidth: "100%",
|
||
tableLayout: "fixed", // 테이블 크기 고정
|
||
}}
|
||
>
|
||
<TableHeader
|
||
className={cn(
|
||
tableConfig.stickyHeader ? "sticky top-0 z-20" : "",
|
||
"border-b border-gray-200 bg-gray-100/80",
|
||
)}
|
||
>
|
||
<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` : undefined,
|
||
minHeight: "48px !important",
|
||
height: "48px !important",
|
||
verticalAlign: "middle",
|
||
lineHeight: "1",
|
||
boxSizing: "border-box",
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
}}
|
||
className={cn(
|
||
"h-12 px-4 py-3 align-middle text-sm font-semibold text-gray-800",
|
||
column.columnName === "__checkbox__"
|
||
? "text-center"
|
||
: "cursor-pointer whitespace-nowrap select-none",
|
||
`text-${column.align}`,
|
||
column.sortable && "transition-colors duration-150 hover:bg-orange-100",
|
||
)}
|
||
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 && (
|
||
<div className="flex flex-col">
|
||
{sortColumn === column.columnName ? (
|
||
sortDirection === "asc" ? (
|
||
<ArrowUp className="h-3 w-3 text-blue-600" />
|
||
) : (
|
||
<ArrowDown className="h-3 w-3 text-blue-600" />
|
||
)
|
||
) : (
|
||
<ArrowUpDown className="h-3 w-3 text-gray-400" />
|
||
)}
|
||
</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 transition-all duration-200",
|
||
// 기본 스타일
|
||
tableConfig.tableStyle?.hoverEffect &&
|
||
"hover:bg-gradient-to-r hover:from-orange-200 hover:to-orange-300/90 hover:shadow-sm",
|
||
tableConfig.tableStyle?.alternateRows && index % 2 === 1 && "bg-gray-100/80",
|
||
// 드래그 상태 스타일 (미묘하게)
|
||
draggedRowIndex === index &&
|
||
"border-blue-200 bg-gradient-to-r from-blue-50 to-blue-100/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-4 py-3 align-middle text-sm transition-all duration-200",
|
||
`text-${column.align}`,
|
||
)}
|
||
style={{
|
||
minHeight: "48px",
|
||
height: "48px",
|
||
verticalAlign: "middle",
|
||
width: column.width ? `${column.width}px` : undefined,
|
||
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>
|
||
|
||
{/* 푸터/페이지네이션 */}
|
||
{tableConfig.showFooter && tableConfig.pagination?.enabled && (
|
||
<div
|
||
className="flex flex-col items-center justify-center space-y-4 border-t border-gray-200 bg-gray-100/80 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-lg border border-slate-200 bg-white/80 px-3 py-2 text-sm font-medium text-slate-700 shadow-sm transition-colors hover:border-slate-300 hover:bg-white"
|
||
>
|
||
{(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-lg border border-gray-200 bg-white p-1 shadow-sm">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => handlePageChange(1)}
|
||
disabled={currentPage === 1}
|
||
className="h-8 w-8 p-0 hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 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 p-0 hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 disabled:opacity-50"
|
||
>
|
||
<ChevronLeft className="h-4 w-4" />
|
||
</Button>
|
||
|
||
<div className="flex items-center rounded-md border border-blue-100 bg-gradient-to-r from-blue-50 to-indigo-50 px-4 py-1">
|
||
<span className="text-sm font-semibold text-blue-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 p-0 hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 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 p-0 hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 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} />;
|
||
};
|