ERP-node/frontend/components/screen/NodeSettingModal.tsx

1890 lines
64 KiB
TypeScript

"use client";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import {
Database,
Link2,
GitBranch,
Columns3,
Save,
Plus,
Pencil,
Trash2,
RefreshCw,
Loader2,
Check,
ChevronsUpDown,
} from "lucide-react";
import {
getTableRelations,
createTableRelation,
updateTableRelation,
deleteTableRelation,
getFieldJoins,
createFieldJoin,
updateFieldJoin,
deleteFieldJoin,
getDataFlows,
createDataFlow,
updateDataFlow,
deleteDataFlow,
FieldJoin,
DataFlow,
TableRelation,
} from "@/lib/api/screenGroup";
import { tableManagementApi, ColumnTypeInfo, TableInfo } from "@/lib/api/tableManagement";
// ============================================================
// 타입 정의
// ============================================================
// 기존 설정 정보 (화면 디자이너에서 추출)
interface ExistingConfig {
joinColumnRefs?: Array<{
column: string;
refTable: string;
refTableLabel?: string;
refColumn: string;
}>;
filterColumns?: string[];
fieldMappings?: Array<{
targetField: string;
sourceField: string;
sourceTable?: string;
sourceDisplayName?: string;
}>;
referencedBy?: Array<{
fromTable: string;
fromTableLabel?: string;
fromColumn: string;
toColumn: string;
toColumnLabel?: string;
relationType: string;
}>;
columns?: Array<{
name: string;
originalName?: string;
type: string;
isPrimaryKey?: boolean;
isForeignKey?: boolean;
}>;
// 화면 노드용 테이블 정보
mainTable?: string;
filterTables?: Array<{
tableName: string;
tableLabel: string;
filterColumns: string[];
joinColumnRefs: Array<{
column: string;
refTable: string;
refTableLabel?: string;
refColumn: string;
}>;
}>;
}
interface NodeSettingModalProps {
isOpen: boolean;
onClose: () => void;
// 노드 정보
nodeType: "screen" | "table";
nodeId: string; // 노드 ID (예: screen-1, table-sales_order_mng)
screenId: number;
screenName: string;
tableName?: string; // 테이블 노드인 경우
tableLabel?: string;
// 그룹 정보 (데이터 흐름 설정에 필요)
groupId?: number;
groupScreens?: Array<{ screen_id: number; screen_name: string }>;
// 기존 설정 정보 (화면 디자이너에서 추출한 조인/필터 정보)
existingConfig?: ExistingConfig;
// 새로고침 콜백
onRefresh?: () => void;
}
// 탭 ID
type TabId = "table-relation" | "join-setting" | "data-flow" | "field-mapping";
// ============================================================
// 검색 가능한 셀렉트 컴포넌트
// ============================================================
interface SearchableSelectProps {
value: string;
onValueChange: (value: string) => void;
options: Array<{ value: string; label: string; description?: string }>;
placeholder?: string;
searchPlaceholder?: string;
emptyText?: string;
disabled?: boolean;
className?: string;
}
function SearchableSelect({
value,
onValueChange,
options,
placeholder = "선택",
searchPlaceholder = "검색...",
emptyText = "항목을 찾을 수 없습니다.",
disabled = false,
className,
}: SearchableSelectProps) {
const [open, setOpen] = useState(false);
const selectedOption = options.find((opt) => opt.value === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn(
"h-9 w-full justify-between text-xs font-normal",
!value && "text-muted-foreground",
className
)}
>
<span className="truncate">
{selectedOption?.label || placeholder}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder={searchPlaceholder} className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs py-4 text-center">
{emptyText}
</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => {
onValueChange(option.value);
setOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === option.value ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span>{option.label}</span>
{option.description && (
<span className="text-[10px] text-muted-foreground">
{option.description}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// ============================================================
// 컴포넌트
// ============================================================
export default function NodeSettingModal({
isOpen,
onClose,
nodeType,
nodeId,
screenId,
screenName,
tableName,
tableLabel,
groupId,
groupScreens = [],
existingConfig,
onRefresh,
}: NodeSettingModalProps) {
// 탭 상태
const [activeTab, setActiveTab] = useState<TabId>("table-relation");
// 로딩 상태
const [loading, setLoading] = useState(false);
// 테이블 목록 (조인/필터 설정용)
const [tables, setTables] = useState<TableInfo[]>([]);
const [tableColumns, setTableColumns] = useState<Record<string, ColumnTypeInfo[]>>({});
// 테이블 연결 데이터
const [tableRelations, setTableRelations] = useState<TableRelation[]>([]);
// 조인 설정 데이터
const [fieldJoins, setFieldJoins] = useState<FieldJoin[]>([]);
// 데이터 흐름 데이터
const [dataFlows, setDataFlows] = useState<DataFlow[]>([]);
// ============================================================
// 데이터 로드
// ============================================================
// 테이블 목록 로드
const loadTables = useCallback(async () => {
try {
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
setTables(response.data);
}
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
}
}, []);
// 테이블 컬럼 로드
const loadTableColumns = useCallback(async (tblName: string) => {
if (tableColumns[tblName]) return; // 이미 로드됨
try {
const response = await tableManagementApi.getColumnList(tblName);
if (response.success && response.data) {
setTableColumns(prev => ({
...prev,
[tblName]: response.data?.columns || [],
}));
}
} catch (error) {
console.error(`테이블 컬럼 로드 실패 (${tblName}):`, error);
}
}, [tableColumns]);
// 테이블 연결 로드
const loadTableRelations = useCallback(async () => {
if (!screenId) return;
setLoading(true);
try {
const response = await getTableRelations({ screen_id: screenId });
if (response.success && response.data) {
setTableRelations(response.data);
}
} catch (error) {
console.error("테이블 연결 로드 실패:", error);
} finally {
setLoading(false);
}
}, [screenId]);
// 조인 설정 로드
const loadFieldJoins = useCallback(async () => {
if (!screenId) return;
setLoading(true);
try {
const response = await getFieldJoins(screenId);
if (response.success && response.data) {
setFieldJoins(response.data);
}
} catch (error) {
console.error("조인 설정 로드 실패:", error);
} finally {
setLoading(false);
}
}, [screenId]);
// 데이터 흐름 로드
const loadDataFlows = useCallback(async () => {
if (!groupId) return;
setLoading(true);
try {
const response = await getDataFlows(groupId);
if (response.success && response.data) {
// 현재 화면 관련 흐름만 필터링
const filtered = response.data.filter(
flow => flow.source_screen_id === screenId || flow.target_screen_id === screenId
);
setDataFlows(filtered);
}
} catch (error) {
console.error("데이터 흐름 로드 실패:", error);
} finally {
setLoading(false);
}
}, [groupId, screenId]);
// 모달 열릴 때 데이터 로드
useEffect(() => {
if (isOpen) {
loadTables();
loadTableRelations();
loadFieldJoins();
if (groupId) {
loadDataFlows();
}
// 현재 테이블 컬럼 로드
if (tableName) {
loadTableColumns(tableName);
}
}
}, [isOpen, loadTables, loadTableRelations, loadFieldJoins, loadDataFlows, tableName, groupId, loadTableColumns]);
// ============================================================
// 이벤트 핸들러
// ============================================================
// 모달 닫기
const handleClose = () => {
onClose();
};
// 새로고침
const handleRefresh = async () => {
setLoading(true);
try {
await Promise.all([
loadTableRelations(),
loadFieldJoins(),
groupId ? loadDataFlows() : Promise.resolve(),
]);
toast.success("데이터가 새로고침되었습니다.");
} catch (error) {
toast.error("새로고침 실패");
} finally {
setLoading(false);
}
};
// ============================================================
// 렌더링
// ============================================================
// 모달 제목
const modalTitle = nodeType === "screen"
? `화면 설정: ${screenName}`
: `테이블 설정: ${tableLabel || tableName}`;
// 모달 설명
const modalDescription = nodeType === "screen"
? "화면의 테이블 연결, 조인, 데이터 흐름을 설정합니다."
: "테이블의 조인 관계 및 필드 매핑을 설정합니다.";
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[900px] max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
{nodeType === "screen" ? (
<Database className="h-5 w-5 text-blue-500" />
) : (
<Database className="h-5 w-5 text-green-500" />
)}
{modalTitle}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{modalDescription}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden">
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabId)} className="h-full flex flex-col">
<div className="flex items-center justify-between border-b pb-2">
<TabsList className="grid grid-cols-4 w-auto">
<TabsTrigger value="table-relation" className="text-xs sm:text-sm gap-1">
<Database className="h-4 w-4" />
<span className="hidden sm:inline"> </span>
<span className="sm:hidden"></span>
</TabsTrigger>
<TabsTrigger value="join-setting" className="text-xs sm:text-sm gap-1">
<Link2 className="h-4 w-4" />
<span className="hidden sm:inline"> </span>
<span className="sm:hidden"></span>
</TabsTrigger>
<TabsTrigger value="data-flow" className="text-xs sm:text-sm gap-1">
<GitBranch className="h-4 w-4" />
<span className="hidden sm:inline"> </span>
<span className="sm:hidden"></span>
</TabsTrigger>
<TabsTrigger value="field-mapping" className="text-xs sm:text-sm gap-1">
<Columns3 className="h-4 w-4" />
<span className="hidden sm:inline"> </span>
<span className="sm:hidden"></span>
</TabsTrigger>
</TabsList>
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={loading}
className="gap-1"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
<span className="hidden sm:inline"></span>
</Button>
</div>
{/* 탭 컨텐츠 */}
<div className="flex-1 overflow-auto pt-4">
{/* 탭1: 테이블 연결 */}
<TabsContent value="table-relation" className="mt-0 h-full">
<TableRelationTab
screenId={screenId}
screenName={screenName}
tableRelations={tableRelations}
tables={tables}
loading={loading}
onReload={loadTableRelations}
onRefreshVisualization={onRefresh}
nodeType={nodeType}
existingConfig={existingConfig}
/>
</TabsContent>
{/* 탭2: 조인 설정 */}
<TabsContent value="join-setting" className="mt-0 h-full">
<JoinSettingTab
screenId={screenId}
tableName={tableName}
fieldJoins={fieldJoins}
tables={tables}
tableColumns={tableColumns}
loading={loading}
onReload={loadFieldJoins}
onLoadColumns={loadTableColumns}
onRefreshVisualization={onRefresh}
existingConfig={existingConfig}
/>
</TabsContent>
{/* 탭3: 데이터 흐름 */}
<TabsContent value="data-flow" className="mt-0 h-full">
<DataFlowTab
screenId={screenId}
groupId={groupId}
groupScreens={groupScreens}
dataFlows={dataFlows}
loading={loading}
onReload={loadDataFlows}
onRefreshVisualization={onRefresh}
/>
</TabsContent>
{/* 탭4: 필드 매핑 */}
<TabsContent value="field-mapping" className="mt-0 h-full">
<FieldMappingTab
screenId={screenId}
tableName={tableName}
tableColumns={tableColumns[tableName || ""] || []}
loading={loading}
/>
</TabsContent>
</div>
</Tabs>
</div>
</DialogContent>
</Dialog>
);
}
// ============================================================
// 탭1: 테이블 연결 설정
// ============================================================
interface TableRelationTabProps {
screenId: number;
screenName: string;
tableRelations: TableRelation[];
tables: TableInfo[];
loading: boolean;
onReload: () => void;
onRefreshVisualization?: () => void;
nodeType: "screen" | "table";
existingConfig?: ExistingConfig;
}
function TableRelationTab({
screenId,
screenName,
tableRelations,
tables,
loading,
onReload,
onRefreshVisualization,
nodeType,
existingConfig,
}: TableRelationTabProps) {
const [isEditing, setIsEditing] = useState(false);
const [editItem, setEditItem] = useState<TableRelation | null>(null);
const [formData, setFormData] = useState({
table_name: "",
relation_type: "main",
crud_operations: "CR",
description: "",
is_active: "Y",
});
// 폼 초기화
const resetForm = () => {
setFormData({
table_name: "",
relation_type: "main",
crud_operations: "CR",
description: "",
is_active: "Y",
});
setEditItem(null);
setIsEditing(false);
};
// 수정 모드
const handleEdit = (item: TableRelation) => {
setEditItem(item);
setFormData({
table_name: item.table_name,
relation_type: item.relation_type,
crud_operations: item.crud_operations,
description: item.description || "",
is_active: item.is_active,
});
setIsEditing(true);
};
// 저장
const handleSave = async () => {
if (!formData.table_name) {
toast.error("테이블을 선택해주세요.");
return;
}
try {
const payload = {
screen_id: screenId,
...formData,
};
let response;
if (editItem) {
response = await updateTableRelation(editItem.id, payload);
} else {
response = await createTableRelation(payload);
}
if (response.success) {
toast.success(editItem ? "테이블 연결이 수정되었습니다." : "테이블 연결이 추가되었습니다.");
resetForm();
onReload();
onRefreshVisualization?.();
} else {
toast.error(response.message || "저장에 실패했습니다.");
}
} catch (error: any) {
toast.error(error.message || "저장 중 오류가 발생했습니다.");
}
};
// 삭제
const handleDelete = async (id: number) => {
if (!confirm("정말 삭제하시겠습니까?")) return;
try {
const response = await deleteTableRelation(id);
if (response.success) {
toast.success("테이블 연결이 삭제되었습니다.");
onReload();
onRefreshVisualization?.();
} else {
toast.error(response.message || "삭제에 실패했습니다.");
}
} catch (error: any) {
toast.error(error.message || "삭제 중 오류가 발생했습니다.");
}
};
// 화면 디자이너에서 추출한 테이블 관계를 통합 목록으로 변환
const designerTableRelations = useMemo(() => {
if (nodeType !== "screen" || !existingConfig) return [];
const result: Array<{
id: string;
source: "designer";
table_name: string;
table_label?: string;
relation_type: string;
crud_operations: string;
description: string;
filterColumns?: string[];
joinColumnRefs?: Array<{ column: string; refTable: string; refTableLabel?: string; refColumn: string; }>;
}> = [];
// 메인 테이블 추가
if (existingConfig.mainTable) {
result.push({
id: `designer-main-${existingConfig.mainTable}`,
source: "designer",
table_name: existingConfig.mainTable,
table_label: existingConfig.mainTable,
relation_type: "main",
crud_operations: "CRUD",
description: "화면의 주요 데이터 소스 테이블",
});
}
// 필터 테이블 추가
if (existingConfig.filterTables) {
existingConfig.filterTables.forEach((ft, idx) => {
result.push({
id: `designer-filter-${ft.tableName}-${idx}`,
source: "designer",
table_name: ft.tableName,
table_label: ft.tableLabel,
relation_type: "sub",
crud_operations: "R",
description: "마스터-디테일 필터 테이블",
filterColumns: ft.filterColumns,
joinColumnRefs: ft.joinColumnRefs,
});
});
}
return result;
}, [nodeType, existingConfig]);
// DB 테이블 관계와 디자이너 테이블 관계 통합
const unifiedTableRelations = useMemo(() => {
// DB 관계
const dbRelations = tableRelations.map(item => ({
...item,
id: item.id,
source: "db" as const,
}));
// 디자이너 관계 (DB에 이미 있는 테이블은 제외)
const dbTableNames = new Set(tableRelations.map(r => r.table_name));
const filteredDesignerRelations = designerTableRelations.filter(
dr => !dbTableNames.has(dr.table_name)
);
return [...filteredDesignerRelations, ...dbRelations];
}, [tableRelations, designerTableRelations]);
// 디자이너 항목 수정 (DB로 저장)
const handleEditDesignerRelation = (item: typeof designerTableRelations[0]) => {
setFormData({
table_name: item.table_name,
relation_type: item.relation_type,
crud_operations: item.crud_operations,
description: item.description || "",
is_active: "Y",
});
setEditItem(null);
setIsEditing(true);
};
return (
<div className="space-y-4">
{/* 입력 폼 */}
<div className="bg-muted/50 rounded-lg p-4 space-y-3">
<div className="text-sm font-medium">{isEditing ? "테이블 연결 수정" : "새 테이블 연결 추가"}</div>
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3">
<div>
<Label className="text-xs"> *</Label>
<SearchableSelect
value={formData.table_name}
onValueChange={(v) => setFormData(prev => ({ ...prev, table_name: v }))}
options={tables.map((t) => ({
value: t.tableName,
label: t.displayName || t.tableName,
description: t.tableName !== t.displayName ? t.tableName : undefined,
}))}
placeholder="테이블 선택"
searchPlaceholder="테이블 검색..."
/>
</div>
<div>
<Label className="text-xs"> </Label>
<SearchableSelect
value={formData.relation_type}
onValueChange={(v) => setFormData(prev => ({ ...prev, relation_type: v }))}
options={[
{ value: "main", label: "메인 테이블" },
{ value: "sub", label: "서브 테이블" },
{ value: "lookup", label: "조회 테이블" },
{ value: "save", label: "저장 테이블" },
]}
placeholder="관계 유형"
searchPlaceholder="유형 검색..."
/>
</div>
<div>
<Label className="text-xs">CRUD </Label>
<SearchableSelect
value={formData.crud_operations}
onValueChange={(v) => setFormData(prev => ({ ...prev, crud_operations: v }))}
options={[
{ value: "C", label: "생성(C)" },
{ value: "R", label: "읽기(R)" },
{ value: "CR", label: "생성+읽기(CR)" },
{ value: "CRU", label: "생성+읽기+수정(CRU)" },
{ value: "CRUD", label: "전체(CRUD)" },
]}
placeholder="CRUD 권한"
searchPlaceholder="권한 검색..."
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
placeholder="설명 입력"
className="h-9 text-xs"
/>
</div>
</div>
<div className="flex justify-end gap-2">
{isEditing && (
<Button variant="outline" size="sm" onClick={resetForm}>
</Button>
)}
<Button size="sm" onClick={handleSave} className="gap-1">
<Save className="h-4 w-4" />
{isEditing ? "수정" : "추가"}
</Button>
</div>
</div>
{/* 목록 */}
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-xs w-[60px]"></TableHead>
<TableHead className="text-xs"></TableHead>
<TableHead className="text-xs"> </TableHead>
<TableHead className="text-xs">CRUD</TableHead>
<TableHead className="text-xs"></TableHead>
<TableHead className="text-xs w-[100px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
</TableCell>
</TableRow>
) : unifiedTableRelations.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground text-sm">
.
</TableCell>
</TableRow>
) : (
unifiedTableRelations.map((item) => (
<TableRow key={item.id} className={item.source === "designer" ? "bg-orange-50/50" : ""}>
<TableCell className="text-xs">
<Badge variant="outline" className={cn(
"h-5 px-2",
item.source === "designer"
? "border-orange-400 text-orange-700 bg-orange-100"
: "border-blue-400 text-blue-700 bg-blue-100"
)}>
{item.source === "designer" ? "화면" : "DB"}
</Badge>
</TableCell>
<TableCell className="text-xs">
<div>
<span className="font-medium">{item.table_label || item.table_name}</span>
{item.table_label && item.table_label !== item.table_name && (
<span className="text-muted-foreground ml-1">({item.table_name})</span>
)}
</div>
{/* 필터 테이블의 경우 필터 컬럼/조인 정보 표시 */}
{item.source === "designer" && "filterColumns" in item && item.filterColumns && item.filterColumns.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{item.filterColumns.map((col, idx) => (
<span key={idx} className="px-1.5 py-0.5 bg-purple-100 text-purple-600 text-[10px] rounded font-mono">
{col}
</span>
))}
</div>
)}
{item.source === "designer" && "joinColumnRefs" in item && item.joinColumnRefs && item.joinColumnRefs.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{item.joinColumnRefs.map((join, idx) => (
<span key={idx} className="px-1.5 py-0.5 bg-orange-100 text-orange-600 text-[10px] rounded">
{join.column}{join.refTable}
</span>
))}
</div>
)}
</TableCell>
<TableCell className="text-xs">
<span className={`px-2 py-1 rounded text-xs ${
item.relation_type === "main" ? "bg-blue-100 text-blue-700" :
item.relation_type === "sub" ? "bg-purple-100 text-purple-700" :
item.relation_type === "save" ? "bg-pink-100 text-pink-700" :
"bg-gray-100 text-gray-700"
}`}>
{item.relation_type === "main" ? "메인" :
item.relation_type === "sub" ? "필터" :
item.relation_type === "save" ? "저장" :
item.relation_type === "lookup" ? "조회" : item.relation_type}
</span>
</TableCell>
<TableCell className="text-xs">{item.crud_operations}</TableCell>
<TableCell className="text-xs text-muted-foreground">
{item.description || "-"}
</TableCell>
<TableCell>
<div className="flex gap-1">
{item.source === "db" ? (
<>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEdit(item as TableRelation)}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => handleDelete(item.id as number)}
>
<Trash2 className="h-3 w-3" />
</Button>
</>
) : (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title="DB에 저장하여 수정"
onClick={() => handleEditDesignerRelation(item as typeof designerTableRelations[0])}
>
<Pencil className="h-3 w-3" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}
// ============================================================
// 탭2: 조인 설정
// ============================================================
interface JoinSettingTabProps {
screenId: number;
tableName?: string;
fieldJoins: FieldJoin[];
tables: TableInfo[];
tableColumns: Record<string, ColumnTypeInfo[]>;
loading: boolean;
onReload: () => void;
onLoadColumns: (tableName: string) => void;
onRefreshVisualization?: () => void;
// 기존 설정 정보 (화면 디자이너에서 추출)
existingConfig?: ExistingConfig;
}
// 화면 디자이너 조인 설정을 통합 형식으로 변환하기 위한 인터페이스
interface UnifiedJoinItem {
id: number | string; // DB는 숫자, 화면 디자이너는 문자열
source: "db" | "designer"; // 출처
save_table: string;
save_table_label?: string;
save_column: string;
join_table: string;
join_table_label?: string;
join_column: string;
display_column?: string;
join_type: string;
}
function JoinSettingTab({
screenId,
tableName,
fieldJoins,
tables,
tableColumns,
loading,
onReload,
onLoadColumns,
onRefreshVisualization,
existingConfig,
}: JoinSettingTabProps) {
const [isEditing, setIsEditing] = useState(false);
const [editItem, setEditItem] = useState<FieldJoin | null>(null);
const [editingDesignerItem, setEditingDesignerItem] = useState<UnifiedJoinItem | null>(null);
const [formData, setFormData] = useState({
field_name: "",
save_table: tableName || "",
save_column: "",
join_table: "",
join_column: "",
display_column: "",
join_type: "LEFT",
filter_condition: "",
is_active: "Y",
});
// 테이블 라벨 가져오기 (tableName -> displayName) - 먼저 선언해야 함
const tableLabel = tables.find(t => t.tableName === tableName)?.displayName;
// 화면 디자이너 조인 설정을 통합 형식으로 변환
// 1. 현재 테이블의 조인 설정
const directJoins: UnifiedJoinItem[] = (existingConfig?.joinColumnRefs || []).map((ref, idx) => ({
id: `designer-direct-${idx}`,
source: "designer" as const,
save_table: tableName || "",
save_table_label: tableLabel || tableName,
save_column: ref.column,
join_table: ref.refTable,
join_table_label: ref.refTableLabel,
join_column: ref.refColumn,
display_column: "",
join_type: "LEFT",
}));
// 2. 필터 테이블들의 조인 설정 (화면 노드에서 열었을 때)
const filterTableJoins: UnifiedJoinItem[] = (existingConfig?.filterTables || []).flatMap((ft, ftIdx) =>
(ft.joinColumnRefs || []).map((ref, refIdx) => ({
id: `designer-filter-${ftIdx}-${refIdx}`,
source: "designer" as const,
save_table: ft.tableName,
save_table_label: ft.tableLabel || ft.tableName,
save_column: ref.column,
join_table: ref.refTable,
join_table_label: ref.refTableLabel,
join_column: ref.refColumn,
display_column: "",
join_type: "LEFT",
}))
);
// 모든 디자이너 조인 설정 통합
const designerJoins: UnifiedJoinItem[] = [...directJoins, ...filterTableJoins];
// DB 조인 설정을 통합 형식으로 변환
const dbJoins: UnifiedJoinItem[] = fieldJoins.map((item) => ({
id: item.id,
source: "db" as const,
save_table: item.save_table,
save_table_label: item.save_table_label,
save_column: item.save_column,
join_table: item.join_table,
join_table_label: item.join_table_label,
join_column: item.join_column,
display_column: item.display_column,
join_type: item.join_type,
}));
// 통합된 조인 목록 (화면 디자이너 + DB)
const unifiedJoins = [...designerJoins, ...dbJoins];
// 저장 테이블 변경 시 컬럼 로드
useEffect(() => {
if (formData.save_table) {
onLoadColumns(formData.save_table);
}
}, [formData.save_table, onLoadColumns]);
// 조인 테이블 변경 시 컬럼 로드
useEffect(() => {
if (formData.join_table) {
onLoadColumns(formData.join_table);
}
}, [formData.join_table, onLoadColumns]);
// 폼 초기화
const resetForm = () => {
setFormData({
field_name: "",
save_table: tableName || "",
save_column: "",
join_table: "",
join_column: "",
display_column: "",
join_type: "LEFT",
filter_condition: "",
is_active: "Y",
});
setEditItem(null);
setEditingDesignerItem(null);
setIsEditing(false);
};
// 수정 모드 (DB 설정)
const handleEdit = (item: FieldJoin) => {
setEditItem(item);
setEditingDesignerItem(null);
setFormData({
field_name: item.field_name || "",
save_table: item.save_table,
save_column: item.save_column,
join_table: item.join_table,
join_column: item.join_column,
display_column: item.display_column,
join_type: item.join_type,
filter_condition: item.filter_condition || "",
is_active: item.is_active,
});
setIsEditing(true);
// 컬럼 로드
onLoadColumns(item.save_table);
onLoadColumns(item.join_table);
};
// 통합 목록에서 수정 버튼 클릭
const handleEditUnified = (item: UnifiedJoinItem) => {
if (item.source === "db") {
// DB 설정은 기존 로직 사용
const originalItem = fieldJoins.find(j => j.id === item.id);
if (originalItem) handleEdit(originalItem);
} else {
// 화면 디자이너 설정은 폼에 채우고 새로 저장하도록
setEditItem(null);
setEditingDesignerItem(item);
setFormData({
field_name: "",
save_table: item.save_table,
save_column: item.save_column,
join_table: item.join_table,
join_column: item.join_column,
display_column: item.display_column || "",
join_type: item.join_type,
filter_condition: "",
is_active: "Y",
});
setIsEditing(true);
// 컬럼 로드
onLoadColumns(item.save_table);
onLoadColumns(item.join_table);
}
};
// 통합 목록에서 삭제 버튼 클릭
const handleDeleteUnified = async (item: UnifiedJoinItem) => {
if (item.source === "db") {
// DB 설정만 삭제 가능
await handleDelete(item.id as number);
} else {
// 화면 디자이너 설정은 삭제 불가 (화면 디자이너에서 수정해야 함)
toast.info("화면 디자이너 설정은 화면 디자이너에서 수정해주세요.");
}
};
// 저장
const handleSave = async () => {
if (!formData.save_table || !formData.save_column || !formData.join_table || !formData.join_column) {
toast.error("필수 필드를 모두 입력해주세요.");
return;
}
try {
const payload = {
screen_id: screenId,
...formData,
};
let response;
if (editItem) {
response = await updateFieldJoin(editItem.id, payload);
} else {
response = await createFieldJoin(payload);
}
if (response.success) {
toast.success(editItem ? "조인 설정이 수정되었습니다." : "조인 설정이 추가되었습니다.");
resetForm();
onReload();
onRefreshVisualization?.();
} else {
toast.error(response.message || "저장에 실패했습니다.");
}
} catch (error: any) {
toast.error(error.message || "저장 중 오류가 발생했습니다.");
}
};
// 삭제
const handleDelete = async (id: number) => {
if (!confirm("정말 삭제하시겠습니까?")) return;
try {
const response = await deleteFieldJoin(id);
if (response.success) {
toast.success("조인 설정이 삭제되었습니다.");
onReload();
onRefreshVisualization?.();
} else {
toast.error(response.message || "삭제에 실패했습니다.");
}
} catch (error: any) {
toast.error(error.message || "삭제 중 오류가 발생했습니다.");
}
};
// 저장 테이블 컬럼
const saveTableColumns = tableColumns[formData.save_table] || [];
// 조인 테이블 컬럼
const joinTableColumns = tableColumns[formData.join_table] || [];
return (
<div className="space-y-4">
{/* 필터링 컬럼 정보 */}
{existingConfig?.filterColumns && existingConfig.filterColumns.length > 0 && (
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
<div className="flex items-center gap-2 mb-2">
<Database className="h-4 w-4 text-purple-600" />
<span className="text-sm font-medium text-purple-800"> (- )</span>
</div>
<div className="flex flex-wrap gap-2">
{existingConfig.filterColumns.map((col, idx) => (
<span key={idx} className="px-2 py-1 bg-purple-100 text-purple-700 text-xs rounded font-mono">
{col}
</span>
))}
</div>
<p className="text-xs text-purple-600 mt-2">
* .
</p>
</div>
)}
{/* 참조 정보 (이 테이블을 참조하는 다른 테이블들) */}
{existingConfig?.referencedBy && existingConfig.referencedBy.length > 0 && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-center gap-2 mb-3">
<GitBranch className="h-4 w-4 text-green-600" />
<span className="text-sm font-medium text-green-800"> </span>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-xs"> </TableHead>
<TableHead className="text-xs"> </TableHead>
<TableHead className="text-xs"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{existingConfig.referencedBy.map((ref, idx) => (
<TableRow key={idx}>
<TableCell className="text-xs">
<span className="font-medium">{ref.fromTableLabel || ref.fromTable}</span>
</TableCell>
<TableCell className="text-xs">
<span className={`px-2 py-0.5 rounded text-xs ${
ref.relationType === 'join' ? 'bg-orange-100 text-orange-700' :
ref.relationType === 'filter' ? 'bg-purple-100 text-purple-700' :
'bg-gray-100 text-gray-700'
}`}>
{ref.relationType}
</span>
</TableCell>
<TableCell className="text-xs font-mono">
{ref.fromColumn} {ref.toColumn}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* 입력 폼 */}
<div className="bg-muted/50 rounded-lg p-4 space-y-3">
<div className="text-sm font-medium">{isEditing ? "조인 설정 수정" : "새 조인 설정 추가"}</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{/* 저장 테이블 */}
<div>
<Label className="text-xs"> *</Label>
<SearchableSelect
value={formData.save_table}
onValueChange={(v) => {
setFormData(prev => ({ ...prev, save_table: v, save_column: "" }));
}}
options={tables.map((t) => ({
value: t.tableName,
label: t.displayName || t.tableName,
description: t.tableName !== t.displayName ? t.tableName : undefined,
}))}
placeholder="테이블 선택"
searchPlaceholder="테이블 검색..."
/>
</div>
{/* 저장 컬럼 */}
<div>
<Label className="text-xs"> (FK) *</Label>
<SearchableSelect
value={formData.save_column}
onValueChange={(v) => setFormData(prev => ({ ...prev, save_column: v }))}
disabled={!formData.save_table}
options={saveTableColumns.map((c) => ({
value: c.columnName,
label: c.displayName || c.columnName,
description: c.columnName !== c.displayName ? c.columnName : undefined,
}))}
placeholder="컬럼 선택"
searchPlaceholder="컬럼 검색..."
/>
</div>
{/* 조인 타입 */}
<div>
<Label className="text-xs"> </Label>
<SearchableSelect
value={formData.join_type}
onValueChange={(v) => setFormData(prev => ({ ...prev, join_type: v }))}
options={[
{ value: "LEFT", label: "LEFT JOIN" },
{ value: "INNER", label: "INNER JOIN" },
{ value: "RIGHT", label: "RIGHT JOIN" },
]}
placeholder="조인 타입"
searchPlaceholder="타입 검색..."
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{/* 조인 테이블 */}
<div>
<Label className="text-xs"> *</Label>
<SearchableSelect
value={formData.join_table}
onValueChange={(v) => {
setFormData(prev => ({ ...prev, join_table: v, join_column: "", display_column: "" }));
}}
options={tables.map((t) => ({
value: t.tableName,
label: t.displayName || t.tableName,
description: t.tableName !== t.displayName ? t.tableName : undefined,
}))}
placeholder="테이블 선택"
searchPlaceholder="테이블 검색..."
/>
</div>
{/* 조인 컬럼 */}
<div>
<Label className="text-xs"> (PK) *</Label>
<SearchableSelect
value={formData.join_column}
onValueChange={(v) => setFormData(prev => ({ ...prev, join_column: v }))}
disabled={!formData.join_table}
options={joinTableColumns.map((c) => ({
value: c.columnName,
label: c.displayName || c.columnName,
description: c.columnName !== c.displayName ? c.columnName : undefined,
}))}
placeholder="컬럼 선택"
searchPlaceholder="컬럼 검색..."
/>
</div>
{/* 표시 컬럼 */}
<div>
<Label className="text-xs"> </Label>
<SearchableSelect
value={formData.display_column}
onValueChange={(v) => setFormData(prev => ({ ...prev, display_column: v }))}
disabled={!formData.join_table}
options={joinTableColumns.map((c) => ({
value: c.columnName,
label: c.displayName || c.columnName,
description: c.columnName !== c.displayName ? c.columnName : undefined,
}))}
placeholder="표시할 컬럼 선택"
searchPlaceholder="컬럼 검색..."
/>
</div>
</div>
<div className="flex justify-end gap-2">
{isEditing && (
<Button variant="outline" size="sm" onClick={resetForm}>
</Button>
)}
<Button size="sm" onClick={handleSave} className="gap-1">
<Save className="h-4 w-4" />
{isEditing ? "수정" : "추가"}
</Button>
</div>
</div>
{/* 통합 조인 목록 */}
<div className="border rounded-lg overflow-x-auto">
<div className="bg-muted/30 px-4 py-2 border-b flex items-center justify-between">
<span className="text-sm font-medium flex items-center gap-2">
<Link2 className="h-4 w-4" />
</span>
<span className="text-xs text-muted-foreground">
{unifiedJoins.length}
</span>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-xs"></TableHead>
<TableHead className="text-xs"> </TableHead>
<TableHead className="text-xs">FK </TableHead>
<TableHead className="text-xs"> </TableHead>
<TableHead className="text-xs">PK </TableHead>
<TableHead className="text-xs"></TableHead>
<TableHead className="text-xs w-[100px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
</TableCell>
</TableRow>
) : unifiedJoins.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground text-sm">
.
</TableCell>
</TableRow>
) : (
unifiedJoins.map((item) => (
<TableRow key={item.id} className={item.source === "designer" ? "bg-orange-50/50" : ""}>
<TableCell className="text-xs">
{item.source === "designer" ? (
<span className="px-2 py-0.5 rounded bg-orange-100 text-orange-700 text-xs">
</span>
) : (
<span className="px-2 py-0.5 rounded bg-blue-100 text-blue-700 text-xs">
DB
</span>
)}
</TableCell>
<TableCell className="text-xs">{item.save_table_label || item.save_table}</TableCell>
<TableCell className="text-xs font-mono">{item.save_column}</TableCell>
<TableCell className="text-xs">{item.join_table_label || item.join_table}</TableCell>
<TableCell className="text-xs font-mono">{item.join_column}</TableCell>
<TableCell className="text-xs">
<span className="px-2 py-1 rounded bg-gray-100 text-gray-700 text-xs">
{item.join_type}
</span>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEditUnified(item)}
title={item.source === "designer" ? "DB 설정으로 저장" : "수정"}
>
<Pencil className="h-3 w-3" />
</Button>
{item.source === "db" && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => handleDeleteUnified(item)}
title="삭제"
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
{designerJoins.length > 0 && (
<div className="px-4 py-2 border-t text-xs text-muted-foreground bg-orange-50/30">
<span className="text-orange-600">* </span>: ( DB에 ) |
<span className="text-blue-600 ml-1">* DB</span>: DB ( / )
</div>
)}
</div>
</div>
);
}
// ============================================================
// 탭3: 데이터 흐름
// ============================================================
interface DataFlowTabProps {
screenId: number;
groupId?: number;
groupScreens: Array<{ screen_id: number; screen_name: string }>;
dataFlows: DataFlow[];
loading: boolean;
onReload: () => void;
onRefreshVisualization?: () => void;
}
function DataFlowTab({
screenId,
groupId,
groupScreens,
dataFlows,
loading,
onReload,
onRefreshVisualization,
}: DataFlowTabProps) {
const [isEditing, setIsEditing] = useState(false);
const [editItem, setEditItem] = useState<DataFlow | null>(null);
const [formData, setFormData] = useState({
source_screen_id: screenId,
source_action: "",
target_screen_id: 0,
target_action: "",
flow_type: "unidirectional",
flow_label: "",
is_active: "Y",
});
// 폼 초기화
const resetForm = () => {
setFormData({
source_screen_id: screenId,
source_action: "",
target_screen_id: 0,
target_action: "",
flow_type: "unidirectional",
flow_label: "",
is_active: "Y",
});
setEditItem(null);
setIsEditing(false);
};
// 수정 모드
const handleEdit = (item: DataFlow) => {
setEditItem(item);
setFormData({
source_screen_id: item.source_screen_id,
source_action: item.source_action || "",
target_screen_id: item.target_screen_id,
target_action: item.target_action || "",
flow_type: item.flow_type,
flow_label: item.flow_label || "",
is_active: item.is_active,
});
setIsEditing(true);
};
// 저장
const handleSave = async () => {
if (!formData.source_screen_id || !formData.target_screen_id) {
toast.error("소스 화면과 타겟 화면을 선택해주세요.");
return;
}
try {
const payload = {
group_id: groupId,
...formData,
};
let response;
if (editItem) {
response = await updateDataFlow(editItem.id, payload);
} else {
response = await createDataFlow(payload);
}
if (response.success) {
toast.success(editItem ? "데이터 흐름이 수정되었습니다." : "데이터 흐름이 추가되었습니다.");
resetForm();
onReload();
onRefreshVisualization?.();
} else {
toast.error(response.message || "저장에 실패했습니다.");
}
} catch (error: any) {
toast.error(error.message || "저장 중 오류가 발생했습니다.");
}
};
// 삭제
const handleDelete = async (id: number) => {
if (!confirm("정말 삭제하시겠습니까?")) return;
try {
const response = await deleteDataFlow(id);
if (response.success) {
toast.success("데이터 흐름이 삭제되었습니다.");
onReload();
onRefreshVisualization?.();
} else {
toast.error(response.message || "삭제에 실패했습니다.");
}
} catch (error: any) {
toast.error(error.message || "삭제 중 오류가 발생했습니다.");
}
};
// 그룹 없음 안내
if (!groupId) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<GitBranch className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2"> </h3>
<p className="text-sm text-muted-foreground">
.
</p>
</div>
);
}
return (
<div className="space-y-4">
{/* 입력 폼 */}
<div className="bg-muted/50 rounded-lg p-4 space-y-3">
<div className="text-sm font-medium">{isEditing ? "데이터 흐름 수정" : "새 데이터 흐름 추가"}</div>
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3">
{/* 소스 화면 */}
<div>
<Label className="text-xs"> *</Label>
<SearchableSelect
value={formData.source_screen_id.toString()}
onValueChange={(v) => setFormData(prev => ({ ...prev, source_screen_id: parseInt(v) }))}
options={groupScreens.map((s) => ({
value: s.screen_id.toString(),
label: s.screen_name,
}))}
placeholder="화면 선택"
searchPlaceholder="화면 검색..."
/>
</div>
{/* 소스 액션 */}
<div>
<Label className="text-xs"> </Label>
<Input
value={formData.source_action}
onChange={(e) => setFormData(prev => ({ ...prev, source_action: e.target.value }))}
placeholder="예: 행 선택"
className="h-9 text-xs"
/>
</div>
{/* 타겟 화면 */}
<div>
<Label className="text-xs"> *</Label>
<SearchableSelect
value={formData.target_screen_id.toString()}
onValueChange={(v) => setFormData(prev => ({ ...prev, target_screen_id: parseInt(v) }))}
options={groupScreens
.filter(s => s.screen_id !== formData.source_screen_id)
.map((s) => ({
value: s.screen_id.toString(),
label: s.screen_name,
}))}
placeholder="화면 선택"
searchPlaceholder="화면 검색..."
/>
</div>
{/* 흐름 타입 */}
<div>
<Label className="text-xs"> </Label>
<SearchableSelect
value={formData.flow_type}
onValueChange={(v) => setFormData(prev => ({ ...prev, flow_type: v }))}
options={[
{ value: "unidirectional", label: "단방향" },
{ value: "bidirectional", label: "양방향" },
]}
placeholder="흐름 타입"
searchPlaceholder="타입 검색..."
/>
</div>
</div>
<div className="flex justify-end gap-2">
{isEditing && (
<Button variant="outline" size="sm" onClick={resetForm}>
</Button>
)}
<Button size="sm" onClick={handleSave} className="gap-1">
<Save className="h-4 w-4" />
{isEditing ? "수정" : "추가"}
</Button>
</div>
</div>
{/* 목록 */}
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-xs"> </TableHead>
<TableHead className="text-xs"></TableHead>
<TableHead className="text-xs"> </TableHead>
<TableHead className="text-xs"></TableHead>
<TableHead className="text-xs w-[100px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
</TableCell>
</TableRow>
) : dataFlows.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">
.
</TableCell>
</TableRow>
) : (
dataFlows.map((item) => (
<TableRow key={item.id}>
<TableCell className="text-xs font-medium">
{item.source_screen_name || `화면 ${item.source_screen_id}`}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{item.source_action || "-"}
</TableCell>
<TableCell className="text-xs font-medium">
{item.target_screen_name || `화면 ${item.target_screen_id}`}
</TableCell>
<TableCell className="text-xs">
<span className={`px-2 py-1 rounded text-xs ${
item.flow_type === "bidirectional"
? "bg-purple-100 text-purple-700"
: "bg-blue-100 text-blue-700"
}`}>
{item.flow_type === "bidirectional" ? "양방향" : "단방향"}
</span>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEdit(item)}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => handleDelete(item.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}
// ============================================================
// 탭4: 필드-컬럼 매핑 (화면 컴포넌트와 DB 컬럼 연결)
// ============================================================
interface FieldMappingTabProps {
screenId: number;
tableName?: string;
tableColumns: ColumnTypeInfo[];
loading: boolean;
}
function FieldMappingTab({
screenId,
tableName,
tableColumns,
loading,
}: FieldMappingTabProps) {
// 필드 매핑은 screen_layouts.properties에서 관리됨
// 이 탭에서는 현재 매핑 상태를 조회하고 편집 가능하게 제공
return (
<div className="space-y-4">
<div className="bg-muted/50 rounded-lg p-4">
<div className="flex items-center gap-2 mb-2">
<Columns3 className="h-5 w-5 text-blue-500" />
<span className="text-sm font-medium">- </span>
</div>
<p className="text-xs text-muted-foreground">
.
<br />
.
</p>
</div>
{/* 테이블 컬럼 목록 */}
{tableName && (
<div className="border rounded-lg">
<div className="bg-muted/30 px-4 py-2 border-b">
<span className="text-sm font-medium">: {tableName}</span>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-xs"></TableHead>
<TableHead className="text-xs"></TableHead>
<TableHead className="text-xs"> </TableHead>
<TableHead className="text-xs"> </TableHead>
<TableHead className="text-xs">PK</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
</TableCell>
</TableRow>
) : tableColumns.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">
.
</TableCell>
</TableRow>
) : (
tableColumns.slice(0, 20).map((col) => (
<TableRow key={col.columnName}>
<TableCell className="text-xs font-mono">{col.columnName}</TableCell>
<TableCell className="text-xs">{col.displayName}</TableCell>
<TableCell className="text-xs text-muted-foreground">{col.dbType}</TableCell>
<TableCell className="text-xs">
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-700 text-xs">
{col.webType}
</span>
</TableCell>
<TableCell className="text-xs">
{col.isPrimaryKey && (
<span className="px-2 py-0.5 rounded bg-yellow-100 text-yellow-700 text-xs">
PK
</span>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
{tableColumns.length > 20 && (
<div className="px-4 py-2 text-xs text-muted-foreground border-t">
+ {tableColumns.length - 20}
</div>
)}
</div>
)}
{!tableName && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Database className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2"> </h3>
<p className="text-sm text-muted-foreground">
.
</p>
</div>
)}
</div>
);
}