1890 lines
64 KiB
TypeScript
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>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|