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

1631 lines
64 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 } from "react";
import { TableListConfig, ColumnConfig } from "./types";
import { WebType } from "@/types/common";
import { tableTypeApi } from "@/lib/api/screen";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { codeCache } from "@/lib/caching/codeCache";
import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization";
import { 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";
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 }>>({}); // 🎯 컬럼 메타정보 (웹타입, 코드카테고리)
// 고급 필터 관련 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;
try {
const response = await tableTypeApi.getColumns(tableConfig.selectedTable);
// API 응답 구조 확인 및 컬럼 배열 추출
const columns = Array.isArray(response) ? response : (response as any).columns || [];
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);
} catch (error) {
console.log("컬럼 라벨 정보를 가져올 수 없습니다:", error);
}
};
// 🎯 전역 코드 캐시 사용으로 함수 제거 (codeCache.convertCodeToName 사용)
// 테이블 라벨명 가져오기
const fetchTableLabel = async () => {
if (!tableConfig.selectedTable) return;
try {
const tables = await tableTypeApi.getTables();
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);
}
} catch (error) {
console.log("테이블 라벨 정보를 가져올 수 없습니다:", error);
setTableLabel(tableConfig.selectedTable);
}
};
// 테이블 데이터 가져오기
const fetchTableData = 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 = codeColumns.map(([, meta]) => meta.codeCategory).filter(Boolean) as string[];
try {
await codeCache.preloadCodes(categoryList);
console.log("📋 모든 코드 캐시 로드 완료 (전역 캐시)");
} catch (error) {
console.error("❌ 코드 캐시 로드 중 오류:", error);
}
}
// 🎯 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);
fetchTableData();
};
const handleClearAdvancedFilters = () => {
setSearchValues({});
setCurrentPage(1);
fetchTableData();
};
// 새로고침
const handleRefresh = () => {
fetchTableData();
};
// 체크박스 핸들러들
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) {
fetchTableData();
}
}, [
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?.([], []);
// 테이블 데이터 새로고침
fetchTableData();
}
}, [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="text-center p-8">
<div className="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-2xl flex items-center justify-center shadow-sm">
<TableIcon className="h-8 w-8 text-blue-600" />
</div>
<div className="text-lg font-semibold text-slate-700 mb-2"> </div>
<div className="text-sm text-slate-500 bg-white/60 px-4 py-2 rounded-full">
</div>
</div>
</div>
</div>
);
}
return (
<div
style={{...componentStyle, zIndex: 10}} // 🎯 componentStyle + z-index 추가
className={cn(
"rounded-lg bg-white border border-gray-200 shadow-md shadow-blue-100/50",
"overflow-hidden relative", // 🎯 항상 overflow-hidden 적용 + relative 추가
className
)}
{...domProps}
>
{/* 헤더 */}
{tableConfig.showHeader && (
<div
className="flex items-center justify-between bg-gray-100/80 border-b border-gray-200 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 bg-blue-50 px-3 py-1 rounded-md">
<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 shadow-sm rounded-lg [&: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 bg-blue-200/30 rounded-full animate-pulse"></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="text-center p-8">
<div className="relative">
<div className="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-2xl flex items-center justify-center">
<RefreshCw className="h-8 w-8 animate-spin text-blue-600" />
</div>
<div className="absolute -top-1 -right-1 w-4 h-4 bg-gradient-to-br from-blue-400 to-indigo-500 rounded-full animate-pulse"></div>
</div>
<div className="text-sm font-medium text-slate-700"> ...</div>
<div className="text-xs text-slate-500 mt-1"> </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="text-center p-8">
<div className="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-red-100 to-orange-100 rounded-2xl flex items-center justify-center">
<div className="w-8 h-8 bg-gradient-to-br from-red-500 to-orange-500 rounded-full flex items-center justify-center">
<span className="text-white text-sm font-bold">!</span>
</div>
</div>
<div className="text-sm font-medium text-red-700"> </div>
<div className="mt-1 text-xs text-red-500 bg-red-50 px-3 py-1 rounded-full">{error}</div>
</div>
</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" : "",
"bg-gray-100/80 border-b border-gray-200"
)}>
<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 align-middle px-4 py-3 text-sm font-semibold text-gray-800",
column.columnName === "__checkbox__"
? "text-center"
: "cursor-pointer whitespace-nowrap select-none",
`text-${column.align}`,
column.sortable && "hover:bg-orange-100 transition-colors duration-150"
)}
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="w-12 h-12 bg-gradient-to-br from-slate-100 to-slate-200 rounded-2xl flex items-center justify-center">
<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 transition-all duration-200 border-b border-gray-100",
// 기본 스타일
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 && "bg-gradient-to-r from-blue-50 to-blue-100/40 shadow-sm border-blue-200",
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 align-middle px-4 py-3 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="opacity-60 cursor-grab active:cursor-grabbing mr-1">
{/* 그리드 스냅 가이드 아이콘 */}
<div className="flex space-x-0.5">
<div className="flex flex-col space-y-0.5">
<div className="w-0.5 h-0.5 bg-gray-400 rounded-full"></div>
<div className="w-0.5 h-0.5 bg-gray-400 rounded-full"></div>
</div>
<div className="flex flex-col space-y-0.5">
<div className="w-0.5 h-0.5 bg-gray-400 rounded-full"></div>
<div className="w-0.5 h-0.5 bg-gray-400 rounded-full"></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 bg-gray-100/80 border-t border-gray-200 p-6 space-y-4"
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="w-2 h-2 bg-blue-500 rounded-full"></div>
<span className="font-medium">
<span className="text-blue-600 font-semibold">{totalItems.toLocaleString()}</span> {" "}
<span className="text-slate-800 font-semibold">
{(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="bg-white/80 border border-slate-200 rounded-lg px-3 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-white hover:border-slate-300 transition-colors"
>
{(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 bg-white rounded-lg border border-gray-200 shadow-sm p-1">
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(1)}
disabled={currentPage === 1}
className="h-8 w-8 p-0 disabled:opacity-50 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-300"
>
<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 disabled:opacity-50 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-300"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex items-center px-4 py-1 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-md border border-blue-100">
<span className="text-sm font-semibold text-blue-800">
{currentPage}
</span>
<span className="text-gray-400 mx-2 font-light">/</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 disabled:opacity-50 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-300"
>
<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 disabled:opacity-50 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-300"
>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)}
</div>
);
};
/**
* TableList 래퍼 컴포넌트
* 추가적인 로직이나 상태 관리가 필요한 경우 사용
*/
export const TableListWrapper: React.FC<TableListComponentProps> = (props) => {
return <TableListComponent {...props} />;
};