ERP-node/frontend/components/screen/panels/TablesPanel.tsx

509 lines
19 KiB
TypeScript

"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Database,
Type,
Hash,
Calendar,
CheckSquare,
List,
AlignLeft,
Code,
Building,
File,
Link2,
ChevronDown,
ChevronRight,
Plus,
Search,
X,
} from "lucide-react";
import { TableInfo, WebType } from "@/types/screen";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { tableManagementApi } from "@/lib/api/tableManagement";
interface EntityJoinColumn {
columnName: string;
columnLabel: string;
dataType: string;
inputType?: string;
description?: string;
}
interface EntityJoinTable {
tableName: string;
currentDisplayColumn: string;
availableColumns: EntityJoinColumn[];
}
interface TablesPanelProps {
tables: TableInfo[];
searchTerm: string;
onSearchChange: (term: string) => void;
onDragStart: (e: React.DragEvent, table: TableInfo, column?: any) => void;
selectedTableName?: string;
placedColumns?: Set<string>; // 이미 배치된 컬럼명 집합 (tableName.columnName 형식)
// 테이블 선택 관련 props
onTableSelect?: (tableName: string) => void; // 테이블 선택 콜백
showTableSelector?: boolean; // 테이블 선택 UI 표시 여부
}
// 위젯 타입별 아이콘
const getWidgetIcon = (widgetType: WebType) => {
switch (widgetType) {
case "text":
case "email":
case "tel":
return <Type className="text-primary h-3 w-3" />;
case "number":
case "decimal":
return <Hash className="h-3 w-3 text-green-600" />;
case "date":
case "datetime":
return <Calendar className="h-3 w-3 text-purple-600" />;
case "select":
case "dropdown":
return <List className="h-3 w-3 text-orange-600" />;
case "textarea":
case "text_area":
return <AlignLeft className="h-3 w-3 text-indigo-600" />;
case "boolean":
case "checkbox":
return <CheckSquare className="text-primary h-3 w-3" />;
case "code":
return <Code className="text-muted-foreground h-3 w-3" />;
case "entity":
return <Building className="h-3 w-3 text-cyan-600" />;
case "file":
return <File className="h-3 w-3 text-yellow-600" />;
default:
return <Type className="h-3 w-3 text-gray-500" />;
}
};
export const TablesPanel: React.FC<TablesPanelProps> = ({
tables,
searchTerm,
onDragStart,
placedColumns = new Set(),
onTableSelect,
showTableSelector = false,
}) => {
// 엔티티 조인 컬럼 상태
const [entityJoinTables, setEntityJoinTables] = useState<EntityJoinTable[]>([]);
const [loadingEntityJoins, setLoadingEntityJoins] = useState(false);
const [expandedJoinTables, setExpandedJoinTables] = useState<Set<string>>(new Set());
// 전체 테이블 목록 (테이블 선택용)
const [allTables, setAllTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
const [loadingAllTables, setLoadingAllTables] = useState(false);
const [tableSearchTerm, setTableSearchTerm] = useState("");
const [showTableSelectDropdown, setShowTableSelectDropdown] = useState(false);
// 시스템 컬럼 목록 (숨김 처리)
const systemColumns = new Set([
"id",
"created_date",
"updated_date",
"writer",
"company_code",
]);
// 전체 테이블 목록 로드
const loadAllTables = useCallback(async () => {
if (allTables.length > 0) return; // 이미 로드됨
setLoadingAllTables(true);
try {
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
setAllTables(response.data.map((t: any) => ({
tableName: t.tableName || t.table_name,
displayName: t.displayName || t.table_label || t.tableName || t.table_name,
})));
}
} catch (error) {
console.error("테이블 목록 조회 오류:", error);
} finally {
setLoadingAllTables(false);
}
}, [allTables.length]);
// 테이블 선택 시 호출
const handleTableSelect = (tableName: string) => {
setShowTableSelectDropdown(false);
setTableSearchTerm("");
onTableSelect?.(tableName);
};
// 필터링된 테이블 목록
const filteredAllTables = tableSearchTerm
? allTables.filter(
(t) =>
t.tableName.toLowerCase().includes(tableSearchTerm.toLowerCase()) ||
t.displayName.toLowerCase().includes(tableSearchTerm.toLowerCase())
)
: allTables;
// 메인 테이블명 추출
const mainTableName = tables[0]?.tableName;
// 엔티티 조인 컬럼 로드
useEffect(() => {
const fetchEntityJoinColumns = async () => {
if (!mainTableName) {
setEntityJoinTables([]);
return;
}
setLoadingEntityJoins(true);
try {
const result = await entityJoinApi.getEntityJoinColumns(mainTableName);
setEntityJoinTables(result.joinTables || []);
// 기본적으로 모든 조인 테이블 펼치기
setExpandedJoinTables(new Set(result.joinTables?.map((t) => t.tableName) || []));
} catch (error) {
console.error("엔티티 조인 컬럼 조회 오류:", error);
setEntityJoinTables([]);
} finally {
setLoadingEntityJoins(false);
}
};
fetchEntityJoinColumns();
}, [mainTableName]);
// 조인 테이블 펼치기/접기 토글
const toggleJoinTable = (tableName: string) => {
setExpandedJoinTables((prev) => {
const newSet = new Set(prev);
if (newSet.has(tableName)) {
newSet.delete(tableName);
} else {
newSet.add(tableName);
}
return newSet;
});
};
// 엔티티 조인 컬럼 드래그 핸들러
const handleEntityJoinDragStart = (
e: React.DragEvent,
joinTable: EntityJoinTable,
column: EntityJoinColumn,
) => {
// "테이블명.컬럼명" 형식으로 컬럼 정보 생성
const fullColumnName = `${joinTable.tableName}.${column.columnName}`;
const columnData = {
columnName: fullColumnName,
columnLabel: column.columnLabel || column.columnName,
dataType: column.dataType,
widgetType: "text" as WebType,
isEntityJoin: true,
entityJoinTable: joinTable.tableName,
entityJoinColumn: column.columnName,
};
// 기존 테이블 정보를 기반으로 가상의 테이블 정보 생성
const virtualTable: TableInfo = {
tableName: mainTableName || "",
tableLabel: tables[0]?.tableLabel || mainTableName || "",
columns: [columnData],
};
onDragStart(e, virtualTable, columnData);
};
// 이미 배치된 컬럼과 시스템 컬럼을 제외한 테이블 정보 생성
const tablesWithAvailableColumns = tables.map((table) => ({
...table,
columns: table.columns.filter((col) => {
const columnKey = `${table.tableName}.${col.columnName}`;
// 시스템 컬럼이거나 이미 배치된 컬럼은 제외
return !systemColumns.has(col.columnName.toLowerCase()) && !placedColumns.has(columnKey);
}),
}));
// 검색어가 있으면 컬럼 필터링
const filteredTables = tablesWithAvailableColumns
.map((table) => {
if (!searchTerm) {
return table;
}
const searchLower = searchTerm.toLowerCase();
// 테이블명이 검색어와 일치하면 모든 컬럼 표시
if (
table.tableName.toLowerCase().includes(searchLower) ||
(table.tableLabel && table.tableLabel.toLowerCase().includes(searchLower))
) {
return table;
}
// 그렇지 않으면 컬럼명/라벨이 검색어와 일치하는 컬럼만 필터링
const filteredColumns = table.columns.filter(
(col) =>
col.columnName.toLowerCase().includes(searchLower) ||
(col.columnLabel && col.columnLabel.toLowerCase().includes(searchLower)),
);
return {
...table,
columns: filteredColumns,
};
})
.filter((table) => table.columns.length > 0); // 컬럼이 있는 테이블만 표시
return (
<div className="flex h-full flex-col">
{/* 테이블 선택 버튼 (메인 테이블이 없을 때 또는 showTableSelector가 true일 때) */}
{(showTableSelector || tables.length === 0) && (
<div className="border-b p-3">
<div className="relative">
<Button
variant="outline"
size="sm"
className="w-full justify-between"
onClick={() => {
setShowTableSelectDropdown(!showTableSelectDropdown);
if (!showTableSelectDropdown) {
loadAllTables();
}
}}
>
<span className="flex items-center gap-2">
<Plus className="h-3.5 w-3.5" />
{tables.length > 0 ? "테이블 추가/변경" : "테이블 선택"}
</span>
<ChevronDown className="h-4 w-4" />
</Button>
{/* 드롭다운 */}
{showTableSelectDropdown && (
<div className="absolute top-full left-0 z-50 mt-1 w-full rounded-md border bg-white shadow-lg">
{/* 검색 */}
<div className="border-b p-2">
<div className="relative">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="테이블 검색..."
value={tableSearchTerm}
onChange={(e) => setTableSearchTerm(e.target.value)}
autoFocus
className="w-full rounded-md border px-8 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{tableSearchTerm && (
<button
onClick={() => setTableSearchTerm("")}
className="absolute right-2 top-1/2 -translate-y-1/2"
>
<X className="h-3.5 w-3.5 text-gray-400" />
</button>
)}
</div>
</div>
{/* 테이블 목록 */}
<div className="max-h-60 overflow-y-auto">
{loadingAllTables ? (
<div className="p-4 text-center text-sm text-gray-500"> ...</div>
) : filteredAllTables.length === 0 ? (
<div className="p-4 text-center text-sm text-gray-500">
{tableSearchTerm ? "검색 결과 없음" : "테이블 없음"}
</div>
) : (
filteredAllTables.map((t) => (
<button
key={t.tableName}
onClick={() => handleTableSelect(t.tableName)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-gray-100"
>
<Database className="h-3.5 w-3.5 text-blue-600" />
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{t.displayName}</div>
<div className="truncate text-xs text-gray-500">{t.tableName}</div>
</div>
</button>
))
)}
</div>
</div>
)}
</div>
{/* 현재 테이블 정보 */}
{tables.length > 0 && (
<div className="mt-2 text-xs text-muted-foreground">
: {tables[0]?.tableLabel || tables[0]?.tableName}
</div>
)}
</div>
)}
{/* 테이블과 컬럼 평면 목록 */}
<div className="flex-1 overflow-y-auto p-3">
<div className="space-y-2">
{filteredTables.map((table) => (
<div key={table.tableName} className="space-y-1">
{/* 테이블 헤더 */}
<div className="bg-muted/50 flex items-center justify-between rounded-md p-2">
<div className="flex items-center gap-2">
<Database className="text-primary h-3.5 w-3.5" />
<span className="text-xs font-semibold">{table.tableLabel || table.tableName}</span>
<Badge variant="secondary" className="h-4 px-1.5 text-[10px]">
{table.columns.length}
</Badge>
</div>
</div>
{/* 컬럼 목록 (항상 표시) */}
<div className="space-y-1 pl-2">
{table.columns.map((column) => (
<div
key={column.columnName}
className="hover:bg-accent/50 flex cursor-grab items-center gap-2 rounded-md p-2 transition-colors"
draggable
onDragStart={(e) => onDragStart(e, table, column)}
>
{getWidgetIcon(column.widgetType)}
<div className="min-w-0 flex-1">
<div
className="text-xs font-medium"
title={column.columnLabel || column.columnName}
>
{column.columnLabel || column.columnName}
</div>
</div>
<div className="flex flex-shrink-0 items-center gap-1">
<Badge variant="secondary" className="h-4 px-1.5 text-[10px]">
{column.widgetType}
</Badge>
{column.required && (
<Badge variant="destructive" className="h-4 px-1 text-[10px]">
</Badge>
)}
</div>
</div>
))}
</div>
</div>
))}
{/* 엔티티 조인 컬럼 섹션 */}
{entityJoinTables.length > 0 && (
<div className="mt-4 space-y-2">
<div className="flex items-center gap-2 px-2 py-1">
<Link2 className="h-3.5 w-3.5 text-cyan-600" />
<span className="text-muted-foreground text-xs font-medium"> </span>
<Badge variant="outline" className="h-4 px-1.5 text-[10px]">
{entityJoinTables.length}
</Badge>
</div>
{entityJoinTables.map((joinTable) => {
const isExpanded = expandedJoinTables.has(joinTable.tableName);
// 검색어로 필터링
const filteredColumns = searchTerm
? joinTable.availableColumns.filter(
(col) =>
col.columnName.toLowerCase().includes(searchTerm.toLowerCase()) ||
col.columnLabel.toLowerCase().includes(searchTerm.toLowerCase()),
)
: joinTable.availableColumns;
// 검색 결과가 없으면 표시하지 않음
if (searchTerm && filteredColumns.length === 0) {
return null;
}
return (
<div key={joinTable.tableName} className="space-y-1">
{/* 조인 테이블 헤더 */}
<div
className="flex cursor-pointer items-center justify-between rounded-md bg-cyan-50 p-2 hover:bg-cyan-100"
onClick={() => toggleJoinTable(joinTable.tableName)}
>
<div className="flex items-center gap-2">
{isExpanded ? (
<ChevronDown className="h-3 w-3 text-cyan-600" />
) : (
<ChevronRight className="h-3 w-3 text-cyan-600" />
)}
<Building className="h-3.5 w-3.5 text-cyan-600" />
<span className="text-xs font-semibold text-cyan-800">{joinTable.tableName}</span>
<Badge variant="secondary" className="h-4 px-1.5 text-[10px]">
{filteredColumns.length}
</Badge>
</div>
</div>
{/* 조인 컬럼 목록 */}
{isExpanded && (
<div className="space-y-1 pl-4">
{filteredColumns.map((column) => {
const fullColumnName = `${joinTable.tableName}.${column.columnName}`;
const isPlaced = placedColumns.has(fullColumnName);
if (isPlaced) return null;
return (
<div
key={column.columnName}
className="flex cursor-grab items-center gap-2 rounded-md border border-cyan-200 bg-cyan-50/50 p-2 transition-colors hover:bg-cyan-100"
draggable
onDragStart={(e) => handleEntityJoinDragStart(e, joinTable, column)}
title="읽기 전용 - 조인된 테이블에서 참조"
>
<Link2 className="h-3 w-3 flex-shrink-0 text-cyan-500" />
<div className="min-w-0 flex-1">
<div className="text-xs font-medium" title={column.columnLabel || column.columnName}>
{column.columnLabel || column.columnName}
</div>
</div>
<div className="flex flex-shrink-0 items-center gap-1">
<Badge variant="secondary" className="h-4 border-gray-300 bg-gray-100 px-1 text-[9px] text-gray-600">
</Badge>
<Badge variant="outline" className="h-4 border-cyan-300 px-1.5 text-[10px]">
{column.inputType || "text"}
</Badge>
</div>
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
)}
{/* 로딩 표시 */}
{loadingEntityJoins && (
<div className="text-muted-foreground flex items-center justify-center py-4 text-xs">
...
</div>
)}
</div>
</div>
</div>
);
};
export default TablesPanel;