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

1295 lines
51 KiB
TypeScript
Raw Normal View History

2025-09-15 11:43:59 +09:00
"use client";
import React, { useState, useEffect, useMemo } from "react";
2025-09-16 16:53:03 +09:00
import { TableListConfig, ColumnConfig } from "./types";
2025-09-15 11:43:59 +09:00
import { tableTypeApi } from "@/lib/api/screen";
2025-09-16 15:13:00 +09:00
import { entityJoinApi } from "@/lib/api/entityJoin";
2025-09-16 16:53:03 +09:00
import { codeCache } from "@/lib/cache/codeCache";
import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization";
2025-09-15 11:43:59 +09:00
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Search,
RefreshCw,
ArrowUpDown,
ArrowUp,
ArrowDown,
TableIcon,
} from "lucide-react";
2025-09-18 18:49:30 +09:00
import { Checkbox } from "@/components/ui/checkbox";
2025-09-15 11:43:59 +09:00
import { cn } from "@/lib/utils";
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;
2025-09-18 18:49:30 +09:00
// 선택된 행 정보 전달 핸들러
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void;
// 테이블 새로고침 키
refreshKey?: number;
2025-09-15 11:43:59 +09:00
}
/**
* TableList
*
*/
export const TableListComponent: React.FC<TableListComponentProps> = ({
component,
isDesignMode = false,
isSelected = false,
onClick,
onDragStart,
onDragEnd,
config,
className,
style,
onFormDataChange,
componentConfig,
2025-09-18 18:49:30 +09:00
onSelectedRowsChange,
refreshKey,
2025-09-15 11:43:59 +09:00
}) => {
// 컴포넌트 설정
const tableConfig = {
...config,
...component.config,
...componentConfig,
} as TableListConfig;
// 상태 관리
2025-09-18 15:14:14 +09:00
const [data, setData] = useState<Record<string, any>[]>([]);
2025-09-15 11:43:59 +09:00
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
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 [selectedSearchColumn, setSelectedSearchColumn] = useState<string>(""); // 선택된 검색 컬럼
2025-09-16 15:13:00 +09:00
const [displayColumns, setDisplayColumns] = useState<ColumnConfig[]>([]); // 🎯 표시할 컬럼 (Entity 조인 적용됨)
const [columnMeta, setColumnMeta] = useState<Record<string, { webType?: string; codeCategory?: string }>>({}); // 🎯 컬럼 메타정보 (웹타입, 코드카테고리)
2025-09-18 18:49:30 +09:00
// 체크박스 상태 관리
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set()); // 선택된 행들의 키 집합
const [isAllSelected, setIsAllSelected] = useState(false); // 전체 선택 상태
2025-09-16 16:53:03 +09:00
// 🎯 Entity 조인 최적화 훅 사용
2025-09-17 13:49:00 +09:00
const { optimizedConvertCode } = useEntityJoinOptimization(columnMeta, {
2025-09-16 16:53:03 +09:00
enableBatchLoading: true,
preloadCommonCodes: true,
maxBatchSize: 5,
});
2025-09-15 11:43:59 +09:00
// 높이 계산 함수
const calculateOptimalHeight = () => {
// 50개 이상일 때는 20개 기준으로 높이 고정
const displayPageSize = localPageSize >= 50 ? 20 : localPageSize;
const headerHeight = 48; // 테이블 헤더
const rowHeight = 40; // 각 행 높이 (normal)
const searchHeight = tableConfig.filter?.enabled ? 48 : 0; // 검색 영역
const footerHeight = tableConfig.showFooter ? 56 : 0; // 페이지네이션
const padding = 8; // 여백
return headerHeight + displayPageSize * rowHeight + searchHeight + footerHeight + padding;
};
// 스타일 계산
const componentStyle: React.CSSProperties = {
width: "100%",
height:
tableConfig.height === "fixed"
? `${tableConfig.fixedHeight || calculateOptimalHeight()}px`
: tableConfig.height === "auto"
? `${calculateOptimalHeight()}px`
: "100%",
...component.style,
...style,
display: "flex",
flexDirection: "column",
};
// 디자인 모드 스타일
if (isDesignMode) {
componentStyle.border = "1px dashed #cbd5e1";
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
componentStyle.minHeight = "200px";
}
// 컬럼 라벨 정보 가져오기
const fetchColumnLabels = async () => {
if (!tableConfig.selectedTable) return;
try {
2025-09-16 15:33:46 +09:00
const response = await tableTypeApi.getColumns(tableConfig.selectedTable);
// API 응답 구조 확인 및 컬럼 배열 추출
2025-09-18 15:14:14 +09:00
const columns = Array.isArray(response) ? response : (response as any).columns || [];
2025-09-15 11:43:59 +09:00
const labels: Record<string, string> = {};
const meta: Record<string, { webType?: string; codeCategory?: string }> = {};
2025-09-15 11:43:59 +09:00
columns.forEach((column: any) => {
// 🎯 Entity 조인된 컬럼의 경우 표시 컬럼명 사용
let displayLabel = column.displayName || column.columnName;
// Entity 타입이고 display_column이 있는 경우
if (column.webType === "entity" && column.displayColumn) {
// 백엔드에서 받은 displayColumnLabel을 사용하거나, 없으면 displayColumn 사용
displayLabel = column.displayColumnLabel || column.displayColumn;
console.log(
`🎯 Entity 조인 컬럼 라벨 설정: ${column.columnName} → "${displayLabel}" (${column.displayColumn})`,
);
}
labels[column.columnName] = displayLabel;
// 🎯 웹타입과 코드카테고리 정보 저장
meta[column.columnName] = {
webType: column.webType,
codeCategory: column.codeCategory,
};
2025-09-15 11:43:59 +09:00
});
2025-09-15 11:43:59 +09:00
setColumnLabels(labels);
setColumnMeta(meta);
2025-09-16 15:33:46 +09:00
console.log("🔍 컬럼 라벨 설정 완료:", labels);
console.log("🔍 컬럼 메타정보 설정 완료:", meta);
2025-09-15 11:43:59 +09:00
} catch (error) {
console.log("컬럼 라벨 정보를 가져올 수 없습니다:", error);
}
};
2025-09-16 16:53:03 +09:00
// 🎯 전역 코드 캐시 사용으로 함수 제거 (codeCache.convertCodeToName 사용)
2025-09-15 11:43:59 +09:00
// 테이블 라벨명 가져오기
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 {
2025-09-16 15:13:00 +09:00
// 🎯 Entity 조인 API 사용 - Entity 조인이 포함된 데이터 조회
console.log("🔗 Entity 조인 데이터 조회 시작:", tableConfig.selectedTable);
// Entity 조인 컬럼 추출 (isEntityJoin === true인 컬럼들)
const entityJoinColumns = tableConfig.columns?.filter((col) => col.isEntityJoin && col.entityJoinInfo) || [];
const additionalJoinColumns = entityJoinColumns.map((col) => ({
sourceTable: col.entityJoinInfo!.sourceTable,
sourceColumn: col.entityJoinInfo!.sourceColumn,
joinAlias: col.entityJoinInfo!.joinAlias,
}));
console.log("🔗 추가 Entity 조인 컬럼:", additionalJoinColumns);
2025-09-16 15:13:00 +09:00
const result = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
2025-09-15 11:43:59 +09:00
page: currentPage,
size: localPageSize,
2025-09-16 15:13:00 +09:00
search: searchTerm?.trim()
2025-09-15 11:43:59 +09:00
? (() => {
// 스마트한 단일 컬럼 선택 로직 (서버가 OR 검색을 지원하지 않음)
let searchColumn = selectedSearchColumn || 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);
console.log("🔍 검색어:", searchTerm);
console.log(
"🔍 사용 가능한 컬럼들:",
visibleColumns.map((col) => `${col.columnName}(${col.dataType || "unknown"})`),
);
return { [searchColumn]: searchTerm };
})()
2025-09-16 15:13:00 +09:00
: undefined,
2025-09-15 11:43:59 +09:00
sortBy: sortColumn || undefined,
sortOrder: sortDirection,
2025-09-16 15:13:00 +09:00
enableEntityJoin: true, // 🎯 Entity 조인 활성화
additionalJoinColumns: additionalJoinColumns.length > 0 ? additionalJoinColumns : undefined, // 추가 조인 컬럼
2025-09-15 11:43:59 +09:00
});
if (result) {
setData(result.data || []);
setTotalPages(result.totalPages || 1);
setTotalItems(result.total || 0);
2025-09-16 15:13:00 +09:00
// 🎯 Entity 조인 정보 로깅
if (result.entityJoinInfo) {
console.log("🔗 Entity 조인 적용됨:", {
strategy: result.entityJoinInfo.strategy,
joinConfigs: result.entityJoinInfo.joinConfigs,
performance: result.entityJoinInfo.performance,
});
} else {
console.log("🔗 Entity 조인 없음");
}
2025-09-16 16:53:03 +09:00
// 🎯 코드 컬럼들의 캐시 미리 로드 (전역 캐시 사용)
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})`),
);
2025-09-16 16:53:03 +09:00
// 필요한 코드 카테고리들을 추출하여 배치 로드
const categoryList = codeColumns.map(([, meta]) => meta.codeCategory).filter(Boolean) as string[];
try {
2025-09-16 16:53:03 +09:00
await codeCache.preloadCodes(categoryList);
console.log("📋 모든 코드 캐시 로드 완료 (전역 캐시)");
} catch (error) {
console.error("❌ 코드 캐시 로드 중 오류:", error);
}
}
2025-09-16 15:13:00 +09:00
// 🎯 Entity 조인된 컬럼 처리
let processedColumns = [...(tableConfig.columns || [])];
// 초기 컬럼이 있으면 먼저 설정
if (processedColumns.length > 0) {
setDisplayColumns(processedColumns);
}
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}`);
2025-09-16 15:33:46 +09:00
const originalColumn = processedColumns[originalColumnIndex];
2025-09-16 15:13:00 +09:00
processedColumns[originalColumnIndex] = {
2025-09-16 15:33:46 +09:00
...originalColumn,
2025-09-16 15:13:00 +09:00
columnName: joinConfig.aliasColumn, // dept_code → dept_code_name
2025-09-16 15:33:46 +09:00
displayName:
columnLabels[originalColumn.columnName] || originalColumn.displayName || originalColumn.columnName, // 올바른 라벨 사용
2025-09-16 15:13:00 +09:00
// isEntityJoined: true, // 🎯 임시 주석 처리 (타입 에러 해결 후 복원)
} as ColumnConfig;
2025-09-16 15:33:46 +09:00
console.log(
`✅ 조인 컬럼 라벨 유지: ${joinConfig.sourceColumn} → "${columnLabels[originalColumn.columnName] || originalColumn.displayName || originalColumn.columnName}"`,
);
2025-09-16 15:13:00 +09:00
}
});
}
2025-09-15 11:43:59 +09:00
// 컬럼 정보가 없으면 첫 번째 데이터 행에서 추출
2025-09-16 15:13:00 +09:00
if ((!processedColumns || processedColumns.length === 0) && result.data.length > 0) {
2025-09-15 11:43:59 +09:00
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,
}));
// 컴포넌트 설정 업데이트 (부모 컴포넌트에 알림)
if (onFormDataChange) {
onFormDataChange({
...component,
config: {
...tableConfig,
columns: autoColumns,
},
});
}
2025-09-16 15:13:00 +09:00
processedColumns = autoColumns;
2025-09-15 11:43:59 +09:00
}
2025-09-16 15:13:00 +09:00
// 🎯 표시할 컬럼 상태 업데이트
setDisplayColumns(processedColumns);
2025-09-15 11:43:59 +09:00
}
} catch (err) {
console.error("테이블 데이터 로딩 오류:", err);
setError(err instanceof Error ? err.message : "데이터를 불러오는 중 오류가 발생했습니다.");
setData([]);
} finally {
setLoading(false);
}
};
// 페이지 변경
const handlePageChange = (newPage: number) => {
setCurrentPage(newPage);
};
// 정렬 변경
const handleSort = (column: string) => {
if (sortColumn === column) {
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
} else {
setSortColumn(column);
setSortDirection("asc");
}
};
// 검색
const handleSearch = (term: string) => {
setSearchTerm(term);
setCurrentPage(1); // 검색 시 첫 페이지로 이동
};
// 새로고침
const handleRefresh = () => {
fetchTableData();
};
2025-09-18 18:49:30 +09:00
// 체크박스 핸들러들
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([]);
}
}
};
2025-09-15 11:43:59 +09:00
// 효과
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]);
2025-09-18 18:49:30 +09:00
// refreshKey 변경 시 테이블 데이터 새로고침
useEffect(() => {
if (refreshKey && refreshKey > 0 && !isDesignMode) {
console.log("🔄 refreshKey 변경 감지, 테이블 데이터 새로고침:", refreshKey);
// 선택된 행 상태 초기화
setSelectedRows(new Set());
setIsAllSelected(false);
// 부모 컴포넌트에 빈 선택 상태 전달
console.log("🔄 선택 상태 초기화 - 빈 배열 전달");
onSelectedRowsChange?.([], []);
// 테이블 데이터 새로고침
fetchTableData();
}
}, [refreshKey]);
// 표시할 컬럼 계산 (Entity 조인 적용됨 + 체크박스 컬럼 추가)
2025-09-15 11:43:59 +09:00
const visibleColumns = useMemo(() => {
2025-09-18 18:49:30 +09:00
// 기본값 처리: checkbox 설정이 없으면 기본값 사용
const checkboxConfig = tableConfig.checkbox || {
enabled: true,
multiple: true,
position: "left",
selectAll: true,
};
let columns: ColumnConfig[] = [];
2025-09-16 15:13:00 +09:00
if (!displayColumns || displayColumns.length === 0) {
// displayColumns가 아직 설정되지 않은 경우 기본 컬럼 사용
if (!tableConfig.columns) return [];
2025-09-18 18:49:30 +09:00
columns = tableConfig.columns.filter((col) => col.visible).sort((a, b) => a.order - b.order);
} else {
columns = displayColumns.filter((col) => col.visible).sort((a, b) => a.order - b.order);
}
// 체크박스가 활성화된 경우 체크박스 컬럼을 추가
if (checkboxConfig.enabled) {
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);
}
2025-09-16 15:13:00 +09:00
}
2025-09-18 18:49:30 +09:00
return columns;
}, [displayColumns, tableConfig.columns, tableConfig.checkbox]);
2025-09-15 11:43:59 +09:00
2025-09-18 15:14:14 +09:00
// 컬럼을 고정 위치별로 분류
const columnsByPosition = useMemo(() => {
const leftFixed: ColumnConfig[] = [];
const rightFixed: ColumnConfig[] = [];
const normal: ColumnConfig[] = [];
visibleColumns.forEach((col) => {
if (col.fixed === "left") {
leftFixed.push(col);
} else if (col.fixed === "right") {
rightFixed.push(col);
} else {
normal.push(col);
}
});
// 고정 컬럼들은 fixedOrder로 정렬
leftFixed.sort((a, b) => (a.fixedOrder || 0) - (b.fixedOrder || 0));
rightFixed.sort((a, b) => (a.fixedOrder || 0) - (b.fixedOrder || 0));
return { leftFixed, rightFixed, normal };
}, [visibleColumns]);
// 가로 스크롤이 필요한지 계산
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;
2025-09-18 18:49:30 +09:00
// 체크박스 컬럼인 경우 고정 너비
if (column.columnName === "__checkbox__") {
return 50;
}
2025-09-18 15:14:14 +09:00
// 컬럼 헤더 텍스트 길이 기반으로 계산
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);
};
2025-09-18 18:49:30 +09:00
// 체크박스 헤더 렌더링
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="전체 선택" />;
};
// 체크박스 셀 렌더링
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} 선택`}
/>
);
};
2025-09-16 16:53:03 +09:00
// 🎯 값 포맷팅 (전역 코드 캐시 사용)
const formatCellValue = useMemo(() => {
return (value: any, format?: string, columnName?: string) => {
if (value === null || value === undefined) return "";
2025-09-15 11:43:59 +09:00
2025-09-16 16:53:03 +09:00
// 🎯 코드 컬럼인 경우 최적화된 코드명 변환 사용
if (columnName && columnMeta[columnName]?.webType === "code" && columnMeta[columnName]?.codeCategory) {
const categoryCode = columnMeta[columnName].codeCategory!;
const convertedValue = optimizedConvertCode(categoryCode, String(value));
2025-09-16 16:53:03 +09:00
if (convertedValue !== String(value)) {
console.log(`🔄 코드 변환: ${columnName}[${categoryCode}] ${value}${convertedValue}`);
}
2025-09-16 16:53:03 +09:00
value = convertedValue;
}
2025-09-16 16:53:03 +09:00
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]); // 최적화된 변환 함수 의존성 추가
2025-09-15 11:43:59 +09:00
// 이벤트 핸들러
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.();
};
// 행 클릭 핸들러
const handleRowClick = (row: any) => {
if (tableConfig.onRowClick) {
tableConfig.onRowClick(row);
}
};
// 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-lg border-2 border-dashed border-gray-300">
<div className="text-center text-gray-500">
<TableIcon className="mx-auto mb-2 h-8 w-8" />
<div className="text-sm font-medium"> </div>
<div className="text-xs text-gray-400"> </div>
</div>
</div>
</div>
);
}
return (
<div style={componentStyle} className={cn("rounded-lg border bg-white shadow-sm", className)} {...domProps}>
{/* 헤더 */}
{tableConfig.showHeader && (
<div className="flex items-center justify-between border-b p-4">
<div className="flex items-center space-x-4">
{(tableConfig.title || tableLabel) && (
<h3 className="text-lg font-medium">{tableConfig.title || tableLabel}</h3>
)}
</div>
<div className="flex items-center space-x-2">
2025-09-18 18:49:30 +09:00
{/* 선택된 항목 정보 표시 */}
{selectedRows.size > 0 && (
<div className="mr-4 flex items-center space-x-2">
<span className="text-sm text-gray-600">{selectedRows.size} </span>
</div>
)}
2025-09-15 11:43:59 +09:00
{/* 검색 */}
{tableConfig.filter?.enabled && tableConfig.filter?.quickSearch && (
<div className="flex items-center space-x-2">
<div className="relative">
<Search className="absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
<Input
placeholder="검색..."
value={searchTerm}
onChange={(e) => handleSearch(e.target.value)}
className="w-64 pl-8"
/>
</div>
{/* 검색 컬럼 선택 드롭다운 */}
{tableConfig.filter?.showColumnSelector && (
<select
value={selectedSearchColumn}
onChange={(e) => setSelectedSearchColumn(e.target.value)}
className="min-w-[120px] rounded border px-2 py-1 text-sm"
>
<option value=""> </option>
{visibleColumns.map((column) => (
<option key={column.columnName} value={column.columnName}>
{columnLabels[column.columnName] || column.displayName || column.columnName}
</option>
))}
</select>
)}
</div>
)}
{/* 새로고침 */}
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={loading}>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</Button>
</div>
</div>
)}
{/* 테이블 컨텐츠 */}
<div className={`flex-1 ${localPageSize >= 50 ? "overflow-auto" : "overflow-hidden"}`}>
{loading ? (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<RefreshCw className="mx-auto mb-2 h-8 w-8 animate-spin text-gray-400" />
<div className="text-sm text-gray-500"> ...</div>
</div>
</div>
) : error ? (
<div className="flex h-full items-center justify-center">
<div className="text-center text-red-500">
<div className="text-sm"> </div>
<div className="mt-1 text-xs text-gray-400">{error}</div>
</div>
</div>
2025-09-18 15:14:14 +09:00
) : needsHorizontalScroll ? (
// 가로 스크롤이 필요한 경우 - 고정 컬럼 지원 테이블
<div className="relative flex h-full">
{/* 왼쪽 고정 컬럼 */}
{columnsByPosition.leftFixed.length > 0 && (
<div className="flex-shrink-0 border-r bg-gray-50/50">
2025-09-18 18:49:30 +09:00
<table
className="table-fixed-layout table-auto"
style={{ borderCollapse: "collapse", margin: 0, padding: 0 }}
>
2025-09-18 15:14:14 +09:00
<thead className={tableConfig.stickyHeader ? "sticky top-0 z-20 bg-white" : ""}>
<tr>
{columnsByPosition.leftFixed.map((column) => (
<th
key={`fixed-left-${column.columnName}`}
className={cn(
2025-09-18 18:49:30 +09:00
column.columnName === "__checkbox__"
? "h-12 border-b px-4 py-3 text-center align-middle"
: "h-12 cursor-pointer border-b px-4 py-3 text-left align-middle font-medium whitespace-nowrap text-gray-900 select-none",
2025-09-18 15:14:14 +09:00
`text-${column.align}`,
column.sortable && "hover:bg-gray-50",
)}
2025-09-18 18:49:30 +09:00
style={{
minWidth: `${getColumnWidth(column)}px`,
minHeight: "48px",
height: "48px",
verticalAlign: "middle",
lineHeight: "1",
boxSizing: "border-box",
}}
2025-09-18 15:14:14 +09:00
onClick={() => column.sortable && handleSort(column.columnName)}
>
2025-09-18 18:49:30 +09:00
{column.columnName === "__checkbox__" ? (
renderCheckboxHeader()
) : (
<div className="flex items-center space-x-1">
<span className="text-sm">{columnLabels[column.columnName] || column.displayName}</span>
{column.sortable && (
<div className="flex flex-col">
{sortColumn === column.columnName ? (
sortDirection === "asc" ? (
<ArrowUp className="h-3 w-3" />
) : (
<ArrowDown className="h-3 w-3" />
)
2025-09-18 15:14:14 +09:00
) : (
2025-09-18 18:49:30 +09:00
<ArrowUpDown className="h-3 w-3 text-gray-400" />
)}
</div>
)}
</div>
)}
2025-09-18 15:14:14 +09:00
</th>
))}
</tr>
</thead>
<tbody>
{data.length === 0 ? (
<tr>
<td colSpan={columnsByPosition.leftFixed.length} className="py-8 text-center text-gray-500">
</td>
</tr>
) : (
data.map((row, index) => (
<tr
key={`fixed-left-row-${index}`}
className={cn(
2025-09-18 18:49:30 +09:00
"h-12 cursor-pointer border-b leading-none",
2025-09-18 15:14:14 +09:00
tableConfig.tableStyle?.hoverEffect && "hover:bg-gray-50",
tableConfig.tableStyle?.alternateRows && index % 2 === 1 && "bg-gray-50/50",
)}
2025-09-18 18:49:30 +09:00
style={{ minHeight: "48px", height: "48px", lineHeight: "1" }}
2025-09-18 15:14:14 +09:00
onClick={() => handleRowClick(row)}
>
{columnsByPosition.leftFixed.map((column) => (
<td
key={`fixed-left-cell-${column.columnName}`}
2025-09-18 18:49:30 +09:00
className={cn(
"h-12 px-4 py-3 align-middle text-sm whitespace-nowrap",
`text-${column.align}`,
)}
style={{ minHeight: "48px", height: "48px", verticalAlign: "middle" }}
2025-09-18 15:14:14 +09:00
>
2025-09-18 18:49:30 +09:00
{column.columnName === "__checkbox__"
? renderCheckboxCell(row, index)
: formatCellValue(row[column.columnName], column.format, column.columnName) || "\u00A0"}
2025-09-18 15:14:14 +09:00
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
)}
{/* 스크롤 가능한 중앙 컬럼들 */}
<div className="flex-1 overflow-x-auto">
2025-09-18 18:49:30 +09:00
<table
className="table-fixed-layout w-full table-auto"
style={{ borderCollapse: "collapse", margin: 0, padding: 0 }}
>
2025-09-18 15:14:14 +09:00
<thead className={tableConfig.stickyHeader ? "sticky top-0 z-10 bg-white" : ""}>
<tr>
{columnsByPosition.normal.map((column) => (
<th
key={`normal-${column.columnName}`}
style={{ minWidth: `${getColumnWidth(column)}px` }}
className={cn(
2025-09-18 18:49:30 +09:00
column.columnName === "__checkbox__"
? "h-12 border-b px-4 py-3 text-center"
: "cursor-pointer border-b px-4 py-3 text-left font-medium whitespace-nowrap text-gray-900 select-none",
2025-09-18 15:14:14 +09:00
`text-${column.align}`,
column.sortable && "hover:bg-gray-50",
)}
onClick={() => column.sortable && handleSort(column.columnName)}
>
2025-09-18 18:49:30 +09:00
{column.columnName === "__checkbox__" ? (
renderCheckboxHeader()
) : (
<div className="flex items-center space-x-1">
<span className="text-sm">{columnLabels[column.columnName] || column.displayName}</span>
{column.sortable && (
<div className="flex flex-col">
{sortColumn === column.columnName ? (
sortDirection === "asc" ? (
<ArrowUp className="h-3 w-3" />
) : (
<ArrowDown className="h-3 w-3" />
)
2025-09-18 15:14:14 +09:00
) : (
2025-09-18 18:49:30 +09:00
<ArrowUpDown className="h-3 w-3 text-gray-400" />
)}
</div>
)}
</div>
)}
2025-09-18 15:14:14 +09:00
</th>
))}
</tr>
</thead>
<tbody>
{data.length === 0 ? (
<tr>
<td colSpan={columnsByPosition.normal.length} className="py-8 text-center text-gray-500">
{columnsByPosition.leftFixed.length === 0 && columnsByPosition.rightFixed.length === 0
? "데이터가 없습니다"
: ""}
</td>
</tr>
) : (
data.map((row, index) => (
<tr
key={`normal-row-${index}`}
className={cn(
"cursor-pointer border-b",
tableConfig.tableStyle?.hoverEffect && "hover:bg-gray-50",
tableConfig.tableStyle?.alternateRows && index % 2 === 1 && "bg-gray-50/50",
)}
onClick={() => handleRowClick(row)}
>
{columnsByPosition.normal.map((column) => (
<td
key={`normal-cell-${column.columnName}`}
2025-09-18 18:49:30 +09:00
className={cn(
"h-12 px-4 py-3 align-middle text-sm whitespace-nowrap",
`text-${column.align}`,
)}
style={{ minHeight: "48px", height: "48px", verticalAlign: "middle" }}
2025-09-18 15:14:14 +09:00
>
2025-09-18 18:49:30 +09:00
{column.columnName === "__checkbox__"
? renderCheckboxCell(row, index)
: formatCellValue(row[column.columnName], column.format, column.columnName) || "\u00A0"}
2025-09-18 15:14:14 +09:00
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
{/* 오른쪽 고정 컬럼 */}
{columnsByPosition.rightFixed.length > 0 && (
<div className="flex-shrink-0 border-l bg-gray-50/50">
2025-09-18 18:49:30 +09:00
<table
className="table-fixed-layout table-auto"
style={{ borderCollapse: "collapse", margin: 0, padding: 0 }}
>
2025-09-18 15:14:14 +09:00
<thead className={tableConfig.stickyHeader ? "sticky top-0 z-20 bg-white" : ""}>
<tr>
{columnsByPosition.rightFixed.map((column) => (
<th
key={`fixed-right-${column.columnName}`}
className={cn(
2025-09-18 18:49:30 +09:00
column.columnName === "__checkbox__"
? "h-12 border-b px-4 py-3 text-center align-middle"
: "h-12 cursor-pointer border-b px-4 py-3 text-left align-middle font-medium whitespace-nowrap text-gray-900 select-none",
2025-09-18 15:14:14 +09:00
`text-${column.align}`,
column.sortable && "hover:bg-gray-50",
)}
2025-09-18 18:49:30 +09:00
style={{
minWidth: `${getColumnWidth(column)}px`,
minHeight: "48px",
height: "48px",
verticalAlign: "middle",
lineHeight: "1",
boxSizing: "border-box",
}}
2025-09-18 15:14:14 +09:00
onClick={() => column.sortable && handleSort(column.columnName)}
>
2025-09-18 18:49:30 +09:00
{column.columnName === "__checkbox__" ? (
renderCheckboxHeader()
) : (
<div className="flex items-center space-x-1">
<span className="text-sm">{columnLabels[column.columnName] || column.displayName}</span>
{column.sortable && (
<div className="flex flex-col">
{sortColumn === column.columnName ? (
sortDirection === "asc" ? (
<ArrowUp className="h-3 w-3" />
) : (
<ArrowDown className="h-3 w-3" />
)
2025-09-18 15:14:14 +09:00
) : (
2025-09-18 18:49:30 +09:00
<ArrowUpDown className="h-3 w-3 text-gray-400" />
)}
</div>
)}
</div>
)}
2025-09-18 15:14:14 +09:00
</th>
))}
</tr>
</thead>
<tbody>
{data.length === 0 ? (
<tr>
<td colSpan={columnsByPosition.rightFixed.length} className="py-8 text-center text-gray-500">
{columnsByPosition.leftFixed.length === 0 && columnsByPosition.normal.length === 0
? "데이터가 없습니다"
: ""}
</td>
</tr>
) : (
data.map((row, index) => (
<tr
key={`fixed-right-row-${index}`}
className={cn(
2025-09-18 18:49:30 +09:00
"h-12 cursor-pointer border-b leading-none",
2025-09-18 15:14:14 +09:00
tableConfig.tableStyle?.hoverEffect && "hover:bg-gray-50",
tableConfig.tableStyle?.alternateRows && index % 2 === 1 && "bg-gray-50/50",
)}
2025-09-18 18:49:30 +09:00
style={{ minHeight: "48px", height: "48px", lineHeight: "1" }}
2025-09-18 15:14:14 +09:00
onClick={() => handleRowClick(row)}
>
{columnsByPosition.rightFixed.map((column) => (
<td
key={`fixed-right-cell-${column.columnName}`}
2025-09-18 18:49:30 +09:00
className={cn(
"h-12 px-4 py-3 align-middle text-sm whitespace-nowrap",
`text-${column.align}`,
)}
style={{ minHeight: "48px", height: "48px", verticalAlign: "middle" }}
2025-09-18 15:14:14 +09:00
>
2025-09-18 18:49:30 +09:00
{column.columnName === "__checkbox__"
? renderCheckboxCell(row, index)
: formatCellValue(row[column.columnName], column.format, column.columnName) || "\u00A0"}
2025-09-18 15:14:14 +09:00
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
)}
</div>
2025-09-15 11:43:59 +09:00
) : (
2025-09-18 15:14:14 +09:00
// 기존 테이블 (가로 스크롤이 필요 없는 경우)
2025-09-15 11:43:59 +09:00
<Table>
<TableHeader className={tableConfig.stickyHeader ? "sticky top-0 z-10 bg-white" : ""}>
2025-09-18 18:49:30 +09:00
<TableRow style={{ minHeight: "48px !important", height: "48px !important", lineHeight: "1" }}>
2025-09-15 11:43:59 +09:00
{visibleColumns.map((column) => (
<TableHead
key={column.columnName}
2025-09-18 18:49:30 +09:00
style={{
width: column.width ? `${column.width}px` : undefined,
minHeight: "48px !important",
height: "48px !important",
verticalAlign: "middle",
lineHeight: "1",
boxSizing: "border-box",
}}
2025-09-15 11:43:59 +09:00
className={cn(
2025-09-18 18:49:30 +09:00
column.columnName === "__checkbox__"
? "h-12 text-center align-middle"
: "h-12 cursor-pointer align-middle whitespace-nowrap select-none",
2025-09-15 11:43:59 +09:00
`text-${column.align}`,
column.sortable && "hover:bg-gray-50",
)}
onClick={() => column.sortable && handleSort(column.columnName)}
>
2025-09-18 18:49:30 +09:00
{column.columnName === "__checkbox__" ? (
renderCheckboxHeader()
) : (
<div className="flex items-center space-x-1">
<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" />
) : (
<ArrowDown className="h-3 w-3" />
)
2025-09-15 11:43:59 +09:00
) : (
2025-09-18 18:49:30 +09:00
<ArrowUpDown className="h-3 w-3 text-gray-400" />
)}
</div>
)}
</div>
)}
2025-09-15 11:43:59 +09:00
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{data.length === 0 ? (
<TableRow>
<TableCell colSpan={visibleColumns.length} className="py-8 text-center text-gray-500">
</TableCell>
</TableRow>
) : (
data.map((row, index) => (
<TableRow
key={index}
className={cn(
2025-09-18 18:49:30 +09:00
"h-12 cursor-pointer leading-none",
2025-09-15 11:43:59 +09:00
tableConfig.tableStyle?.hoverEffect && "hover:bg-gray-50",
tableConfig.tableStyle?.alternateRows && index % 2 === 1 && "bg-gray-50/50",
)}
2025-09-18 18:49:30 +09:00
style={{ minHeight: "48px", height: "48px", lineHeight: "1" }}
2025-09-15 11:43:59 +09:00
onClick={() => handleRowClick(row)}
>
{visibleColumns.map((column) => (
2025-09-18 18:49:30 +09:00
<TableCell
key={column.columnName}
className={cn("h-12 align-middle whitespace-nowrap", `text-${column.align}`)}
style={{ minHeight: "48px", height: "48px", verticalAlign: "middle" }}
>
{column.columnName === "__checkbox__"
? renderCheckboxCell(row, index)
: formatCellValue(row[column.columnName], column.format, column.columnName) || "\u00A0"}
2025-09-15 11:43:59 +09:00
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
)}
</div>
{/* 푸터/페이지네이션 */}
{tableConfig.showFooter && tableConfig.pagination?.enabled && (
<div className="flex items-center justify-between border-t p-4">
<div className="text-sm text-gray-500">
{tableConfig.pagination?.showPageInfo && (
<span>
{totalItems.toLocaleString()} {(currentPage - 1) * localPageSize + 1}-
{Math.min(currentPage * localPageSize, totalItems)}
</span>
)}
</div>
<div className="flex items-center space-x-2">
{/* 페이지 크기 선택 */}
{tableConfig.pagination?.showSizeSelector && (
<select
value={localPageSize}
onChange={(e) => {
const newPageSize = parseInt(e.target.value);
// 로컬 상태만 업데이트 (데이터베이스에 저장하지 않음)
setLocalPageSize(newPageSize);
// 페이지를 1로 리셋
setCurrentPage(1);
// 데이터는 useEffect에서 자동으로 다시 로드됨
}}
className="rounded border px-2 py-1 text-sm"
>
{tableConfig.pagination?.pageSizeOptions?.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
)}
{/* 페이지네이션 버튼 */}
<div className="flex items-center space-x-1">
<Button variant="outline" size="sm" onClick={() => handlePageChange(1)} disabled={currentPage === 1}>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="px-3 py-1 text-sm">
{currentPage} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(totalPages)}
disabled={currentPage === totalPages}
>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)}
</div>
);
};
/**
* TableList
*
*/
export const TableListWrapper: React.FC<TableListComponentProps> = (props) => {
return <TableListComponent {...props} />;
};