1095 lines
34 KiB
TypeScript
1095 lines
34 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 { Badge } from "@/components/ui/badge";
|
||
|
|
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 {
|
||
|
|
Database,
|
||
|
|
Link2,
|
||
|
|
Columns3,
|
||
|
|
Key,
|
||
|
|
Save,
|
||
|
|
Plus,
|
||
|
|
Pencil,
|
||
|
|
Trash2,
|
||
|
|
RefreshCw,
|
||
|
|
Loader2,
|
||
|
|
Check,
|
||
|
|
ChevronsUpDown,
|
||
|
|
Table2,
|
||
|
|
ArrowRight,
|
||
|
|
Eye,
|
||
|
|
Settings2,
|
||
|
|
} from "lucide-react";
|
||
|
|
import {
|
||
|
|
getFieldJoins,
|
||
|
|
createFieldJoin,
|
||
|
|
updateFieldJoin,
|
||
|
|
deleteFieldJoin,
|
||
|
|
FieldJoin,
|
||
|
|
} from "@/lib/api/screenGroup";
|
||
|
|
import { tableManagementApi, ColumnTypeInfo, TableInfo } from "@/lib/api/tableManagement";
|
||
|
|
|
||
|
|
// ============================================================
|
||
|
|
// 타입 정의
|
||
|
|
// ============================================================
|
||
|
|
|
||
|
|
interface JoinColumnRef {
|
||
|
|
column: string;
|
||
|
|
refTable: string;
|
||
|
|
refTableLabel?: string;
|
||
|
|
refColumn: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface ReferencedBy {
|
||
|
|
fromTable: string;
|
||
|
|
fromTableLabel?: string;
|
||
|
|
fromColumn: string;
|
||
|
|
toColumn: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface ColumnInfo {
|
||
|
|
column: string;
|
||
|
|
label?: string;
|
||
|
|
type?: string;
|
||
|
|
isPK?: boolean;
|
||
|
|
isFK?: boolean;
|
||
|
|
refTable?: string;
|
||
|
|
refColumn?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface TableSettingModalProps {
|
||
|
|
isOpen: boolean;
|
||
|
|
onClose: () => void;
|
||
|
|
tableName: string;
|
||
|
|
tableLabel?: string;
|
||
|
|
screenId?: number;
|
||
|
|
joinColumnRefs?: JoinColumnRef[];
|
||
|
|
referencedBy?: ReferencedBy[];
|
||
|
|
columns?: ColumnInfo[];
|
||
|
|
filterColumns?: string[];
|
||
|
|
onSaveSuccess?: () => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 검색 가능한 Select 컴포넌트
|
||
|
|
interface SearchableSelectProps {
|
||
|
|
value: string;
|
||
|
|
onValueChange: (value: string) => void;
|
||
|
|
options: Array<{ value: string; label: string; description?: string }>;
|
||
|
|
placeholder?: string;
|
||
|
|
disabled?: boolean;
|
||
|
|
className?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
function SearchableSelect({
|
||
|
|
value,
|
||
|
|
onValueChange,
|
||
|
|
options,
|
||
|
|
placeholder = "선택...",
|
||
|
|
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-8 w-full justify-between text-xs", className)}
|
||
|
|
>
|
||
|
|
{selectedOption ? (
|
||
|
|
<span className="truncate">{selectedOption.label}</span>
|
||
|
|
) : (
|
||
|
|
<span className="text-muted-foreground">{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="검색..." className="text-xs" />
|
||
|
|
<CommandList>
|
||
|
|
<CommandEmpty className="py-2 text-center text-xs">
|
||
|
|
결과 없음
|
||
|
|
</CommandEmpty>
|
||
|
|
<CommandGroup>
|
||
|
|
{options.map((option) => (
|
||
|
|
<CommandItem
|
||
|
|
key={option.value}
|
||
|
|
value={option.value}
|
||
|
|
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-muted-foreground text-[10px]">
|
||
|
|
{option.description}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</CommandItem>
|
||
|
|
))}
|
||
|
|
</CommandGroup>
|
||
|
|
</CommandList>
|
||
|
|
</Command>
|
||
|
|
</PopoverContent>
|
||
|
|
</Popover>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================
|
||
|
|
// 메인 모달 컴포넌트
|
||
|
|
// ============================================================
|
||
|
|
|
||
|
|
export function TableSettingModal({
|
||
|
|
isOpen,
|
||
|
|
onClose,
|
||
|
|
tableName,
|
||
|
|
tableLabel,
|
||
|
|
screenId,
|
||
|
|
joinColumnRefs = [],
|
||
|
|
referencedBy = [],
|
||
|
|
columns = [],
|
||
|
|
filterColumns = [],
|
||
|
|
onSaveSuccess,
|
||
|
|
}: TableSettingModalProps) {
|
||
|
|
const [activeTab, setActiveTab] = useState("info");
|
||
|
|
const [loading, setLoading] = useState(false);
|
||
|
|
const [tableColumns, setTableColumns] = useState<ColumnTypeInfo[]>([]);
|
||
|
|
const [tables, setTables] = useState<TableInfo[]>([]);
|
||
|
|
const [fieldJoins, setFieldJoins] = useState<FieldJoin[]>([]);
|
||
|
|
|
||
|
|
// 테이블 컬럼 정보 로드
|
||
|
|
const loadTableColumns = useCallback(async () => {
|
||
|
|
if (!tableName) return;
|
||
|
|
|
||
|
|
setLoading(true);
|
||
|
|
try {
|
||
|
|
// 테이블 목록 로드
|
||
|
|
const tablesResponse = await tableManagementApi.getTables();
|
||
|
|
if (tablesResponse.success && tablesResponse.data) {
|
||
|
|
setTables(tablesResponse.data);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 테이블 컬럼 로드
|
||
|
|
const columnsResponse = await tableManagementApi.getTableColumns(tableName);
|
||
|
|
if (columnsResponse.success && columnsResponse.data) {
|
||
|
|
setTableColumns(columnsResponse.data);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 필드 조인 로드 (screenId가 있는 경우)
|
||
|
|
if (screenId) {
|
||
|
|
const joinsResponse = await getFieldJoins(screenId);
|
||
|
|
if (joinsResponse.success && joinsResponse.data) {
|
||
|
|
// 이 테이블과 관련된 조인만 필터링
|
||
|
|
const relevantJoins = joinsResponse.data.filter(
|
||
|
|
(j) => j.save_table === tableName || j.join_table === tableName
|
||
|
|
);
|
||
|
|
setFieldJoins(relevantJoins);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error("테이블 정보 로드 실패:", error);
|
||
|
|
toast.error("테이블 정보를 불러오는데 실패했습니다.");
|
||
|
|
} finally {
|
||
|
|
setLoading(false);
|
||
|
|
}
|
||
|
|
}, [tableName, screenId]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (isOpen && tableName) {
|
||
|
|
loadTableColumns();
|
||
|
|
}
|
||
|
|
}, [isOpen, tableName, loadTableColumns]);
|
||
|
|
|
||
|
|
// 새로고침
|
||
|
|
const handleRefresh = () => {
|
||
|
|
loadTableColumns();
|
||
|
|
toast.success("새로고침 완료");
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||
|
|
<DialogContent className="flex h-[80vh] max-h-[800px] w-[95vw] max-w-[1000px] flex-col">
|
||
|
|
<DialogHeader className="flex-shrink-0">
|
||
|
|
<DialogTitle className="flex items-center gap-2 text-lg">
|
||
|
|
<Table2 className="h-5 w-5 text-green-500" />
|
||
|
|
테이블 설정: {tableLabel || tableName}
|
||
|
|
</DialogTitle>
|
||
|
|
<DialogDescription className="text-sm">
|
||
|
|
테이블의 컬럼 정보, 조인 설정, 참조 관계를 확인하고 설정합니다.
|
||
|
|
{tableName !== tableLabel && (
|
||
|
|
<span className="ml-2 text-muted-foreground">({tableName})</span>
|
||
|
|
)}
|
||
|
|
</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
|
||
|
|
<Tabs
|
||
|
|
value={activeTab}
|
||
|
|
onValueChange={setActiveTab}
|
||
|
|
className="flex min-h-0 flex-1 flex-col"
|
||
|
|
>
|
||
|
|
<div className="flex flex-shrink-0 items-center justify-between border-b pb-2">
|
||
|
|
<TabsList className="h-9">
|
||
|
|
<TabsTrigger value="info" className="gap-1 text-xs">
|
||
|
|
<Columns3 className="h-3.5 w-3.5" />
|
||
|
|
테이블 정보
|
||
|
|
</TabsTrigger>
|
||
|
|
<TabsTrigger value="joins" className="gap-1 text-xs">
|
||
|
|
<Link2 className="h-3.5 w-3.5" />
|
||
|
|
조인 설정
|
||
|
|
</TabsTrigger>
|
||
|
|
<TabsTrigger value="references" className="gap-1 text-xs">
|
||
|
|
<Eye className="h-3.5 w-3.5" />
|
||
|
|
참조 관계
|
||
|
|
</TabsTrigger>
|
||
|
|
</TabsList>
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="sm"
|
||
|
|
onClick={handleRefresh}
|
||
|
|
className="gap-1"
|
||
|
|
>
|
||
|
|
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||
|
|
새로고침
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 탭 1: 테이블 정보 */}
|
||
|
|
<TabsContent value="info" className="mt-0 min-h-0 flex-1 overflow-auto p-4">
|
||
|
|
<TableInfoTab
|
||
|
|
tableName={tableName}
|
||
|
|
tableLabel={tableLabel}
|
||
|
|
columns={columns}
|
||
|
|
tableColumns={tableColumns}
|
||
|
|
filterColumns={filterColumns}
|
||
|
|
loading={loading}
|
||
|
|
/>
|
||
|
|
</TabsContent>
|
||
|
|
|
||
|
|
{/* 탭 2: 조인 설정 */}
|
||
|
|
<TabsContent value="joins" className="mt-0 min-h-0 flex-1 overflow-auto p-4">
|
||
|
|
<JoinSettingTab
|
||
|
|
tableName={tableName}
|
||
|
|
tableLabel={tableLabel}
|
||
|
|
screenId={screenId}
|
||
|
|
joinColumnRefs={joinColumnRefs}
|
||
|
|
fieldJoins={fieldJoins}
|
||
|
|
tables={tables}
|
||
|
|
tableColumns={tableColumns}
|
||
|
|
loading={loading}
|
||
|
|
onReload={loadTableColumns}
|
||
|
|
onSaveSuccess={onSaveSuccess}
|
||
|
|
/>
|
||
|
|
</TabsContent>
|
||
|
|
|
||
|
|
{/* 탭 3: 참조 관계 */}
|
||
|
|
<TabsContent value="references" className="mt-0 min-h-0 flex-1 overflow-auto p-4">
|
||
|
|
<ReferenceTab
|
||
|
|
tableName={tableName}
|
||
|
|
tableLabel={tableLabel}
|
||
|
|
referencedBy={referencedBy}
|
||
|
|
joinColumnRefs={joinColumnRefs}
|
||
|
|
loading={loading}
|
||
|
|
/>
|
||
|
|
</TabsContent>
|
||
|
|
</Tabs>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================
|
||
|
|
// 탭 1: 테이블 정보
|
||
|
|
// ============================================================
|
||
|
|
|
||
|
|
interface TableInfoTabProps {
|
||
|
|
tableName: string;
|
||
|
|
tableLabel?: string;
|
||
|
|
columns: ColumnInfo[];
|
||
|
|
tableColumns: ColumnTypeInfo[];
|
||
|
|
filterColumns: string[];
|
||
|
|
loading: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
function TableInfoTab({
|
||
|
|
tableName,
|
||
|
|
tableLabel,
|
||
|
|
columns,
|
||
|
|
tableColumns,
|
||
|
|
filterColumns,
|
||
|
|
loading,
|
||
|
|
}: TableInfoTabProps) {
|
||
|
|
// 컬럼 정보 통합 (기존 columns + API에서 가져온 tableColumns)
|
||
|
|
const mergedColumns = useMemo(() => {
|
||
|
|
const columnsMap = new Map<string, ColumnInfo>();
|
||
|
|
|
||
|
|
// 먼저 기존 columns 추가
|
||
|
|
columns.forEach((col) => {
|
||
|
|
columnsMap.set(col.column, col);
|
||
|
|
});
|
||
|
|
|
||
|
|
// API에서 가져온 컬럼 정보로 보강
|
||
|
|
tableColumns.forEach((tcol) => {
|
||
|
|
const existing = columnsMap.get(tcol.column_name);
|
||
|
|
if (existing) {
|
||
|
|
columnsMap.set(tcol.column_name, {
|
||
|
|
...existing,
|
||
|
|
type: tcol.data_type,
|
||
|
|
isPK: tcol.is_primary_key,
|
||
|
|
isFK: tcol.is_foreign_key,
|
||
|
|
refTable: tcol.references?.table,
|
||
|
|
refColumn: tcol.references?.column,
|
||
|
|
label: existing.label || tcol.column_name,
|
||
|
|
});
|
||
|
|
} else {
|
||
|
|
columnsMap.set(tcol.column_name, {
|
||
|
|
column: tcol.column_name,
|
||
|
|
label: tcol.column_name,
|
||
|
|
type: tcol.data_type,
|
||
|
|
isPK: tcol.is_primary_key,
|
||
|
|
isFK: tcol.is_foreign_key,
|
||
|
|
refTable: tcol.references?.table,
|
||
|
|
refColumn: tcol.references?.column,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
return Array.from(columnsMap.values());
|
||
|
|
}, [columns, tableColumns]);
|
||
|
|
|
||
|
|
// PK, FK 분류
|
||
|
|
const pkColumns = mergedColumns.filter((c) => c.isPK);
|
||
|
|
const fkColumns = mergedColumns.filter((c) => c.isFK);
|
||
|
|
|
||
|
|
if (loading) {
|
||
|
|
return (
|
||
|
|
<div className="flex h-64 items-center justify-center">
|
||
|
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-6">
|
||
|
|
{/* 기본 정보 */}
|
||
|
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||
|
|
<div className="rounded-lg border bg-blue-50 p-4">
|
||
|
|
<div className="text-2xl font-bold text-blue-600">{mergedColumns.length}</div>
|
||
|
|
<div className="text-xs text-blue-700">전체 컬럼</div>
|
||
|
|
</div>
|
||
|
|
<div className="rounded-lg border bg-orange-50 p-4">
|
||
|
|
<div className="text-2xl font-bold text-orange-600">{pkColumns.length}</div>
|
||
|
|
<div className="text-xs text-orange-700">Primary Key</div>
|
||
|
|
</div>
|
||
|
|
<div className="rounded-lg border bg-green-50 p-4">
|
||
|
|
<div className="text-2xl font-bold text-green-600">{fkColumns.length}</div>
|
||
|
|
<div className="text-xs text-green-700">Foreign Key</div>
|
||
|
|
</div>
|
||
|
|
<div className="rounded-lg border bg-purple-50 p-4">
|
||
|
|
<div className="text-2xl font-bold text-purple-600">{filterColumns.length}</div>
|
||
|
|
<div className="text-xs text-purple-700">필터 컬럼</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 필터 컬럼 */}
|
||
|
|
{filterColumns.length > 0 && (
|
||
|
|
<div className="space-y-2">
|
||
|
|
<h3 className="text-sm font-semibold">필터 컬럼</h3>
|
||
|
|
<div className="flex flex-wrap gap-2">
|
||
|
|
{filterColumns.map((col, idx) => (
|
||
|
|
<Badge key={idx} variant="outline" className="bg-purple-50 text-purple-700">
|
||
|
|
{col}
|
||
|
|
</Badge>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* 컬럼 목록 */}
|
||
|
|
<div className="space-y-2">
|
||
|
|
<h3 className="text-sm font-semibold">컬럼 목록</h3>
|
||
|
|
<div className="rounded-lg border">
|
||
|
|
<Table>
|
||
|
|
<TableHeader>
|
||
|
|
<TableRow>
|
||
|
|
<TableHead className="w-[50px] text-xs">#</TableHead>
|
||
|
|
<TableHead className="text-xs">컬럼명</TableHead>
|
||
|
|
<TableHead className="text-xs">데이터 타입</TableHead>
|
||
|
|
<TableHead className="text-xs">키</TableHead>
|
||
|
|
<TableHead className="text-xs">참조</TableHead>
|
||
|
|
</TableRow>
|
||
|
|
</TableHeader>
|
||
|
|
<TableBody>
|
||
|
|
{mergedColumns.length === 0 ? (
|
||
|
|
<TableRow>
|
||
|
|
<TableCell
|
||
|
|
colSpan={5}
|
||
|
|
className="py-8 text-center text-sm text-muted-foreground"
|
||
|
|
>
|
||
|
|
컬럼 정보를 불러올 수 없습니다.
|
||
|
|
</TableCell>
|
||
|
|
</TableRow>
|
||
|
|
) : (
|
||
|
|
mergedColumns.map((col, idx) => (
|
||
|
|
<TableRow
|
||
|
|
key={col.column}
|
||
|
|
className={cn(
|
||
|
|
col.isPK && "bg-orange-50/50",
|
||
|
|
col.isFK && "bg-green-50/50"
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<TableCell className="text-xs text-muted-foreground">
|
||
|
|
{idx + 1}
|
||
|
|
</TableCell>
|
||
|
|
<TableCell className="text-xs font-medium">
|
||
|
|
{col.label || col.column}
|
||
|
|
{col.label && col.column !== col.label && (
|
||
|
|
<span className="ml-1 text-muted-foreground">({col.column})</span>
|
||
|
|
)}
|
||
|
|
</TableCell>
|
||
|
|
<TableCell className="text-xs text-muted-foreground">
|
||
|
|
{col.type || "-"}
|
||
|
|
</TableCell>
|
||
|
|
<TableCell>
|
||
|
|
<div className="flex gap-1">
|
||
|
|
{col.isPK && (
|
||
|
|
<Badge variant="outline" className="bg-orange-100 text-orange-700 text-[10px]">
|
||
|
|
<Key className="mr-1 h-3 w-3" />
|
||
|
|
PK
|
||
|
|
</Badge>
|
||
|
|
)}
|
||
|
|
{col.isFK && (
|
||
|
|
<Badge variant="outline" className="bg-green-100 text-green-700 text-[10px]">
|
||
|
|
<Link2 className="mr-1 h-3 w-3" />
|
||
|
|
FK
|
||
|
|
</Badge>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</TableCell>
|
||
|
|
<TableCell className="text-xs text-muted-foreground">
|
||
|
|
{col.refTable && col.refColumn ? (
|
||
|
|
<span className="flex items-center gap-1">
|
||
|
|
<ArrowRight className="h-3 w-3" />
|
||
|
|
{col.refTable}.{col.refColumn}
|
||
|
|
</span>
|
||
|
|
) : (
|
||
|
|
"-"
|
||
|
|
)}
|
||
|
|
</TableCell>
|
||
|
|
</TableRow>
|
||
|
|
))
|
||
|
|
)}
|
||
|
|
</TableBody>
|
||
|
|
</Table>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================
|
||
|
|
// 탭 2: 조인 설정
|
||
|
|
// ============================================================
|
||
|
|
|
||
|
|
interface JoinSettingTabProps {
|
||
|
|
tableName: string;
|
||
|
|
tableLabel?: string;
|
||
|
|
screenId?: number;
|
||
|
|
joinColumnRefs: JoinColumnRef[];
|
||
|
|
fieldJoins: FieldJoin[];
|
||
|
|
tables: TableInfo[];
|
||
|
|
tableColumns: ColumnTypeInfo[];
|
||
|
|
loading: boolean;
|
||
|
|
onReload: () => void;
|
||
|
|
onSaveSuccess?: () => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
function JoinSettingTab({
|
||
|
|
tableName,
|
||
|
|
tableLabel,
|
||
|
|
screenId,
|
||
|
|
joinColumnRefs,
|
||
|
|
fieldJoins,
|
||
|
|
tables,
|
||
|
|
tableColumns,
|
||
|
|
loading,
|
||
|
|
onReload,
|
||
|
|
onSaveSuccess,
|
||
|
|
}: JoinSettingTabProps) {
|
||
|
|
const [isEditing, setIsEditing] = useState(false);
|
||
|
|
const [editItem, setEditItem] = useState<FieldJoin | null>(null);
|
||
|
|
const [formData, setFormData] = useState({
|
||
|
|
save_column: "",
|
||
|
|
join_table: "",
|
||
|
|
join_column: "",
|
||
|
|
display_column: "",
|
||
|
|
join_type: "LEFT",
|
||
|
|
});
|
||
|
|
const [targetColumns, setTargetColumns] = useState<ColumnTypeInfo[]>([]);
|
||
|
|
|
||
|
|
// 조인 테이블 변경 시 컬럼 로드
|
||
|
|
const loadTargetColumns = useCallback(async (targetTable: string) => {
|
||
|
|
if (!targetTable) {
|
||
|
|
setTargetColumns([]);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const response = await tableManagementApi.getTableColumns(targetTable);
|
||
|
|
if (response.success && response.data) {
|
||
|
|
setTargetColumns(response.data);
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error("대상 테이블 컬럼 로드 실패:", error);
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (formData.join_table) {
|
||
|
|
loadTargetColumns(formData.join_table);
|
||
|
|
}
|
||
|
|
}, [formData.join_table, loadTargetColumns]);
|
||
|
|
|
||
|
|
// 폼 초기화
|
||
|
|
const resetForm = () => {
|
||
|
|
setFormData({
|
||
|
|
save_column: "",
|
||
|
|
join_table: "",
|
||
|
|
join_column: "",
|
||
|
|
display_column: "",
|
||
|
|
join_type: "LEFT",
|
||
|
|
});
|
||
|
|
setEditItem(null);
|
||
|
|
setIsEditing(false);
|
||
|
|
setTargetColumns([]);
|
||
|
|
};
|
||
|
|
|
||
|
|
// 수정 모드
|
||
|
|
const handleEdit = (item: FieldJoin) => {
|
||
|
|
setEditItem(item);
|
||
|
|
setFormData({
|
||
|
|
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,
|
||
|
|
});
|
||
|
|
setIsEditing(true);
|
||
|
|
loadTargetColumns(item.join_table);
|
||
|
|
};
|
||
|
|
|
||
|
|
// 디자이너 설정을 DB로 저장
|
||
|
|
const handleSaveDesignerJoin = async (join: JoinColumnRef) => {
|
||
|
|
if (!screenId) {
|
||
|
|
toast.error("화면 ID가 필요합니다.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const payload = {
|
||
|
|
screen_id: screenId,
|
||
|
|
save_table: tableName,
|
||
|
|
save_column: join.column,
|
||
|
|
join_table: join.refTable,
|
||
|
|
join_column: join.refColumn,
|
||
|
|
display_column: "",
|
||
|
|
join_type: "LEFT",
|
||
|
|
is_active: "Y",
|
||
|
|
};
|
||
|
|
|
||
|
|
const response = await createFieldJoin(payload);
|
||
|
|
if (response.success) {
|
||
|
|
toast.success("조인 설정이 DB에 저장되었습니다.");
|
||
|
|
onReload();
|
||
|
|
onSaveSuccess?.();
|
||
|
|
} else {
|
||
|
|
toast.error(response.message || "저장에 실패했습니다.");
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error("저장 오류:", error);
|
||
|
|
toast.error("저장 중 오류가 발생했습니다.");
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// 저장
|
||
|
|
const handleSave = async () => {
|
||
|
|
if (!screenId) {
|
||
|
|
toast.error("화면 ID가 필요합니다.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!formData.save_column || !formData.join_table || !formData.join_column) {
|
||
|
|
toast.error("필수 항목을 모두 입력해주세요.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const payload = {
|
||
|
|
screen_id: screenId,
|
||
|
|
save_table: tableName,
|
||
|
|
save_column: formData.save_column,
|
||
|
|
join_table: formData.join_table,
|
||
|
|
join_column: formData.join_column,
|
||
|
|
display_column: formData.display_column || null,
|
||
|
|
join_type: formData.join_type,
|
||
|
|
is_active: "Y",
|
||
|
|
};
|
||
|
|
|
||
|
|
let response;
|
||
|
|
if (editItem) {
|
||
|
|
response = await updateFieldJoin(editItem.id, payload);
|
||
|
|
} else {
|
||
|
|
response = await createFieldJoin(payload);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (response.success) {
|
||
|
|
toast.success(editItem ? "조인 설정이 수정되었습니다." : "조인 설정이 추가되었습니다.");
|
||
|
|
resetForm();
|
||
|
|
onReload();
|
||
|
|
onSaveSuccess?.();
|
||
|
|
} else {
|
||
|
|
toast.error(response.message || "저장에 실패했습니다.");
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error("저장 오류:", error);
|
||
|
|
toast.error("저장 중 오류가 발생했습니다.");
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// 삭제
|
||
|
|
const handleDelete = async (id: number) => {
|
||
|
|
if (!confirm("정말로 삭제하시겠습니까?")) return;
|
||
|
|
|
||
|
|
try {
|
||
|
|
const response = await deleteFieldJoin(id);
|
||
|
|
if (response.success) {
|
||
|
|
toast.success("조인 설정이 삭제되었습니다.");
|
||
|
|
onReload();
|
||
|
|
onSaveSuccess?.();
|
||
|
|
} else {
|
||
|
|
toast.error(response.message || "삭제에 실패했습니다.");
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error("삭제 오류:", error);
|
||
|
|
toast.error("삭제 중 오류가 발생했습니다.");
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// 통합 조인 목록 (디자이너 + DB)
|
||
|
|
const unifiedJoins = useMemo(() => {
|
||
|
|
// DB에서 가져온 조인
|
||
|
|
const dbJoins = fieldJoins.map((j) => ({
|
||
|
|
...j,
|
||
|
|
id: j.id,
|
||
|
|
source: "db" as const,
|
||
|
|
}));
|
||
|
|
|
||
|
|
// 디자이너 조인 (DB에 없는 것만)
|
||
|
|
const dbJoinKeys = new Set(
|
||
|
|
fieldJoins.map((j) => `${j.save_column}:${j.join_table}:${j.join_column}`)
|
||
|
|
);
|
||
|
|
const designerJoins = joinColumnRefs
|
||
|
|
.filter(
|
||
|
|
(j) => !dbJoinKeys.has(`${j.column}:${j.refTable}:${j.refColumn}`)
|
||
|
|
)
|
||
|
|
.map((j, idx) => ({
|
||
|
|
id: `designer-${idx}`,
|
||
|
|
source: "designer" as const,
|
||
|
|
save_table: tableName,
|
||
|
|
save_column: j.column,
|
||
|
|
join_table: j.refTable,
|
||
|
|
join_table_label: j.refTableLabel,
|
||
|
|
join_column: j.refColumn,
|
||
|
|
display_column: "",
|
||
|
|
join_type: "LEFT",
|
||
|
|
}));
|
||
|
|
|
||
|
|
return [...designerJoins, ...dbJoins];
|
||
|
|
}, [fieldJoins, joinColumnRefs, tableName]);
|
||
|
|
|
||
|
|
// 테이블 옵션
|
||
|
|
const tableOptions = useMemo(
|
||
|
|
() =>
|
||
|
|
tables.map((t) => ({
|
||
|
|
value: t.table_name,
|
||
|
|
label: t.table_name,
|
||
|
|
})),
|
||
|
|
[tables]
|
||
|
|
);
|
||
|
|
|
||
|
|
// 현재 테이블 컬럼 옵션
|
||
|
|
const columnOptions = useMemo(
|
||
|
|
() =>
|
||
|
|
tableColumns.map((c) => ({
|
||
|
|
value: c.column_name,
|
||
|
|
label: c.column_name,
|
||
|
|
description: c.data_type,
|
||
|
|
})),
|
||
|
|
[tableColumns]
|
||
|
|
);
|
||
|
|
|
||
|
|
// 대상 테이블 컬럼 옵션
|
||
|
|
const targetColumnOptions = useMemo(
|
||
|
|
() =>
|
||
|
|
targetColumns.map((c) => ({
|
||
|
|
value: c.column_name,
|
||
|
|
label: c.column_name,
|
||
|
|
description: c.data_type,
|
||
|
|
})),
|
||
|
|
[targetColumns]
|
||
|
|
);
|
||
|
|
|
||
|
|
if (loading) {
|
||
|
|
return (
|
||
|
|
<div className="flex h-64 items-center justify-center">
|
||
|
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-4">
|
||
|
|
{/* 입력 폼 */}
|
||
|
|
{screenId && (
|
||
|
|
<div className="space-y-3 rounded-lg bg-muted/50 p-4">
|
||
|
|
<div className="text-sm font-medium">
|
||
|
|
{isEditing ? "조인 설정 수정" : "새 조인 설정 추가"}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-5">
|
||
|
|
<div>
|
||
|
|
<Label className="text-xs">현재 테이블 컬럼 *</Label>
|
||
|
|
<SearchableSelect
|
||
|
|
value={formData.save_column}
|
||
|
|
onValueChange={(v) => setFormData({ ...formData, save_column: v })}
|
||
|
|
options={columnOptions}
|
||
|
|
placeholder="컬럼 선택"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<Label className="text-xs">조인 테이블 *</Label>
|
||
|
|
<SearchableSelect
|
||
|
|
value={formData.join_table}
|
||
|
|
onValueChange={(v) =>
|
||
|
|
setFormData({ ...formData, join_table: v, join_column: "", display_column: "" })
|
||
|
|
}
|
||
|
|
options={tableOptions}
|
||
|
|
placeholder="테이블 선택"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<Label className="text-xs">조인 컬럼 *</Label>
|
||
|
|
<SearchableSelect
|
||
|
|
value={formData.join_column}
|
||
|
|
onValueChange={(v) => setFormData({ ...formData, join_column: v })}
|
||
|
|
options={targetColumnOptions}
|
||
|
|
placeholder="컬럼 선택"
|
||
|
|
disabled={!formData.join_table}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<Label className="text-xs">표시 컬럼</Label>
|
||
|
|
<SearchableSelect
|
||
|
|
value={formData.display_column}
|
||
|
|
onValueChange={(v) => setFormData({ ...formData, display_column: v })}
|
||
|
|
options={targetColumnOptions}
|
||
|
|
placeholder="선택 (옵션)"
|
||
|
|
disabled={!formData.join_table}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<Label className="text-xs">조인 타입</Label>
|
||
|
|
<SearchableSelect
|
||
|
|
value={formData.join_type}
|
||
|
|
onValueChange={(v) => setFormData({ ...formData, join_type: v })}
|
||
|
|
options={[
|
||
|
|
{ value: "LEFT", label: "LEFT JOIN" },
|
||
|
|
{ value: "INNER", label: "INNER JOIN" },
|
||
|
|
{ value: "RIGHT", label: "RIGHT JOIN" },
|
||
|
|
]}
|
||
|
|
placeholder="타입 선택"
|
||
|
|
/>
|
||
|
|
</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="rounded-lg border">
|
||
|
|
<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">타입</TableHead>
|
||
|
|
<TableHead className="w-[100px] text-xs">작업</TableHead>
|
||
|
|
</TableRow>
|
||
|
|
</TableHeader>
|
||
|
|
<TableBody>
|
||
|
|
{unifiedJoins.length === 0 ? (
|
||
|
|
<TableRow>
|
||
|
|
<TableCell
|
||
|
|
colSpan={6}
|
||
|
|
className="py-8 text-center text-sm text-muted-foreground"
|
||
|
|
>
|
||
|
|
등록된 조인 설정이 없습니다.
|
||
|
|
</TableCell>
|
||
|
|
</TableRow>
|
||
|
|
) : (
|
||
|
|
unifiedJoins.map((item) => (
|
||
|
|
<TableRow
|
||
|
|
key={item.id}
|
||
|
|
className={item.source === "designer" ? "bg-blue-50" : ""}
|
||
|
|
>
|
||
|
|
<TableCell className="text-xs">
|
||
|
|
<Badge
|
||
|
|
variant="outline"
|
||
|
|
className={cn(
|
||
|
|
"text-xs",
|
||
|
|
item.source === "designer"
|
||
|
|
? "bg-blue-100 text-blue-700"
|
||
|
|
: "bg-gray-100 text-gray-700"
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{item.source === "designer" ? "화면" : "DB"}
|
||
|
|
</Badge>
|
||
|
|
</TableCell>
|
||
|
|
<TableCell className="text-xs font-medium">
|
||
|
|
{item.save_column}
|
||
|
|
</TableCell>
|
||
|
|
<TableCell className="text-xs">
|
||
|
|
{"join_table_label" in item && item.join_table_label
|
||
|
|
? item.join_table_label
|
||
|
|
: item.join_table}
|
||
|
|
</TableCell>
|
||
|
|
<TableCell className="text-xs">{item.join_column}</TableCell>
|
||
|
|
<TableCell className="text-xs">
|
||
|
|
<Badge variant="outline" className="text-xs">
|
||
|
|
{item.join_type}
|
||
|
|
</Badge>
|
||
|
|
</TableCell>
|
||
|
|
<TableCell>
|
||
|
|
<div className="flex gap-1">
|
||
|
|
{item.source === "designer" ? (
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="sm"
|
||
|
|
className="h-7 text-xs"
|
||
|
|
onClick={() =>
|
||
|
|
handleSaveDesignerJoin({
|
||
|
|
column: item.save_column,
|
||
|
|
refTable: item.join_table,
|
||
|
|
refColumn: item.join_column,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
disabled={!screenId}
|
||
|
|
>
|
||
|
|
<Save className="mr-1 h-3 w-3" />
|
||
|
|
DB 저장
|
||
|
|
</Button>
|
||
|
|
) : (
|
||
|
|
<>
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="icon"
|
||
|
|
className="h-7 w-7"
|
||
|
|
onClick={() => handleEdit(item as FieldJoin)}
|
||
|
|
>
|
||
|
|
<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>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</TableCell>
|
||
|
|
</TableRow>
|
||
|
|
))
|
||
|
|
)}
|
||
|
|
</TableBody>
|
||
|
|
</Table>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 안내 */}
|
||
|
|
<div className="text-xs text-muted-foreground">
|
||
|
|
* 화면: 화면 디자이너에서 설정됨 (DB 저장으로 변환 가능) | * DB: 데이터베이스에 저장됨 (수정/삭제 가능)
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================
|
||
|
|
// 탭 3: 참조 관계
|
||
|
|
// ============================================================
|
||
|
|
|
||
|
|
interface ReferenceTabProps {
|
||
|
|
tableName: string;
|
||
|
|
tableLabel?: string;
|
||
|
|
referencedBy: ReferencedBy[];
|
||
|
|
joinColumnRefs: JoinColumnRef[];
|
||
|
|
loading: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
function ReferenceTab({
|
||
|
|
tableName,
|
||
|
|
tableLabel,
|
||
|
|
referencedBy,
|
||
|
|
joinColumnRefs,
|
||
|
|
loading,
|
||
|
|
}: ReferenceTabProps) {
|
||
|
|
if (loading) {
|
||
|
|
return (
|
||
|
|
<div className="flex h-64 items-center justify-center">
|
||
|
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-6">
|
||
|
|
{/* 이 테이블이 참조하는 테이블 */}
|
||
|
|
<div className="space-y-2">
|
||
|
|
<h3 className="flex items-center gap-2 text-sm font-semibold">
|
||
|
|
<ArrowRight className="h-4 w-4 text-orange-500" />
|
||
|
|
이 테이블이 참조하는 테이블 ({joinColumnRefs.length}개)
|
||
|
|
</h3>
|
||
|
|
{joinColumnRefs.length > 0 ? (
|
||
|
|
<div className="space-y-2">
|
||
|
|
{joinColumnRefs.map((ref, idx) => (
|
||
|
|
<div
|
||
|
|
key={idx}
|
||
|
|
className="flex items-center gap-3 rounded-lg border bg-orange-50/50 p-3"
|
||
|
|
>
|
||
|
|
<Badge variant="outline" className="bg-orange-100 text-orange-700">
|
||
|
|
{ref.column}
|
||
|
|
</Badge>
|
||
|
|
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||
|
|
<div className="flex-1">
|
||
|
|
<span className="font-medium">
|
||
|
|
{ref.refTableLabel || ref.refTable}
|
||
|
|
</span>
|
||
|
|
<span className="text-muted-foreground">.{ref.refColumn}</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">
|
||
|
|
참조하는 테이블이 없습니다.
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 이 테이블을 참조하는 테이블 */}
|
||
|
|
<div className="space-y-2">
|
||
|
|
<h3 className="flex items-center gap-2 text-sm font-semibold">
|
||
|
|
<Eye className="h-4 w-4 text-green-500" />
|
||
|
|
이 테이블을 참조하는 테이블 ({referencedBy.length}개)
|
||
|
|
</h3>
|
||
|
|
{referencedBy.length > 0 ? (
|
||
|
|
<div className="space-y-2">
|
||
|
|
{referencedBy.map((ref, idx) => (
|
||
|
|
<div
|
||
|
|
key={idx}
|
||
|
|
className="flex items-center gap-3 rounded-lg border bg-green-50/50 p-3"
|
||
|
|
>
|
||
|
|
<div className="flex-1">
|
||
|
|
<span className="font-medium">
|
||
|
|
{ref.fromTableLabel || ref.fromTable}
|
||
|
|
</span>
|
||
|
|
<span className="text-muted-foreground">.{ref.fromColumn}</span>
|
||
|
|
</div>
|
||
|
|
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||
|
|
<Badge variant="outline" className="bg-green-100 text-green-700">
|
||
|
|
{ref.toColumn}
|
||
|
|
</Badge>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">
|
||
|
|
이 테이블을 참조하는 테이블이 없습니다.
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export default TableSettingModal;
|
||
|
|
|