"use client"; import React, { useState, useEffect, useMemo } from "react"; import { ComponentRendererProps } from "@/types/component"; import { TableListConfig, ColumnConfig, TableDataResponse } from "./types"; import { tableTypeApi } from "@/lib/api/screen"; import { entityJoinApi } from "@/lib/api/entityJoin"; import { commonCodeApi } from "@/lib/api/commonCode"; 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"; 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; 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; } /** * TableList 컴포넌트 * 데이터베이스 테이블의 데이터를 목록으로 표시하는 컴포넌트 */ export const TableListComponent: React.FC = ({ component, isDesignMode = false, isSelected = false, isInteractive = false, onClick, onDragStart, onDragEnd, config, className, style, formData, onFormDataChange, screenId, size, position, componentConfig, selectedScreen, onZoneComponentDrop, onZoneClick, tableName, onRefresh, onClose, }) => { // 컴포넌트 설정 const tableConfig = { ...config, ...component.config, ...componentConfig, } as TableListConfig; // 상태 관리 const [data, setData] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [currentPage, setCurrentPage] = useState(1); const [totalPages, setTotalPages] = useState(0); const [totalItems, setTotalItems] = useState(0); const [searchTerm, setSearchTerm] = useState(""); const [sortColumn, setSortColumn] = useState(null); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); const [columnLabels, setColumnLabels] = useState>({}); const [tableLabel, setTableLabel] = useState(""); const [localPageSize, setLocalPageSize] = useState(tableConfig.pagination?.pageSize || 20); // 로컬 페이지 크기 상태 const [selectedSearchColumn, setSelectedSearchColumn] = useState(""); // 선택된 검색 컬럼 const [displayColumns, setDisplayColumns] = useState([]); // 🎯 표시할 컬럼 (Entity 조인 적용됨) const [columnMeta, setColumnMeta] = useState>({}); // 🎯 컬럼 메타정보 (웹타입, 코드카테고리) const [codeCache, setCodeCache] = useState>>({}); // 🎯 코드명 캐시 (categoryCode: {codeValue: codeName}) // 높이 계산 함수 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 { const response = await tableTypeApi.getColumns(tableConfig.selectedTable); // API 응답 구조 확인 및 컬럼 배열 추출 const columns = response.columns || response; const labels: Record = {}; const meta: Record = {}; columns.forEach((column: any) => { labels[column.columnName] = column.displayName || column.columnName; // 🎯 웹타입과 코드카테고리 정보 저장 meta[column.columnName] = { webType: column.webType, codeCategory: column.codeCategory, }; }); setColumnLabels(labels); setColumnMeta(meta); console.log("🔍 컬럼 라벨 설정 완료:", labels); console.log("🔍 컬럼 메타정보 설정 완료:", meta); } catch (error) { console.log("컬럼 라벨 정보를 가져올 수 없습니다:", error); } }; // 🎯 코드 캐시 로드 함수 const loadCodeCache = async (categoryCode: string): Promise => { if (codeCache[categoryCode]) { return; // 이미 캐시됨 } try { const response = await commonCodeApi.options.getOptions(categoryCode); const codeMap: Record = {}; if (response.success && response.data) { response.data.forEach((option: any) => { // 🎯 대소문자 구분 없이 저장 (모두 대문자로 키 저장) codeMap[option.value?.toUpperCase()] = option.label; }); } setCodeCache((prev) => ({ ...prev, [categoryCode]: codeMap, })); console.log(`📋 코드 캐시 로드 완료 [${categoryCode}]:`, codeMap); } catch (error) { console.error(`❌ 코드 캐시 로드 실패 [${categoryCode}]:`, error); } }; // 🎯 코드값을 코드명으로 변환하는 함수 (대소문자 구분 없음) const convertCodeToName = (categoryCode: string, codeValue: string): string => { if (!categoryCode || !codeValue) return codeValue; const codes = codeCache[categoryCode]; if (!codes) return codeValue; // 🎯 대소문자 구분 없이 검색 const upperCodeValue = codeValue.toUpperCase(); return codes[upperCodeValue] || codeValue; }; // 테이블 라벨명 가져오기 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); const result = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, { page: currentPage, size: localPageSize, search: searchTerm?.trim() ? (() => { // 스마트한 단일 컬럼 선택 로직 (서버가 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 }; })() : undefined, sortBy: sortColumn || undefined, sortOrder: sortDirection, enableEntityJoin: true, // 🎯 Entity 조인 활성화 }); if (result) { 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 loadPromises = codeColumns.map(([_, meta]) => meta.codeCategory ? loadCodeCache(meta.codeCategory) : Promise.resolve(), ); try { await Promise.all(loadPromises); console.log("📋 모든 코드 캐시 로드 완료"); } catch (error) { console.error("❌ 코드 캐시 로드 중 오류:", error); } } // 🎯 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}`); 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}"`, ); } }); } // 컬럼 정보가 없으면 첫 번째 데이터 행에서 추출 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, })); // 컴포넌트 설정 업데이트 (부모 컴포넌트에 알림) if (onFormDataChange) { onFormDataChange({ ...component, config: { ...tableConfig, columns: autoColumns, }, }); } processedColumns = autoColumns; } // 🎯 표시할 컬럼 상태 업데이트 setDisplayColumns(processedColumns); } } 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(); }; // 효과 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]); // 표시할 컬럼 계산 (Entity 조인 적용됨) const visibleColumns = useMemo(() => { if (!displayColumns || displayColumns.length === 0) { // displayColumns가 아직 설정되지 않은 경우 기본 컬럼 사용 if (!tableConfig.columns) return []; return tableConfig.columns.filter((col) => col.visible).sort((a, b) => a.order - b.order); } return displayColumns.filter((col) => col.visible).sort((a, b) => a.order - b.order); }, [displayColumns, tableConfig.columns]); // 🎯 값 포맷팅 (코드 변환 포함) const formatCellValue = (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 = convertCodeToName(categoryCode, String(value)); if (convertedValue !== String(value)) { console.log(`🔄 코드 변환: ${columnName}[${categoryCode}] ${value} → ${convertedValue}`); } 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); } }; // 이벤트 핸들러 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 (
테이블 리스트
설정 패널에서 테이블을 선택해주세요
); } return (
{/* 헤더 */} {tableConfig.showHeader && (
{(tableConfig.title || tableLabel) && (

{tableConfig.title || tableLabel}

)}
{/* 검색 */} {tableConfig.filter?.enabled && tableConfig.filter?.quickSearch && (
handleSearch(e.target.value)} className="w-64 pl-8" />
{/* 검색 컬럼 선택 드롭다운 */} {tableConfig.filter?.showColumnSelector && ( )}
)} {/* 새로고침 */}
)} {/* 테이블 컨텐츠 */}
= 50 ? "overflow-auto" : "overflow-hidden"}`}> {loading ? (
데이터를 불러오는 중...
) : error ? (
오류가 발생했습니다
{error}
) : ( {visibleColumns.map((column) => ( column.sortable && handleSort(column.columnName)} >
{columnLabels[column.columnName] || column.displayName} {column.sortable && (
{sortColumn === column.columnName ? ( sortDirection === "asc" ? ( ) : ( ) ) : ( )}
)}
))}
{data.length === 0 ? ( 데이터가 없습니다 ) : ( data.map((row, index) => ( handleRowClick(row)} > {visibleColumns.map((column) => ( {formatCellValue(row[column.columnName], column.format, column.columnName)} ))} )) )}
)}
{/* 푸터/페이지네이션 */} {tableConfig.showFooter && tableConfig.pagination?.enabled && (
{tableConfig.pagination?.showPageInfo && ( 전체 {totalItems.toLocaleString()}건 중 {(currentPage - 1) * localPageSize + 1}- {Math.min(currentPage * localPageSize, totalItems)} 표시 )}
{/* 페이지 크기 선택 */} {tableConfig.pagination?.showSizeSelector && ( )} {/* 페이지네이션 버튼 */}
{currentPage} / {totalPages}
)}
); }; /** * TableList 래퍼 컴포넌트 * 추가적인 로직이나 상태 관리가 필요한 경우 사용 */ export const TableListWrapper: React.FC = (props) => { return ; };