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-18 19:15:13 +09:00
|
|
|
import { codeCache } from "@/lib/caching/codeCache";
|
2025-09-16 16:53:03 +09:00
|
|
|
import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization";
|
2025-09-15 11:43:59 +09:00
|
|
|
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";
|
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";
|
2025-09-23 14:26:18 +09:00
|
|
|
import { AdvancedSearchFilters } from "@/components/screen/filters/AdvancedSearchFilters";
|
|
|
|
|
import { Separator } from "@/components/ui/separator";
|
2025-09-23 15:31:27 +09:00
|
|
|
import { SingleTableWithSticky } from "./SingleTableWithSticky";
|
2025-09-15 11:43:59 +09:00
|
|
|
|
|
|
|
|
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); // 로컬 페이지 크기 상태
|
2025-09-16 15:13:00 +09:00
|
|
|
const [displayColumns, setDisplayColumns] = useState<ColumnConfig[]>([]); // 🎯 표시할 컬럼 (Entity 조인 적용됨)
|
2025-09-16 15:52:37 +09:00
|
|
|
const [columnMeta, setColumnMeta] = useState<Record<string, { webType?: string; codeCategory?: string }>>({}); // 🎯 컬럼 메타정보 (웹타입, 코드카테고리)
|
2025-09-18 18:49:30 +09:00
|
|
|
|
2025-09-23 14:26:18 +09:00
|
|
|
// 고급 필터 관련 state
|
|
|
|
|
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
|
|
|
|
|
|
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
|
|
|
|
2025-09-23 15:31:27 +09:00
|
|
|
// 높이 계산 함수 (메모이제이션)
|
|
|
|
|
const optimalHeight = useMemo(() => {
|
|
|
|
|
// 50개 이상일 때는 20개 기준으로 높이 고정하고 스크롤 생성
|
|
|
|
|
// 50개 미만일 때는 실제 데이터 개수에 맞춰서 스크롤 없이 표시
|
|
|
|
|
const actualDataCount = Math.min(data.length, localPageSize);
|
|
|
|
|
const displayPageSize = localPageSize >= 50 ? 20 : Math.max(actualDataCount, 5);
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
willHaveScroll: localPageSize >= 50,
|
|
|
|
|
titleHeight,
|
|
|
|
|
searchHeight,
|
|
|
|
|
headerHeight,
|
|
|
|
|
rowHeight,
|
|
|
|
|
footerHeight,
|
|
|
|
|
padding,
|
|
|
|
|
calculatedHeight,
|
|
|
|
|
finalHeight: `${calculatedHeight}px`,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 추가 디버깅: 실제 데이터 상황
|
|
|
|
|
console.log("🔍 실제 데이터 상황:", {
|
|
|
|
|
actualDataLength: data.length,
|
|
|
|
|
localPageSize,
|
|
|
|
|
currentPage,
|
|
|
|
|
totalItems,
|
|
|
|
|
totalPages,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return calculatedHeight;
|
|
|
|
|
}, [data.length, localPageSize, tableConfig.filter?.enabled, tableConfig.showFooter, tableConfig.showHeader]);
|
2025-09-15 11:43:59 +09:00
|
|
|
|
|
|
|
|
// 스타일 계산
|
|
|
|
|
const componentStyle: React.CSSProperties = {
|
|
|
|
|
width: "100%",
|
2025-09-23 15:31:27 +09:00
|
|
|
height: `${optimalHeight}px`, // 20개 데이터를 모두 보여주는 높이
|
|
|
|
|
minHeight: `${optimalHeight}px`, // 최소 높이 보장
|
2025-09-15 11:43:59 +09:00
|
|
|
...component.style,
|
|
|
|
|
...style,
|
|
|
|
|
display: "flex",
|
|
|
|
|
flexDirection: "column",
|
2025-09-23 15:31:27 +09:00
|
|
|
boxSizing: "border-box", // 패딩/보더 포함한 크기 계산
|
2025-09-15 11:43:59 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 디자인 모드 스타일
|
|
|
|
|
if (isDesignMode) {
|
|
|
|
|
componentStyle.border = "1px dashed #cbd5e1";
|
|
|
|
|
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
|
2025-09-23 15:31:27 +09:00
|
|
|
// minHeight 제거 - 실제 데이터에 맞는 높이 사용
|
2025-09-15 11:43:59 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 컬럼 라벨 정보 가져오기
|
|
|
|
|
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> = {};
|
2025-09-16 15:52:37 +09:00
|
|
|
const meta: Record<string, { webType?: string; codeCategory?: string }> = {};
|
|
|
|
|
|
2025-09-15 11:43:59 +09:00
|
|
|
columns.forEach((column: any) => {
|
2025-09-17 10:35:36 +09:00
|
|
|
// 🎯 Entity 조인된 컬럼의 경우 표시 컬럼명 사용
|
|
|
|
|
let displayLabel = column.displayName || column.columnName;
|
|
|
|
|
|
2025-09-23 16:23:36 +09:00
|
|
|
// Entity 타입인 경우
|
|
|
|
|
if (column.webType === "entity") {
|
2025-09-23 16:51:12 +09:00
|
|
|
// 우선 기준 테이블의 컬럼 라벨을 사용
|
|
|
|
|
displayLabel = column.displayName || column.columnName;
|
2025-09-17 10:35:36 +09:00
|
|
|
console.log(
|
2025-09-23 16:51:12 +09:00
|
|
|
`🎯 Entity 조인 컬럼 라벨 설정: ${column.columnName} → "${displayLabel}" (기준 테이블 라벨 사용)`,
|
2025-09-17 10:35:36 +09:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
labels[column.columnName] = displayLabel;
|
2025-09-16 15:52:37 +09:00
|
|
|
// 🎯 웹타입과 코드카테고리 정보 저장
|
|
|
|
|
meta[column.columnName] = {
|
|
|
|
|
webType: column.webType,
|
|
|
|
|
codeCategory: column.codeCategory,
|
|
|
|
|
};
|
2025-09-15 11:43:59 +09:00
|
|
|
});
|
2025-09-16 15:52:37 +09:00
|
|
|
|
2025-09-15 11:43:59 +09:00
|
|
|
setColumnLabels(labels);
|
2025-09-16 15:52:37 +09:00
|
|
|
setColumnMeta(meta);
|
2025-09-16 15:33:46 +09:00
|
|
|
console.log("🔍 컬럼 라벨 설정 완료:", labels);
|
2025-09-16 15:52:37 +09:00
|
|
|
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-16 15:52:37 +09:00
|
|
|
|
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);
|
|
|
|
|
|
2025-09-16 18:02:19 +09:00
|
|
|
// 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,
|
|
|
|
|
}));
|
|
|
|
|
|
2025-09-23 16:23:36 +09:00
|
|
|
// 🎯 화면별 엔티티 표시 설정 생성
|
|
|
|
|
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 || " - ",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-09-16 18:02:19 +09:00
|
|
|
console.log("🔗 추가 Entity 조인 컬럼:", additionalJoinColumns);
|
2025-09-23 16:23:36 +09:00
|
|
|
console.log("🎯 화면별 엔티티 설정:", screenEntityConfigs);
|
2025-09-16 18:02:19 +09:00
|
|
|
|
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-23 14:26:18 +09:00
|
|
|
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 !== "";
|
|
|
|
|
});
|
2025-09-15 11:43:59 +09:00
|
|
|
|
2025-09-23 14:26:18 +09:00
|
|
|
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"),
|
2025-09-15 11:43:59 +09:00
|
|
|
);
|
|
|
|
|
|
2025-09-23 14:26:18 +09:00
|
|
|
// 우선순위에 따라 선택
|
|
|
|
|
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;
|
|
|
|
|
})(),
|
2025-09-15 11:43:59 +09:00
|
|
|
sortBy: sortColumn || undefined,
|
|
|
|
|
sortOrder: sortDirection,
|
2025-09-16 15:13:00 +09:00
|
|
|
enableEntityJoin: true, // 🎯 Entity 조인 활성화
|
2025-09-16 18:02:19 +09:00
|
|
|
additionalJoinColumns: additionalJoinColumns.length > 0 ? additionalJoinColumns : undefined, // 추가 조인 컬럼
|
2025-09-23 16:23:36 +09:00
|
|
|
screenEntityConfigs: Object.keys(screenEntityConfigs).length > 0 ? screenEntityConfigs : 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
|
|
|
// 🎯 코드 컬럼들의 캐시 미리 로드 (전역 캐시 사용)
|
2025-09-16 15:52:37 +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[];
|
2025-09-16 15:52:37 +09:00
|
|
|
|
|
|
|
|
try {
|
2025-09-16 16:53:03 +09:00
|
|
|
await codeCache.preloadCodes(categoryList);
|
|
|
|
|
console.log("📋 모든 코드 캐시 로드 완료 (전역 캐시)");
|
2025-09-16 15:52:37 +09:00
|
|
|
} 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");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-09-23 14:26:18 +09:00
|
|
|
// 고급 필터 핸들러
|
|
|
|
|
const handleSearchValueChange = (columnName: string, value: any) => {
|
|
|
|
|
setSearchValues((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
[columnName]: value,
|
|
|
|
|
}));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleAdvancedSearch = () => {
|
|
|
|
|
setCurrentPage(1);
|
|
|
|
|
fetchTableData();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleClearAdvancedFilters = () => {
|
|
|
|
|
setSearchValues({});
|
|
|
|
|
setCurrentPage(1);
|
|
|
|
|
fetchTableData();
|
2025-09-15 11:43:59 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 새로고침
|
|
|
|
|
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();
|
|
|
|
|
}
|
2025-09-23 14:26:18 +09:00
|
|
|
}, [
|
|
|
|
|
tableConfig.selectedTable,
|
|
|
|
|
localPageSize,
|
|
|
|
|
currentPage,
|
|
|
|
|
searchTerm,
|
|
|
|
|
sortColumn,
|
|
|
|
|
sortDirection,
|
|
|
|
|
columnLabels,
|
|
|
|
|
searchValues,
|
|
|
|
|
]);
|
2025-09-15 11:43:59 +09:00
|
|
|
|
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]);
|
|
|
|
|
|
2025-09-19 12:19:34 +09:00
|
|
|
// 표시할 컬럼 계산 (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-19 12:19:34 +09:00
|
|
|
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);
|
2025-09-18 18:49:30 +09:00
|
|
|
} else {
|
2025-09-19 12:19:34 +09:00
|
|
|
columns = displayColumns
|
|
|
|
|
.filter((col) => {
|
|
|
|
|
// 디자인 모드에서는 숨김 컬럼도 표시 (연하게), 실제 화면에서는 완전히 숨김
|
|
|
|
|
if (isDesignMode) {
|
|
|
|
|
return col.visible; // 디자인 모드에서는 visible만 체크
|
|
|
|
|
} else {
|
|
|
|
|
return col.visible && !col.hidden; // 실제 화면에서는 visible이면서 hidden이 아닌 것만
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.sort((a, b) => a.order - b.order);
|
2025-09-18 18:49:30 +09:00
|
|
|
}
|
|
|
|
|
|
2025-09-23 14:26:18 +09:00
|
|
|
// 체크박스가 활성화되고 실제 데이터 컬럼이 있는 경우에만 체크박스 컬럼을 추가
|
|
|
|
|
if (checkboxConfig.enabled && columns.length > 0) {
|
2025-09-18 18:49:30 +09:00
|
|
|
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-23 15:31:27 +09:00
|
|
|
// columnsByPosition은 SingleTableWithSticky에서 사용하지 않으므로 제거
|
|
|
|
|
// 기존 테이블에서만 필요한 경우 다시 추가 가능
|
2025-09-18 15:14:14 +09:00
|
|
|
|
|
|
|
|
// 가로 스크롤이 필요한지 계산
|
|
|
|
|
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-19 02:15:21 +09:00
|
|
|
// 디버깅: 모든 값 변환 시도를 로깅
|
|
|
|
|
if (
|
|
|
|
|
columnName &&
|
|
|
|
|
(columnName === "contract_type" || columnName === "domestic_foreign" || columnName === "status")
|
|
|
|
|
) {
|
|
|
|
|
console.log(`🔍 값 변환 시도: ${columnName}="${value}"`, {
|
|
|
|
|
columnMeta: columnMeta[columnName],
|
|
|
|
|
hasColumnMeta: !!columnMeta[columnName],
|
|
|
|
|
webType: columnMeta[columnName]?.webType,
|
|
|
|
|
codeCategory: columnMeta[columnName]?.codeCategory,
|
|
|
|
|
globalColumnMeta: Object.keys(columnMeta),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
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 15:52:37 +09:00
|
|
|
|
2025-09-16 16:53:03 +09:00
|
|
|
if (convertedValue !== String(value)) {
|
2025-09-19 02:15:21 +09:00
|
|
|
console.log(`🔄 코드 변환 성공: ${columnName}[${categoryCode}] ${value} → ${convertedValue}`);
|
|
|
|
|
} else {
|
|
|
|
|
console.log(`⚠️ 코드 변환 실패: ${columnName}[${categoryCode}] ${value} → ${convertedValue} (값 동일)`);
|
2025-09-16 16:53:03 +09:00
|
|
|
}
|
2025-09-16 15:52:37 +09:00
|
|
|
|
2025-09-16 16:53:03 +09:00
|
|
|
value = convertedValue;
|
|
|
|
|
}
|
2025-09-16 15:52:37 +09:00
|
|
|
|
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-23 14:26:18 +09:00
|
|
|
{/* 검색 - 기존 방식은 주석처리 */}
|
|
|
|
|
{/* {tableConfig.filter?.enabled && tableConfig.filter?.quickSearch && (
|
2025-09-15 11:43:59 +09:00
|
|
|
<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>
|
2025-09-23 14:26:18 +09:00
|
|
|
)} */}
|
2025-09-15 11:43:59 +09:00
|
|
|
|
|
|
|
|
{/* 새로고침 */}
|
|
|
|
|
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={loading}>
|
|
|
|
|
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-09-23 14:26:18 +09:00
|
|
|
{/* 고급 검색 필터 - 항상 표시 (컬럼 정보 기반 자동 생성) */}
|
|
|
|
|
{tableConfig.filter?.enabled && visibleColumns && visibleColumns.length > 0 && (
|
|
|
|
|
<>
|
2025-09-23 15:31:27 +09:00
|
|
|
<Separator className="my-1" />
|
2025-09-23 14:26:18 +09:00
|
|
|
<AdvancedSearchFilters
|
|
|
|
|
filters={tableConfig.filter?.filters || []} // 설정된 필터 사용, 없으면 자동 생성
|
|
|
|
|
searchValues={searchValues}
|
|
|
|
|
onSearchValueChange={handleSearchValueChange}
|
|
|
|
|
onSearch={handleAdvancedSearch}
|
|
|
|
|
onClearFilters={handleClearAdvancedFilters}
|
|
|
|
|
tableColumns={visibleColumns.map((col) => ({
|
|
|
|
|
columnName: col.columnName,
|
|
|
|
|
webType: columnMeta[col.columnName]?.webType || "text",
|
|
|
|
|
displayName: columnLabels[col.columnName] || col.displayName || col.columnName,
|
|
|
|
|
codeCategory: columnMeta[col.columnName]?.codeCategory,
|
|
|
|
|
isVisible: col.visible,
|
|
|
|
|
// 추가 메타데이터 전달 (필터 자동 생성용)
|
|
|
|
|
web_type: columnMeta[col.columnName]?.webType || "text",
|
|
|
|
|
column_name: col.columnName,
|
|
|
|
|
column_label: columnLabels[col.columnName] || col.displayName || col.columnName,
|
|
|
|
|
code_category: columnMeta[col.columnName]?.codeCategory,
|
|
|
|
|
}))}
|
|
|
|
|
tableName={tableConfig.selectedTable}
|
|
|
|
|
/>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-09-15 11:43:59 +09:00
|
|
|
{/* 테이블 컨텐츠 */}
|
2025-09-23 15:31:27 +09:00
|
|
|
<div className={`w-full ${localPageSize >= 50 ? "flex-1 overflow-auto" : ""}`}>
|
2025-09-15 11:43:59 +09:00
|
|
|
{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 ? (
|
2025-09-23 15:31:27 +09:00
|
|
|
// 가로 스크롤이 필요한 경우 - 단일 테이블에서 sticky 컬럼 사용
|
|
|
|
|
<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}
|
|
|
|
|
/>
|
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-23 15:31:27 +09:00
|
|
|
<TableRow style={{ minHeight: "40px !important", height: "40px !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,
|
2025-09-23 15:31:27 +09:00
|
|
|
minHeight: "40px !important",
|
|
|
|
|
height: "40px !important",
|
2025-09-18 18:49:30 +09:00
|
|
|
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__"
|
2025-09-23 15:31:27 +09:00
|
|
|
? "h-10 text-center align-middle"
|
|
|
|
|
: "h-10 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-23 15:31:27 +09:00
|
|
|
"h-10 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-23 15:31:27 +09:00
|
|
|
style={{ minHeight: "40px", height: "40px", 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}
|
2025-09-23 15:31:27 +09:00
|
|
|
className={cn("h-10 align-middle whitespace-nowrap", `text-${column.align}`)}
|
|
|
|
|
style={{ minHeight: "40px", height: "40px", verticalAlign: "middle" }}
|
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-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} />;
|
|
|
|
|
};
|