365 lines
13 KiB
TypeScript
365 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect } from "react";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Database,
|
|
Type,
|
|
Hash,
|
|
Calendar,
|
|
CheckSquare,
|
|
List,
|
|
AlignLeft,
|
|
Code,
|
|
Building,
|
|
File,
|
|
Link2,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
} from "lucide-react";
|
|
import { TableInfo, WebType } from "@/types/screen";
|
|
import { entityJoinApi } from "@/lib/api/entityJoin";
|
|
|
|
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 형식)
|
|
}
|
|
|
|
// 위젯 타입별 아이콘
|
|
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(),
|
|
}) => {
|
|
// 엔티티 조인 컬럼 상태
|
|
const [entityJoinTables, setEntityJoinTables] = useState<EntityJoinTable[]>([]);
|
|
const [loadingEntityJoins, setLoadingEntityJoins] = useState(false);
|
|
const [expandedJoinTables, setExpandedJoinTables] = useState<Set<string>>(new Set());
|
|
|
|
// 시스템 컬럼 목록 (숨김 처리)
|
|
const systemColumns = new Set([
|
|
"id",
|
|
"created_date",
|
|
"updated_date",
|
|
"writer",
|
|
"company_code",
|
|
]);
|
|
|
|
// 메인 테이블명 추출
|
|
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">
|
|
{/* 테이블과 컬럼 평면 목록 */}
|
|
<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;
|