feat: 화면 그룹 관리 기능 추가
- 화면 그룹 CRUD API 및 라우트 구현 - 화면 그룹 목록 조회, 생성, 수정, 삭제 기능 추가 - 화면-그룹 연결 및 데이터 흐름 관리 기능 포함 - 프론트엔드에서 화면 그룹 필터링 및 시각화 기능
This commit is contained in:
232
frontend/components/screen/ScreenGroupTreeView.tsx
Normal file
232
frontend/components/screen/ScreenGroupTreeView.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronRight, ChevronDown, Monitor, FolderOpen, Folder } from "lucide-react";
|
||||
import { ScreenDefinition } from "@/types/screen";
|
||||
import { ScreenGroup, getScreenGroups } from "@/lib/api/screenGroup";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface ScreenGroupTreeViewProps {
|
||||
screens: ScreenDefinition[];
|
||||
selectedScreen: ScreenDefinition | null;
|
||||
onScreenSelect: (screen: ScreenDefinition) => void;
|
||||
onScreenDesign: (screen: ScreenDefinition) => void;
|
||||
companyCode?: string;
|
||||
}
|
||||
|
||||
interface TreeNode {
|
||||
type: "group" | "screen";
|
||||
id: string;
|
||||
name: string;
|
||||
data?: ScreenDefinition | ScreenGroup;
|
||||
children?: TreeNode[];
|
||||
expanded?: boolean;
|
||||
}
|
||||
|
||||
export function ScreenGroupTreeView({
|
||||
screens,
|
||||
selectedScreen,
|
||||
onScreenSelect,
|
||||
onScreenDesign,
|
||||
companyCode,
|
||||
}: ScreenGroupTreeViewProps) {
|
||||
const [groups, setGroups] = useState<ScreenGroup[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
const [groupScreensMap, setGroupScreensMap] = useState<Map<number, number[]>>(new Map());
|
||||
|
||||
// 그룹 목록 로드
|
||||
useEffect(() => {
|
||||
const loadGroups = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await getScreenGroups({});
|
||||
if (response.success && response.data) {
|
||||
setGroups(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("그룹 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadGroups();
|
||||
}, [companyCode]);
|
||||
|
||||
// 그룹에 속한 화면 ID들을 가져오기
|
||||
const getGroupedScreenIds = (): Set<number> => {
|
||||
const ids = new Set<number>();
|
||||
groupScreensMap.forEach((screenIds) => {
|
||||
screenIds.forEach((id) => ids.add(id));
|
||||
});
|
||||
return ids;
|
||||
};
|
||||
|
||||
// 미분류 화면들 (어떤 그룹에도 속하지 않은 화면)
|
||||
const getUngroupedScreens = (): ScreenDefinition[] => {
|
||||
const groupedIds = getGroupedScreenIds();
|
||||
return screens.filter((screen) => !groupedIds.has(screen.screenId));
|
||||
};
|
||||
|
||||
// 그룹에 속한 화면들
|
||||
const getScreensInGroup = (groupId: number): ScreenDefinition[] => {
|
||||
const screenIds = groupScreensMap.get(groupId) || [];
|
||||
return screens.filter((screen) => screenIds.includes(screen.screenId));
|
||||
};
|
||||
|
||||
const toggleGroup = (groupId: string) => {
|
||||
const newExpanded = new Set(expandedGroups);
|
||||
if (newExpanded.has(groupId)) {
|
||||
newExpanded.delete(groupId);
|
||||
} else {
|
||||
newExpanded.add(groupId);
|
||||
}
|
||||
setExpandedGroups(newExpanded);
|
||||
};
|
||||
|
||||
const handleScreenClick = (screen: ScreenDefinition) => {
|
||||
onScreenSelect(screen);
|
||||
};
|
||||
|
||||
const handleScreenDoubleClick = (screen: ScreenDefinition) => {
|
||||
onScreenDesign(screen);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="text-sm text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ungroupedScreens = getUngroupedScreens();
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="p-2">
|
||||
{/* 그룹화된 화면들 */}
|
||||
{groups.map((group) => {
|
||||
const groupId = String(group.id);
|
||||
const isExpanded = expandedGroups.has(groupId);
|
||||
const groupScreens = getScreensInGroup(group.id);
|
||||
|
||||
return (
|
||||
<div key={groupId} className="mb-1">
|
||||
{/* 그룹 헤더 */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer hover:bg-accent transition-colors",
|
||||
"text-sm font-medium"
|
||||
)}
|
||||
onClick={() => toggleGroup(groupId)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
{isExpanded ? (
|
||||
<FolderOpen className="h-4 w-4 shrink-0 text-amber-500" />
|
||||
) : (
|
||||
<Folder className="h-4 w-4 shrink-0 text-amber-500" />
|
||||
)}
|
||||
<span className="truncate flex-1">{group.groupName}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{groupScreens.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 그룹 내 화면들 */}
|
||||
{isExpanded && (
|
||||
<div className="ml-4 mt-1 space-y-0.5">
|
||||
{groupScreens.length === 0 ? (
|
||||
<div className="pl-6 py-2 text-xs text-muted-foreground">
|
||||
화면이 없습니다
|
||||
</div>
|
||||
) : (
|
||||
groupScreens.map((screen) => (
|
||||
<div
|
||||
key={screen.screenId}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors",
|
||||
"text-sm hover:bg-accent",
|
||||
selectedScreen?.screenId === screen.screenId && "bg-accent"
|
||||
)}
|
||||
onClick={() => handleScreenClick(screen)}
|
||||
onDoubleClick={() => handleScreenDoubleClick(screen)}
|
||||
>
|
||||
<Monitor className="h-4 w-4 shrink-0 text-blue-500" />
|
||||
<span className="truncate flex-1">{screen.screenName}</span>
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[100px]">
|
||||
{screen.screenCode}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 미분류 화면들 */}
|
||||
{ungroupedScreens.length > 0 && (
|
||||
<div className="mb-1">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer hover:bg-accent transition-colors",
|
||||
"text-sm font-medium text-muted-foreground"
|
||||
)}
|
||||
onClick={() => toggleGroup("ungrouped")}
|
||||
>
|
||||
{expandedGroups.has("ungrouped") ? (
|
||||
<ChevronDown className="h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<Folder className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate flex-1">미분류</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{ungroupedScreens.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{expandedGroups.has("ungrouped") && (
|
||||
<div className="ml-4 mt-1 space-y-0.5">
|
||||
{ungroupedScreens.map((screen) => (
|
||||
<div
|
||||
key={screen.screenId}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors",
|
||||
"text-sm hover:bg-accent",
|
||||
selectedScreen?.screenId === screen.screenId && "bg-accent"
|
||||
)}
|
||||
onClick={() => handleScreenClick(screen)}
|
||||
onDoubleClick={() => handleScreenDoubleClick(screen)}
|
||||
>
|
||||
<Monitor className="h-4 w-4 shrink-0 text-blue-500" />
|
||||
<span className="truncate flex-1">{screen.screenName}</span>
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[100px]">
|
||||
{screen.screenCode}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.length === 0 && ungroupedScreens.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Monitor className="h-12 w-12 text-muted-foreground/50 mb-2" />
|
||||
<p className="text-sm text-muted-foreground">등록된 화면이 없습니다</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette, RotateC
|
||||
import { ScreenDefinition } from "@/types/screen";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { ExternalRestApiConnectionAPI, ExternalRestApiConnection } from "@/lib/api/externalRestApiConnection";
|
||||
import { getScreenGroups, ScreenGroup } from "@/lib/api/screenGroup";
|
||||
import { Layers } from "lucide-react";
|
||||
import CreateScreenModal from "./CreateScreenModal";
|
||||
import CopyScreenModal from "./CopyScreenModal";
|
||||
import dynamic from "next/dynamic";
|
||||
@@ -93,6 +95,11 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||
const [isCopyOpen, setIsCopyOpen] = useState(false);
|
||||
const [screenToCopy, setScreenToCopy] = useState<ScreenDefinition | null>(null);
|
||||
|
||||
// 그룹 필터 관련 상태
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string>("all");
|
||||
const [groups, setGroups] = useState<ScreenGroup[]>([]);
|
||||
const [loadingGroups, setLoadingGroups] = useState(false);
|
||||
|
||||
// 검색어 디바운스를 위한 타이머 ref
|
||||
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
@@ -183,6 +190,25 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||
}
|
||||
};
|
||||
|
||||
// 화면 그룹 목록 로드
|
||||
useEffect(() => {
|
||||
loadGroups();
|
||||
}, []);
|
||||
|
||||
const loadGroups = async () => {
|
||||
try {
|
||||
setLoadingGroups(true);
|
||||
const response = await getScreenGroups();
|
||||
if (response.success && response.data) {
|
||||
setGroups(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("그룹 목록 조회 실패:", error);
|
||||
} finally {
|
||||
setLoadingGroups(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 검색어 디바운스 처리 (150ms 지연 - 빠른 응답)
|
||||
useEffect(() => {
|
||||
// 이전 타이머 취소
|
||||
@@ -224,6 +250,11 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||
params.companyCode = selectedCompanyCode;
|
||||
}
|
||||
|
||||
// 그룹 필터
|
||||
if (selectedGroupId !== "all") {
|
||||
params.groupId = selectedGroupId;
|
||||
}
|
||||
|
||||
console.log("🔍 화면 목록 API 호출:", params); // 디버깅용
|
||||
const resp = await screenApi.getScreens(params);
|
||||
console.log("✅ 화면 목록 응답:", resp); // 디버깅용
|
||||
@@ -256,7 +287,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||
return () => {
|
||||
abort = true;
|
||||
};
|
||||
}, [currentPage, debouncedSearchTerm, activeTab, selectedCompanyCode, isSuperAdmin]);
|
||||
}, [currentPage, debouncedSearchTerm, activeTab, selectedCompanyCode, selectedGroupId, isSuperAdmin]);
|
||||
|
||||
const filteredScreens = screens; // 서버 필터 기준 사용
|
||||
|
||||
@@ -671,6 +702,25 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 그룹 필터 */}
|
||||
<div className="w-full sm:w-[180px]">
|
||||
<Select value={selectedGroupId} onValueChange={setSelectedGroupId} disabled={activeTab === "trash"}>
|
||||
<SelectTrigger className="h-10 text-sm">
|
||||
<Layers className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||
<SelectValue placeholder="전체 그룹" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체 그룹</SelectItem>
|
||||
<SelectItem value="ungrouped">미분류</SelectItem>
|
||||
{groups.map((group) => (
|
||||
<SelectItem key={group.id} value={String(group.id)}>
|
||||
{group.groupName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 검색 입력 */}
|
||||
<div className="w-full sm:w-[400px]">
|
||||
<div className="relative">
|
||||
|
||||
446
frontend/components/screen/ScreenNode.tsx
Normal file
446
frontend/components/screen/ScreenNode.tsx
Normal file
@@ -0,0 +1,446 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import {
|
||||
Monitor,
|
||||
Database,
|
||||
FormInput,
|
||||
Table2,
|
||||
LayoutDashboard,
|
||||
MousePointer2,
|
||||
Key,
|
||||
Link2,
|
||||
Columns3,
|
||||
} from "lucide-react";
|
||||
import { ScreenLayoutSummary } from "@/lib/api/screenGroup";
|
||||
|
||||
// ========== 타입 정의 ==========
|
||||
|
||||
// 화면 노드 데이터 인터페이스
|
||||
export interface ScreenNodeData {
|
||||
label: string;
|
||||
subLabel?: string;
|
||||
type: "screen" | "table" | "action";
|
||||
tableName?: string;
|
||||
isMain?: boolean;
|
||||
// 레이아웃 요약 정보 (미리보기용)
|
||||
layoutSummary?: ScreenLayoutSummary;
|
||||
}
|
||||
|
||||
// 테이블 노드 데이터 인터페이스
|
||||
export interface TableNodeData {
|
||||
label: string;
|
||||
subLabel?: string;
|
||||
isMain?: boolean;
|
||||
columns?: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
isPrimaryKey?: boolean;
|
||||
isForeignKey?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ========== 유틸리티 함수 ==========
|
||||
|
||||
// 화면 타입별 아이콘
|
||||
const getScreenTypeIcon = (screenType?: string) => {
|
||||
switch (screenType) {
|
||||
case "grid":
|
||||
return <Table2 className="h-4 w-4" />;
|
||||
case "dashboard":
|
||||
return <LayoutDashboard className="h-4 w-4" />;
|
||||
case "action":
|
||||
return <MousePointer2 className="h-4 w-4" />;
|
||||
default:
|
||||
return <FormInput className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
// 화면 타입별 색상 (헤더)
|
||||
const getScreenTypeColor = (screenType?: string, isMain?: boolean) => {
|
||||
if (!isMain) return "bg-slate-400";
|
||||
switch (screenType) {
|
||||
case "grid":
|
||||
return "bg-violet-500";
|
||||
case "dashboard":
|
||||
return "bg-amber-500";
|
||||
case "action":
|
||||
return "bg-rose-500";
|
||||
default:
|
||||
return "bg-blue-500";
|
||||
}
|
||||
};
|
||||
|
||||
// 화면 타입별 라벨
|
||||
const getScreenTypeLabel = (screenType?: string) => {
|
||||
switch (screenType) {
|
||||
case "grid":
|
||||
return "그리드";
|
||||
case "dashboard":
|
||||
return "대시보드";
|
||||
case "action":
|
||||
return "액션";
|
||||
default:
|
||||
return "폼";
|
||||
}
|
||||
};
|
||||
|
||||
// ========== 화면 노드 (상단) - 미리보기 표시 ==========
|
||||
export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
|
||||
const { label, subLabel, isMain, tableName, layoutSummary } = data;
|
||||
const screenType = layoutSummary?.screenType || "form";
|
||||
const headerColor = getScreenTypeColor(screenType, isMain);
|
||||
|
||||
return (
|
||||
<div className="group relative flex h-[320px] w-[260px] flex-col overflow-hidden rounded-lg border border-border bg-card shadow-md transition-all hover:shadow-lg hover:ring-2 hover:ring-primary/20">
|
||||
{/* Handles */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id="left"
|
||||
className="!h-2 !w-2 !border-2 !border-background !bg-blue-500 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="right"
|
||||
className="!h-2 !w-2 !border-2 !border-background !bg-blue-500 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id="bottom"
|
||||
className="!h-2 !w-2 !border-2 !border-background !bg-blue-500 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
|
||||
{/* 헤더 (컬러) */}
|
||||
<div className={`flex items-center gap-2 px-3 py-2 text-white ${headerColor}`}>
|
||||
<Monitor className="h-4 w-4" />
|
||||
<span className="flex-1 truncate text-xs font-semibold">{label}</span>
|
||||
{isMain && <span className="flex h-2 w-2 rounded-full bg-white/80" />}
|
||||
</div>
|
||||
|
||||
{/* 화면 미리보기 영역 (컴팩트) */}
|
||||
<div className="h-[140px] overflow-hidden bg-slate-50 p-2">
|
||||
{layoutSummary ? (
|
||||
<ScreenPreview layoutSummary={layoutSummary} screenType={screenType} />
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
||||
{getScreenTypeIcon(screenType)}
|
||||
<span className="mt-1 text-[10px]">화면: {label}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 필드 매핑 영역 */}
|
||||
<div className="flex-1 overflow-hidden border-t border-slate-200 bg-white px-2 py-1.5">
|
||||
<div className="mb-1 flex items-center gap-1 text-[9px] font-medium text-slate-500">
|
||||
<Columns3 className="h-3 w-3" />
|
||||
<span>필드 매핑</span>
|
||||
<span className="ml-auto text-[8px] text-slate-400">
|
||||
{layoutSummary?.layoutItems?.filter(i => i.label && !i.componentKind?.includes('button')).length || 0}개
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 overflow-y-auto" style={{ maxHeight: '80px' }}>
|
||||
{layoutSummary?.layoutItems
|
||||
?.filter(item => item.label && !item.componentKind?.includes('button'))
|
||||
?.slice(0, 6)
|
||||
?.map((item, idx) => (
|
||||
<div key={idx} className="flex items-center gap-1 rounded bg-slate-50 px-1.5 py-0.5">
|
||||
<div className={`h-1.5 w-1.5 rounded-full ${
|
||||
item.componentKind === 'table-list' ? 'bg-violet-400' :
|
||||
item.componentKind?.includes('select') ? 'bg-amber-400' :
|
||||
'bg-slate-400'
|
||||
}`} />
|
||||
<span className="flex-1 truncate text-[9px] text-slate-600">{item.label}</span>
|
||||
<span className="text-[8px] text-slate-400">{item.componentKind?.split('-')[0] || 'field'}</span>
|
||||
</div>
|
||||
)) || (
|
||||
<div className="text-center text-[9px] text-slate-400 py-2">필드 정보 없음</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 푸터 (테이블 정보) */}
|
||||
<div className="flex items-center justify-between border-t border-border bg-muted/30 px-3 py-1.5">
|
||||
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
<Database className="h-3 w-3" />
|
||||
<span className="max-w-[120px] truncate font-mono">{tableName || "No Table"}</span>
|
||||
</div>
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 text-[9px] font-medium text-muted-foreground">
|
||||
{getScreenTypeLabel(screenType)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ========== 컴포넌트 종류별 미니어처 색상 ==========
|
||||
// componentKind는 더 정확한 컴포넌트 타입 (table-list, button-primary 등)
|
||||
const getComponentColor = (componentKind: string) => {
|
||||
// 테이블/그리드 관련
|
||||
if (componentKind === "table-list" || componentKind === "data-grid") {
|
||||
return "bg-violet-200 border-violet-400";
|
||||
}
|
||||
// 검색 필터
|
||||
if (componentKind === "table-search-widget" || componentKind === "search-filter") {
|
||||
return "bg-pink-200 border-pink-400";
|
||||
}
|
||||
// 버튼 관련
|
||||
if (componentKind?.includes("button")) {
|
||||
return "bg-blue-300 border-blue-500";
|
||||
}
|
||||
// 입력 필드
|
||||
if (componentKind?.includes("input") || componentKind?.includes("text")) {
|
||||
return "bg-slate-200 border-slate-400";
|
||||
}
|
||||
// 셀렉트/드롭다운
|
||||
if (componentKind?.includes("select") || componentKind?.includes("dropdown")) {
|
||||
return "bg-amber-200 border-amber-400";
|
||||
}
|
||||
// 차트
|
||||
if (componentKind?.includes("chart")) {
|
||||
return "bg-emerald-200 border-emerald-400";
|
||||
}
|
||||
// 커스텀 위젯
|
||||
if (componentKind === "custom") {
|
||||
return "bg-pink-200 border-pink-400";
|
||||
}
|
||||
return "bg-slate-100 border-slate-300";
|
||||
};
|
||||
|
||||
// ========== 화면 미리보기 컴포넌트 - 화면 타입별 간단한 일러스트 ==========
|
||||
const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType: string }> = ({
|
||||
layoutSummary,
|
||||
screenType,
|
||||
}) => {
|
||||
const { totalComponents, widgetCounts } = layoutSummary;
|
||||
|
||||
// 그리드 화면 일러스트
|
||||
if (screenType === "grid") {
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-2 rounded-lg border border-slate-200 bg-gradient-to-b from-slate-50 to-white p-3">
|
||||
{/* 상단 툴바 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-16 rounded bg-pink-400/80 shadow-sm" />
|
||||
<div className="flex-1" />
|
||||
<div className="h-4 w-8 rounded bg-blue-500 shadow-sm" />
|
||||
<div className="h-4 w-8 rounded bg-blue-500 shadow-sm" />
|
||||
<div className="h-4 w-8 rounded bg-rose-500 shadow-sm" />
|
||||
</div>
|
||||
{/* 테이블 헤더 */}
|
||||
<div className="flex gap-1 rounded-t-md bg-violet-500 px-2 py-2 shadow-sm">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="h-2.5 flex-1 rounded bg-white/40" />
|
||||
))}
|
||||
</div>
|
||||
{/* 테이블 행들 */}
|
||||
<div className="flex flex-1 flex-col gap-1 overflow-hidden">
|
||||
{[...Array(7)].map((_, i) => (
|
||||
<div key={i} className={`flex gap-1 rounded px-2 py-1.5 ${i % 2 === 0 ? "bg-slate-100" : "bg-white"}`}>
|
||||
{[...Array(5)].map((_, j) => (
|
||||
<div key={j} className="h-2 flex-1 rounded bg-slate-300/70" />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* 페이지네이션 */}
|
||||
<div className="flex items-center justify-center gap-2 pt-1">
|
||||
<div className="h-2.5 w-4 rounded bg-slate-300" />
|
||||
<div className="h-2.5 w-4 rounded bg-blue-500" />
|
||||
<div className="h-2.5 w-4 rounded bg-slate-300" />
|
||||
<div className="h-2.5 w-4 rounded bg-slate-300" />
|
||||
</div>
|
||||
{/* 컴포넌트 수 */}
|
||||
<div className="absolute bottom-2 right-2 rounded-md bg-slate-800/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
|
||||
{totalComponents}개
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 폼 화면 일러스트
|
||||
if (screenType === "form") {
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 rounded-lg border border-slate-200 bg-gradient-to-b from-slate-50 to-white p-3">
|
||||
{/* 폼 필드들 */}
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<div className="h-2.5 w-14 rounded bg-slate-400" />
|
||||
<div className="h-5 flex-1 rounded-md border border-slate-300 bg-white shadow-sm" />
|
||||
</div>
|
||||
))}
|
||||
{/* 버튼 영역 */}
|
||||
<div className="mt-auto flex justify-end gap-2 border-t border-slate-100 pt-3">
|
||||
<div className="h-5 w-14 rounded-md bg-slate-300 shadow-sm" />
|
||||
<div className="h-5 w-14 rounded-md bg-blue-500 shadow-sm" />
|
||||
</div>
|
||||
{/* 컴포넌트 수 */}
|
||||
<div className="absolute bottom-2 right-2 rounded-md bg-slate-800/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
|
||||
{totalComponents}개
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 대시보드 화면 일러스트
|
||||
if (screenType === "dashboard") {
|
||||
return (
|
||||
<div className="grid h-full grid-cols-2 gap-2 rounded-lg border border-slate-200 bg-gradient-to-b from-slate-50 to-white p-3">
|
||||
{/* 카드/차트들 */}
|
||||
<div className="rounded-lg bg-emerald-100 p-2 shadow-sm">
|
||||
<div className="mb-2 h-2.5 w-10 rounded bg-emerald-400" />
|
||||
<div className="h-10 rounded-md bg-emerald-300/80" />
|
||||
</div>
|
||||
<div className="rounded-lg bg-amber-100 p-2 shadow-sm">
|
||||
<div className="mb-2 h-2.5 w-10 rounded bg-amber-400" />
|
||||
<div className="h-10 rounded-md bg-amber-300/80" />
|
||||
</div>
|
||||
<div className="col-span-2 rounded-lg bg-blue-100 p-2 shadow-sm">
|
||||
<div className="mb-2 h-2.5 w-12 rounded bg-blue-400" />
|
||||
<div className="flex h-14 items-end gap-1">
|
||||
{[...Array(10)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 rounded-t bg-blue-400/80"
|
||||
style={{ height: `${25 + Math.random() * 75}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* 컴포넌트 수 */}
|
||||
<div className="absolute bottom-2 right-2 rounded-md bg-slate-800/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
|
||||
{totalComponents}개
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 액션 화면 일러스트 (버튼 중심)
|
||||
if (screenType === "action") {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-lg border border-slate-200 bg-gradient-to-b from-slate-50 to-white p-3">
|
||||
<div className="rounded-full bg-slate-100 p-4 text-slate-400">
|
||||
<MousePointer2 className="h-10 w-10" />
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="h-7 w-16 rounded-md bg-blue-500 shadow-sm" />
|
||||
<div className="h-7 w-16 rounded-md bg-slate-300 shadow-sm" />
|
||||
</div>
|
||||
<div className="text-xs font-medium text-slate-400">액션 화면</div>
|
||||
{/* 컴포넌트 수 */}
|
||||
<div className="absolute bottom-2 right-2 rounded-md bg-slate-800/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
|
||||
{totalComponents}개
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 기본 (알 수 없는 타입)
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 rounded-lg border border-slate-200 bg-gradient-to-b from-slate-50 to-white text-slate-400">
|
||||
<div className="rounded-full bg-slate-100 p-4">
|
||||
{getScreenTypeIcon(screenType)}
|
||||
</div>
|
||||
<span className="text-sm font-medium">{totalComponents}개 컴포넌트</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ========== 테이블 노드 (하단) - 컬럼 목록 표시 ==========
|
||||
export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
||||
const { label, subLabel, isMain, columns } = data;
|
||||
|
||||
return (
|
||||
<div className="group relative flex h-[320px] w-[260px] flex-col overflow-hidden rounded-lg border border-border bg-card shadow-md transition-all hover:shadow-lg hover:ring-2 hover:ring-emerald-500/20">
|
||||
{/* Handles */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
id="top"
|
||||
className="!h-2 !w-2 !border-2 !border-background !bg-emerald-500 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id="left"
|
||||
className="!h-2 !w-2 !border-2 !border-background !bg-emerald-500 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="right"
|
||||
className="!h-2 !w-2 !border-2 !border-background !bg-emerald-500 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
|
||||
{/* 헤더 (초록색) */}
|
||||
<div className={`flex items-center gap-2 px-3 py-2 text-white ${isMain ? "bg-emerald-600" : "bg-slate-500"}`}>
|
||||
<Database className="h-4 w-4" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate text-xs font-semibold">{label}</div>
|
||||
{subLabel && <div className="truncate text-[10px] opacity-80">{subLabel}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 목록 */}
|
||||
<div className="flex-1 overflow-hidden p-2">
|
||||
{columns && columns.length > 0 ? (
|
||||
<div className="flex h-full flex-col gap-0.5">
|
||||
{columns.map((col, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center gap-1.5 rounded bg-slate-50 px-2 py-1 hover:bg-slate-100"
|
||||
>
|
||||
{/* PK/FK 아이콘 */}
|
||||
{col.isPrimaryKey && <Key className="h-3 w-3 text-amber-500" />}
|
||||
{col.isForeignKey && !col.isPrimaryKey && <Link2 className="h-3 w-3 text-blue-500" />}
|
||||
{!col.isPrimaryKey && !col.isForeignKey && <div className="w-3" />}
|
||||
|
||||
{/* 컬럼명 */}
|
||||
<span className="flex-1 truncate font-mono text-[10px] font-medium text-slate-700">
|
||||
{col.name}
|
||||
</span>
|
||||
|
||||
{/* 타입 */}
|
||||
<span className="text-[9px] text-slate-400">{col.type}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{columns.length > 8 && (
|
||||
<div className="text-center text-[9px] text-slate-400">... 외 {columns.length - 8}개 컬럼</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
||||
<Database className="h-6 w-6 text-slate-300" />
|
||||
<span className="mt-1 text-[9px] text-slate-400">컬럼 정보 없음</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="flex items-center justify-between border-t border-border bg-muted/30 px-3 py-1.5">
|
||||
<span className="text-[10px] text-muted-foreground">PostgreSQL</span>
|
||||
{columns && (
|
||||
<span className="text-[10px] text-muted-foreground">{columns.length}개 컬럼</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ========== 기존 호환성 유지용 ==========
|
||||
export const LegacyScreenNode = ScreenNode;
|
||||
export const AggregateNode: React.FC<{ data: any }> = ({ data }) => {
|
||||
return (
|
||||
<div className="rounded-lg border-2 border-purple-300 bg-white p-3 shadow-lg">
|
||||
<Handle type="target" position={Position.Left} id="left" className="!h-3 !w-3 !bg-purple-500" />
|
||||
<Handle type="source" position={Position.Right} id="right" className="!h-3 !w-3 !bg-purple-500" />
|
||||
<div className="flex items-center gap-2 text-purple-600">
|
||||
<Table2 className="h-4 w-4" />
|
||||
<span className="text-sm font-semibold">{data.label || "Aggregate"}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
367
frontend/components/screen/ScreenRelationFlow.tsx
Normal file
367
frontend/components/screen/ScreenRelationFlow.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
ReactFlow,
|
||||
Controls,
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Node,
|
||||
Edge,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
MarkerType,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
|
||||
import { ScreenDefinition } from "@/types/screen";
|
||||
import { ScreenNode, TableNode, ScreenNodeData, TableNodeData } from "./ScreenNode";
|
||||
import {
|
||||
getFieldJoins,
|
||||
getDataFlows,
|
||||
getTableRelations,
|
||||
getMultipleScreenLayoutSummary,
|
||||
ScreenLayoutSummary,
|
||||
} from "@/lib/api/screenGroup";
|
||||
import { getTableColumns, ColumnTypeInfo } from "@/lib/api/tableManagement";
|
||||
|
||||
// 노드 타입 등록
|
||||
const nodeTypes = {
|
||||
screenNode: ScreenNode,
|
||||
tableNode: TableNode,
|
||||
};
|
||||
|
||||
// 레이아웃 상수
|
||||
const SCREEN_Y = 50; // 화면 노드 Y 위치 (상단)
|
||||
const TABLE_Y = 400; // 테이블 노드 Y 위치 (하단)
|
||||
const NODE_WIDTH = 260; // 노드 너비 (조금 넓게)
|
||||
const NODE_GAP = 40; // 노드 간격
|
||||
|
||||
interface ScreenRelationFlowProps {
|
||||
screen: ScreenDefinition | null;
|
||||
}
|
||||
|
||||
// 노드 타입 (Record<string, unknown> 확장)
|
||||
type ScreenNodeType = Node<ScreenNodeData & Record<string, unknown>>;
|
||||
type TableNodeType = Node<TableNodeData & Record<string, unknown>>;
|
||||
type AllNodeType = ScreenNodeType | TableNodeType;
|
||||
|
||||
export function ScreenRelationFlow({ screen }: ScreenRelationFlowProps) {
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<AllNodeType>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [tableColumns, setTableColumns] = useState<Record<string, ColumnTypeInfo[]>>({});
|
||||
|
||||
// 테이블 컬럼 정보 로드
|
||||
const loadTableColumns = useCallback(
|
||||
async (tableName: string): Promise<ColumnTypeInfo[]> => {
|
||||
if (!tableName) return [];
|
||||
if (tableColumns[tableName]) return tableColumns[tableName];
|
||||
|
||||
try {
|
||||
const response = await getTableColumns(tableName);
|
||||
if (response.success && response.data && response.data.columns) {
|
||||
const columns = response.data.columns;
|
||||
setTableColumns((prev) => ({ ...prev, [tableName]: columns }));
|
||||
return columns;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`테이블 ${tableName} 컬럼 로드 실패:`, error);
|
||||
}
|
||||
return [];
|
||||
},
|
||||
[tableColumns]
|
||||
);
|
||||
|
||||
// 데이터 로드 및 노드/엣지 생성
|
||||
useEffect(() => {
|
||||
if (!screen) {
|
||||
setNodes([]);
|
||||
setEdges([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadRelations = async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// 관계 데이터 로드
|
||||
const [joinsRes, flowsRes, relationsRes] = await Promise.all([
|
||||
getFieldJoins(screen.screenId).catch(() => ({ success: false, data: [] })),
|
||||
getDataFlows().catch(() => ({ success: false, data: [] })),
|
||||
getTableRelations({ screen_id: screen.screenId }).catch(() => ({ success: false, data: [] })),
|
||||
]);
|
||||
|
||||
const joins = joinsRes.success ? joinsRes.data || [] : [];
|
||||
const flows = flowsRes.success ? flowsRes.data || [] : [];
|
||||
const relations = relationsRes.success ? relationsRes.data || [] : [];
|
||||
|
||||
// ========== 화면 목록 수집 ==========
|
||||
const screenList: ScreenDefinition[] = [screen];
|
||||
|
||||
// 데이터 흐름에서 연결된 화면들 추가
|
||||
flows.forEach((flow: any) => {
|
||||
if (flow.source_screen_id === screen.screenId && flow.target_screen_id) {
|
||||
const exists = screenList.some((s) => s.screenId === flow.target_screen_id);
|
||||
if (!exists) {
|
||||
screenList.push({
|
||||
screenId: flow.target_screen_id,
|
||||
screenName: flow.target_screen_name || `화면 ${flow.target_screen_id}`,
|
||||
screenCode: "",
|
||||
tableName: "",
|
||||
companyCode: screen.companyCode,
|
||||
isActive: "Y",
|
||||
createdDate: new Date(),
|
||||
updatedDate: new Date(),
|
||||
} as ScreenDefinition);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 화면 레이아웃 요약 정보 로드
|
||||
const screenIds = screenList.map((s) => s.screenId);
|
||||
let layoutSummaries: Record<number, ScreenLayoutSummary> = {};
|
||||
try {
|
||||
const layoutRes = await getMultipleScreenLayoutSummary(screenIds);
|
||||
if (layoutRes.success && layoutRes.data) {
|
||||
// API 응답이 Record 형태 (screenId -> summary)
|
||||
layoutSummaries = layoutRes.data as Record<number, ScreenLayoutSummary>;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("레이아웃 요약 로드 실패:", e);
|
||||
}
|
||||
|
||||
// ========== 상단: 화면 노드들 ==========
|
||||
const screenNodes: ScreenNodeType[] = [];
|
||||
const screenStartX = 50;
|
||||
|
||||
screenList.forEach((scr, idx) => {
|
||||
const isMain = scr.screenId === screen.screenId;
|
||||
const summary = layoutSummaries[scr.screenId];
|
||||
|
||||
screenNodes.push({
|
||||
id: `screen-${scr.screenId}`,
|
||||
type: "screenNode",
|
||||
position: { x: screenStartX + idx * (NODE_WIDTH + NODE_GAP), y: SCREEN_Y },
|
||||
data: {
|
||||
label: scr.screenName,
|
||||
subLabel: isMain ? "메인 화면" : "연결 화면",
|
||||
type: "screen",
|
||||
isMain,
|
||||
tableName: scr.tableName,
|
||||
layoutSummary: summary,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ========== 하단: 테이블 노드들 ==========
|
||||
const tableNodes: TableNodeType[] = [];
|
||||
const tableSet = new Set<string>();
|
||||
|
||||
// 메인 화면의 테이블 추가
|
||||
if (screen.tableName) {
|
||||
tableSet.add(screen.tableName);
|
||||
}
|
||||
|
||||
// 조인된 테이블들 추가
|
||||
joins.forEach((join: any) => {
|
||||
if (join.save_table) tableSet.add(join.save_table);
|
||||
if (join.join_table) tableSet.add(join.join_table);
|
||||
});
|
||||
|
||||
// 테이블 관계에서 추가
|
||||
relations.forEach((rel: any) => {
|
||||
if (rel.table_name) tableSet.add(rel.table_name);
|
||||
});
|
||||
|
||||
// 테이블 노드 배치 (하단, 가로 배치)
|
||||
const tableList = Array.from(tableSet);
|
||||
const tableStartX = 50;
|
||||
|
||||
for (let idx = 0; idx < tableList.length; idx++) {
|
||||
const tableName = tableList[idx];
|
||||
const isMainTable = tableName === screen.tableName;
|
||||
|
||||
// 컬럼 정보 로드
|
||||
let columns: ColumnTypeInfo[] = [];
|
||||
try {
|
||||
columns = await loadTableColumns(tableName);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// 컬럼 정보를 PK/FK 표시와 함께 변환
|
||||
const formattedColumns = columns.slice(0, 8).map((col) => ({
|
||||
name: col.displayName || col.columnName || "",
|
||||
type: col.dataType || "",
|
||||
isPrimaryKey: col.isPrimaryKey || col.columnName === "id",
|
||||
isForeignKey: !!col.referenceTable || (col.columnName?.includes("_id") && col.columnName !== "id"),
|
||||
}));
|
||||
|
||||
tableNodes.push({
|
||||
id: `table-${tableName}`,
|
||||
type: "tableNode",
|
||||
position: { x: tableStartX + idx * (NODE_WIDTH + NODE_GAP), y: TABLE_Y },
|
||||
data: {
|
||||
label: tableName,
|
||||
subLabel: isMainTable ? "메인 테이블" : "조인 테이블",
|
||||
isMain: isMainTable,
|
||||
columns: formattedColumns,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ========== 엣지: 연결선 생성 ==========
|
||||
const newEdges: Edge[] = [];
|
||||
|
||||
// 메인 화면 → 메인 테이블 연결 (양방향 CRUD)
|
||||
if (screen.tableName) {
|
||||
newEdges.push({
|
||||
id: `edge-main`,
|
||||
source: `screen-${screen.screenId}`,
|
||||
target: `table-${screen.tableName}`,
|
||||
sourceHandle: "bottom",
|
||||
targetHandle: "top",
|
||||
type: "smoothstep",
|
||||
animated: true,
|
||||
style: { stroke: "#3b82f6", strokeWidth: 2 },
|
||||
});
|
||||
}
|
||||
|
||||
// 조인 관계 엣지 (테이블 간 - 1:N 관계 표시)
|
||||
joins.forEach((join: any, idx: number) => {
|
||||
if (join.save_table && join.join_table && join.save_table !== join.join_table) {
|
||||
newEdges.push({
|
||||
id: `edge-join-${idx}`,
|
||||
source: `table-${join.save_table}`,
|
||||
target: `table-${join.join_table}`,
|
||||
sourceHandle: "right",
|
||||
targetHandle: "left",
|
||||
type: "smoothstep",
|
||||
label: "1:N 관계",
|
||||
labelStyle: { fontSize: 10, fill: "#6366f1", fontWeight: 500 },
|
||||
labelBgStyle: { fill: "white", stroke: "#e2e8f0", strokeWidth: 1 },
|
||||
labelBgPadding: [4, 2] as [number, number],
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: "#6366f1" },
|
||||
style: { stroke: "#6366f1", strokeWidth: 1.5, strokeDasharray: "5,5" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 테이블 관계 엣지 (추가 관계)
|
||||
relations.forEach((rel: any, idx: number) => {
|
||||
if (rel.table_name && rel.table_name !== screen.tableName) {
|
||||
// 화면 → 연결 테이블
|
||||
const edgeExists = newEdges.some(
|
||||
(e) => e.source === `screen-${screen.screenId}` && e.target === `table-${rel.table_name}`
|
||||
);
|
||||
if (!edgeExists) {
|
||||
newEdges.push({
|
||||
id: `edge-rel-${idx}`,
|
||||
source: `screen-${screen.screenId}`,
|
||||
target: `table-${rel.table_name}`,
|
||||
sourceHandle: "bottom",
|
||||
targetHandle: "top",
|
||||
type: "smoothstep",
|
||||
label: rel.relation_type === "join" ? "조인" : rel.crud_operations || "",
|
||||
labelStyle: { fontSize: 9, fill: "#10b981" },
|
||||
labelBgStyle: { fill: "white", stroke: "#e2e8f0", strokeWidth: 1 },
|
||||
labelBgPadding: [3, 2] as [number, number],
|
||||
style: { stroke: "#10b981", strokeWidth: 1.5 },
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 데이터 흐름 엣지 (화면 간)
|
||||
flows
|
||||
.filter((flow: any) => flow.source_screen_id === screen.screenId)
|
||||
.forEach((flow: any, idx: number) => {
|
||||
if (flow.target_screen_id) {
|
||||
newEdges.push({
|
||||
id: `edge-flow-${idx}`,
|
||||
source: `screen-${screen.screenId}`,
|
||||
target: `screen-${flow.target_screen_id}`,
|
||||
sourceHandle: "right",
|
||||
targetHandle: "left",
|
||||
type: "smoothstep",
|
||||
animated: true,
|
||||
label: flow.flow_label || flow.flow_type || "이동",
|
||||
labelStyle: { fontSize: 10, fill: "#8b5cf6", fontWeight: 500 },
|
||||
labelBgStyle: { fill: "white", stroke: "#e2e8f0", strokeWidth: 1 },
|
||||
labelBgPadding: [4, 2] as [number, number],
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: "#8b5cf6" },
|
||||
style: { stroke: "#8b5cf6", strokeWidth: 2 },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 최종 노드 배열 합치기
|
||||
const allNodes: AllNodeType[] = [...screenNodes, ...tableNodes];
|
||||
|
||||
// 테이블이 없으면 안내 노드 추가
|
||||
if (tableNodes.length === 0) {
|
||||
allNodes.push({
|
||||
id: "hint-table",
|
||||
type: "tableNode",
|
||||
position: { x: 50, y: TABLE_Y },
|
||||
data: {
|
||||
label: "연결된 테이블 없음",
|
||||
subLabel: "화면에 테이블을 설정하세요",
|
||||
isMain: false,
|
||||
columns: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setNodes(allNodes);
|
||||
setEdges(newEdges);
|
||||
} catch (error) {
|
||||
console.error("관계 데이터 로드 실패:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadRelations();
|
||||
}, [screen, setNodes, setEdges, loadTableColumns]);
|
||||
|
||||
if (!screen) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<p className="text-sm">화면을 선택하면</p>
|
||||
<p className="text-sm">데이터 관계가 시각화됩니다</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-sm text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
minZoom={0.3}
|
||||
maxZoom={1.5}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#e2e8f0" />
|
||||
<Controls position="bottom-right" />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
296
frontend/components/screen/ScreenRelationView.tsx
Normal file
296
frontend/components/screen/ScreenRelationView.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { ScreenDefinition } from "@/types/screen";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Database,
|
||||
Monitor,
|
||||
ArrowRight,
|
||||
Link2,
|
||||
Table,
|
||||
Columns,
|
||||
ExternalLink,
|
||||
Layers,
|
||||
GitBranch
|
||||
} from "lucide-react";
|
||||
import { getFieldJoins, getDataFlows, getTableRelations, FieldJoin, DataFlow, TableRelation } from "@/lib/api/screenGroup";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
|
||||
interface ScreenRelationViewProps {
|
||||
screen: ScreenDefinition | null;
|
||||
}
|
||||
|
||||
export function ScreenRelationView({ screen }: ScreenRelationViewProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fieldJoins, setFieldJoins] = useState<FieldJoin[]>([]);
|
||||
const [dataFlows, setDataFlows] = useState<DataFlow[]>([]);
|
||||
const [tableRelations, setTableRelations] = useState<TableRelation[]>([]);
|
||||
const [layoutInfo, setLayoutInfo] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadRelations = async () => {
|
||||
if (!screen) {
|
||||
setFieldJoins([]);
|
||||
setDataFlows([]);
|
||||
setTableRelations([]);
|
||||
setLayoutInfo(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 병렬로 데이터 로드
|
||||
const [joinsRes, flowsRes, relationsRes, layoutRes] = await Promise.all([
|
||||
getFieldJoins(screen.screenId),
|
||||
getDataFlows(screen.screenId),
|
||||
getTableRelations(screen.screenId),
|
||||
screenApi.getLayout(screen.screenId).catch(() => null),
|
||||
]);
|
||||
|
||||
if (joinsRes.success && joinsRes.data) {
|
||||
setFieldJoins(joinsRes.data);
|
||||
}
|
||||
if (flowsRes.success && flowsRes.data) {
|
||||
setDataFlows(flowsRes.data);
|
||||
}
|
||||
if (relationsRes.success && relationsRes.data) {
|
||||
setTableRelations(relationsRes.data);
|
||||
}
|
||||
if (layoutRes) {
|
||||
setLayoutInfo(layoutRes);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("관계 정보 로드 실패:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadRelations();
|
||||
}, [screen?.screenId]);
|
||||
|
||||
if (!screen) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center py-12">
|
||||
<Layers className="h-16 w-16 text-muted-foreground/30 mb-4" />
|
||||
<h3 className="text-lg font-medium text-muted-foreground mb-2">화면을 선택하세요</h3>
|
||||
<p className="text-sm text-muted-foreground/70">
|
||||
왼쪽 트리에서 화면을 선택하면 데이터 관계가 표시됩니다
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="text-sm text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 컴포넌트에서 사용하는 테이블 분석
|
||||
const getUsedTables = () => {
|
||||
const tables = new Set<string>();
|
||||
if (screen.tableName) {
|
||||
tables.add(screen.tableName);
|
||||
}
|
||||
if (layoutInfo?.components) {
|
||||
layoutInfo.components.forEach((comp: any) => {
|
||||
if (comp.properties?.tableName) {
|
||||
tables.add(comp.properties.tableName);
|
||||
}
|
||||
if (comp.properties?.dataSource?.tableName) {
|
||||
tables.add(comp.properties.dataSource.tableName);
|
||||
}
|
||||
});
|
||||
}
|
||||
return Array.from(tables);
|
||||
};
|
||||
|
||||
const usedTables = getUsedTables();
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4 overflow-auto h-full">
|
||||
{/* 화면 기본 정보 */}
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex items-center justify-center w-12 h-12 rounded-lg bg-blue-500/10">
|
||||
<Monitor className="h-6 w-6 text-blue-500" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-lg truncate">{screen.screenName}</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="outline">{screen.screenCode}</Badge>
|
||||
<Badge variant="secondary">{screen.screenType}</Badge>
|
||||
</div>
|
||||
{screen.description && (
|
||||
<p className="text-sm text-muted-foreground mt-2 line-clamp-2">
|
||||
{screen.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 연결된 테이블 */}
|
||||
<Card>
|
||||
<CardHeader className="py-3 px-4">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Database className="h-4 w-4 text-green-500" />
|
||||
연결된 테이블
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4 pt-0">
|
||||
{usedTables.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{usedTables.map((tableName, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-2 p-2 rounded-md bg-muted/50"
|
||||
>
|
||||
<Table className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-mono">{tableName}</span>
|
||||
{tableName === screen.tableName && (
|
||||
<Badge variant="default" className="text-xs ml-auto">
|
||||
메인
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">연결된 테이블이 없습니다</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 필드 조인 관계 */}
|
||||
<Card>
|
||||
<CardHeader className="py-3 px-4">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Link2 className="h-4 w-4 text-purple-500" />
|
||||
필드 조인 관계
|
||||
{fieldJoins.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-auto">{fieldJoins.length}</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4 pt-0">
|
||||
{fieldJoins.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{fieldJoins.map((join) => (
|
||||
<div
|
||||
key={join.id}
|
||||
className="flex items-center gap-2 p-2 rounded-md bg-muted/50 text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-mono text-xs">{join.sourceTable}</span>
|
||||
<span className="text-muted-foreground">.</span>
|
||||
<span className="font-mono text-xs text-blue-600">{join.sourceColumn}</span>
|
||||
</div>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-mono text-xs">{join.targetTable}</span>
|
||||
<span className="text-muted-foreground">.</span>
|
||||
<span className="font-mono text-xs text-green-600">{join.targetColumn}</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="ml-auto text-xs">
|
||||
{join.joinType}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">설정된 조인이 없습니다</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 데이터 흐름 */}
|
||||
<Card>
|
||||
<CardHeader className="py-3 px-4">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4 text-orange-500" />
|
||||
데이터 흐름
|
||||
{dataFlows.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-auto">{dataFlows.length}</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4 pt-0">
|
||||
{dataFlows.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{dataFlows.map((flow) => (
|
||||
<div
|
||||
key={flow.id}
|
||||
className="flex items-center gap-2 p-2 rounded-md bg-muted/50 text-sm"
|
||||
>
|
||||
<Monitor className="h-4 w-4 text-blue-500" />
|
||||
<span className="truncate">{flow.flowName || "이름 없음"}</span>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||
<Monitor className="h-4 w-4 text-green-500" />
|
||||
<span className="text-muted-foreground truncate">
|
||||
화면 #{flow.targetScreenId}
|
||||
</span>
|
||||
<Badge variant="outline" className="ml-auto text-xs">
|
||||
{flow.flowType}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">설정된 데이터 흐름이 없습니다</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 테이블 관계 */}
|
||||
<Card>
|
||||
<CardHeader className="py-3 px-4">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Columns className="h-4 w-4 text-cyan-500" />
|
||||
테이블 관계
|
||||
{tableRelations.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-auto">{tableRelations.length}</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4 pt-0">
|
||||
{tableRelations.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{tableRelations.map((relation) => (
|
||||
<div
|
||||
key={relation.id}
|
||||
className="flex items-center gap-2 p-2 rounded-md bg-muted/50 text-sm"
|
||||
>
|
||||
<Table className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-mono text-xs">{relation.parentTable}</span>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="font-mono text-xs">{relation.childTable}</span>
|
||||
<Badge variant="outline" className="ml-auto text-xs">
|
||||
{relation.relationType}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">설정된 테이블 관계가 없습니다</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 빠른 작업 */}
|
||||
<div className="pt-2 border-t">
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
더블클릭하면 화면 디자이너로 이동합니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
457
frontend/components/screen/panels/DataFlowPanel.tsx
Normal file
457
frontend/components/screen/panels/DataFlowPanel.tsx
Normal file
@@ -0,0 +1,457 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, ArrowRight, Trash2, Pencil, GitBranch, RefreshCw } from "lucide-react";
|
||||
import {
|
||||
getDataFlows,
|
||||
createDataFlow,
|
||||
updateDataFlow,
|
||||
deleteDataFlow,
|
||||
DataFlow,
|
||||
} from "@/lib/api/screenGroup";
|
||||
|
||||
interface DataFlowPanelProps {
|
||||
groupId?: number;
|
||||
screenId?: number;
|
||||
screens?: Array<{ screen_id: number; screen_name: string }>;
|
||||
}
|
||||
|
||||
export default function DataFlowPanel({ groupId, screenId, screens = [] }: DataFlowPanelProps) {
|
||||
// 상태 관리
|
||||
const [dataFlows, setDataFlows] = useState<DataFlow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedFlow, setSelectedFlow] = useState<DataFlow | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
source_screen_id: 0,
|
||||
source_action: "",
|
||||
target_screen_id: 0,
|
||||
target_action: "",
|
||||
data_mapping: "",
|
||||
flow_type: "unidirectional",
|
||||
flow_label: "",
|
||||
condition_expression: "",
|
||||
is_active: "Y",
|
||||
});
|
||||
|
||||
// 데이터 로드
|
||||
const loadDataFlows = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getDataFlows(groupId);
|
||||
if (response.success && response.data) {
|
||||
setDataFlows(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("데이터 흐름 로드 실패:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [groupId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadDataFlows();
|
||||
}, [loadDataFlows]);
|
||||
|
||||
// 모달 열기
|
||||
const openModal = (flow?: DataFlow) => {
|
||||
if (flow) {
|
||||
setSelectedFlow(flow);
|
||||
setFormData({
|
||||
source_screen_id: flow.source_screen_id,
|
||||
source_action: flow.source_action || "",
|
||||
target_screen_id: flow.target_screen_id,
|
||||
target_action: flow.target_action || "",
|
||||
data_mapping: flow.data_mapping ? JSON.stringify(flow.data_mapping, null, 2) : "",
|
||||
flow_type: flow.flow_type,
|
||||
flow_label: flow.flow_label || "",
|
||||
condition_expression: flow.condition_expression || "",
|
||||
is_active: flow.is_active,
|
||||
});
|
||||
} else {
|
||||
setSelectedFlow(null);
|
||||
setFormData({
|
||||
source_screen_id: screenId || 0,
|
||||
source_action: "",
|
||||
target_screen_id: 0,
|
||||
target_action: "",
|
||||
data_mapping: "",
|
||||
flow_type: "unidirectional",
|
||||
flow_label: "",
|
||||
condition_expression: "",
|
||||
is_active: "Y",
|
||||
});
|
||||
}
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
if (!formData.source_screen_id || !formData.target_screen_id) {
|
||||
toast.error("소스 화면과 타겟 화면을 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let dataMappingJson = null;
|
||||
if (formData.data_mapping) {
|
||||
try {
|
||||
dataMappingJson = JSON.parse(formData.data_mapping);
|
||||
} catch {
|
||||
toast.error("데이터 매핑 JSON 형식이 올바르지 않습니다.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
group_id: groupId,
|
||||
source_screen_id: formData.source_screen_id,
|
||||
source_action: formData.source_action || null,
|
||||
target_screen_id: formData.target_screen_id,
|
||||
target_action: formData.target_action || null,
|
||||
data_mapping: dataMappingJson,
|
||||
flow_type: formData.flow_type,
|
||||
flow_label: formData.flow_label || null,
|
||||
condition_expression: formData.condition_expression || null,
|
||||
is_active: formData.is_active,
|
||||
};
|
||||
|
||||
let response;
|
||||
if (selectedFlow) {
|
||||
response = await updateDataFlow(selectedFlow.id, payload);
|
||||
} else {
|
||||
response = await createDataFlow(payload);
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
toast.success(selectedFlow ? "데이터 흐름이 수정되었습니다." : "데이터 흐름이 추가되었습니다.");
|
||||
setIsModalOpen(false);
|
||||
loadDataFlows();
|
||||
} else {
|
||||
toast.error(response.message || "저장에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("저장 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm("이 데이터 흐름을 삭제하시겠습니까?")) return;
|
||||
|
||||
try {
|
||||
const response = await deleteDataFlow(id);
|
||||
if (response.success) {
|
||||
toast.success("데이터 흐름이 삭제되었습니다.");
|
||||
loadDataFlows();
|
||||
} else {
|
||||
toast.error(response.message || "삭제에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("삭제 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 액션 옵션
|
||||
const sourceActions = [
|
||||
{ value: "click", label: "클릭" },
|
||||
{ value: "submit", label: "제출" },
|
||||
{ value: "select", label: "선택" },
|
||||
{ value: "change", label: "변경" },
|
||||
{ value: "doubleClick", label: "더블클릭" },
|
||||
];
|
||||
|
||||
const targetActions = [
|
||||
{ value: "open", label: "열기" },
|
||||
{ value: "load", label: "로드" },
|
||||
{ value: "refresh", label: "새로고침" },
|
||||
{ value: "save", label: "저장" },
|
||||
{ value: "filter", label: "필터" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-semibold">데이터 흐름</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={loadDataFlows} className="h-8 w-8 p-0">
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => openModal()} className="h-8 gap-1 text-xs">
|
||||
<Plus className="h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 설명 */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
화면 간 데이터 전달 흐름을 정의합니다. (예: 목록 화면에서 행 클릭 시 상세 화면 열기)
|
||||
</p>
|
||||
|
||||
{/* 흐름 목록 */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : dataFlows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-8">
|
||||
<GitBranch className="h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="mt-2 text-xs text-muted-foreground">정의된 데이터 흐름이 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{dataFlows.map((flow) => (
|
||||
<div
|
||||
key={flow.id}
|
||||
className="flex items-center justify-between rounded-lg border bg-card p-3 text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{/* 소스 화면 */}
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium truncate max-w-[100px]">
|
||||
{flow.source_screen_name || `화면 ${flow.source_screen_id}`}
|
||||
</span>
|
||||
{flow.source_action && (
|
||||
<span className="text-muted-foreground">{flow.source_action}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 화살표 */}
|
||||
<div className="flex items-center gap-1 text-primary">
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
{flow.flow_type === "bidirectional" && (
|
||||
<ArrowRight className="h-4 w-4 rotate-180" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 타겟 화면 */}
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium truncate max-w-[100px]">
|
||||
{flow.target_screen_name || `화면 ${flow.target_screen_id}`}
|
||||
</span>
|
||||
{flow.target_action && (
|
||||
<span className="text-muted-foreground">{flow.target_action}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 라벨 */}
|
||||
{flow.flow_label && (
|
||||
<span className="rounded bg-muted px-2 py-0.5 text-muted-foreground">
|
||||
{flow.flow_label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => openModal(flow)}>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-destructive hover:text-destructive"
|
||||
onClick={() => handleDelete(flow.id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 추가/수정 모달 */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
{selectedFlow ? "데이터 흐름 수정" : "데이터 흐름 추가"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
화면 간 데이터 전달 흐름을 설정합니다
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 소스 화면 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">소스 화면 *</Label>
|
||||
<Select
|
||||
value={formData.source_screen_id.toString()}
|
||||
onValueChange={(value) => setFormData({ ...formData, source_screen_id: parseInt(value) })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="화면 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{screens.map((screen) => (
|
||||
<SelectItem key={screen.screen_id} value={screen.screen_id.toString()}>
|
||||
{screen.screen_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">소스 액션</Label>
|
||||
<Select
|
||||
value={formData.source_action}
|
||||
onValueChange={(value) => setFormData({ ...formData, source_action: value })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="액션 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceActions.map((action) => (
|
||||
<SelectItem key={action.value} value={action.value}>
|
||||
{action.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 타겟 화면 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">타겟 화면 *</Label>
|
||||
<Select
|
||||
value={formData.target_screen_id.toString()}
|
||||
onValueChange={(value) => setFormData({ ...formData, target_screen_id: parseInt(value) })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="화면 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{screens.map((screen) => (
|
||||
<SelectItem key={screen.screen_id} value={screen.screen_id.toString()}>
|
||||
{screen.screen_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">타겟 액션</Label>
|
||||
<Select
|
||||
value={formData.target_action}
|
||||
onValueChange={(value) => setFormData({ ...formData, target_action: value })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="액션 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetActions.map((action) => (
|
||||
<SelectItem key={action.value} value={action.value}>
|
||||
{action.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 흐름 설정 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">흐름 타입</Label>
|
||||
<Select
|
||||
value={formData.flow_type}
|
||||
onValueChange={(value) => setFormData({ ...formData, flow_type: value })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="unidirectional">단방향</SelectItem>
|
||||
<SelectItem value="bidirectional">양방향</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">흐름 라벨</Label>
|
||||
<Input
|
||||
value={formData.flow_label}
|
||||
onChange={(e) => setFormData({ ...formData, flow_label: e.target.value })}
|
||||
placeholder="예: 상세 보기"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 데이터 매핑 */}
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">데이터 매핑 (JSON)</Label>
|
||||
<Textarea
|
||||
value={formData.data_mapping}
|
||||
onChange={(e) => setFormData({ ...formData, data_mapping: e.target.value })}
|
||||
placeholder='{"source_field": "target_field"}'
|
||||
className="min-h-[80px] font-mono text-xs sm:text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||
소스 화면의 필드를 타겟 화면의 필드로 매핑합니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 조건식 */}
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">실행 조건 (선택)</Label>
|
||||
<Input
|
||||
value={formData.condition_expression}
|
||||
onChange={(e) => setFormData({ ...formData, condition_expression: e.target.value })}
|
||||
placeholder="예: data.status === 'active'"
|
||||
className="h-8 font-mono text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{selectedFlow ? "수정" : "추가"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
409
frontend/components/screen/panels/FieldJoinPanel.tsx
Normal file
409
frontend/components/screen/panels/FieldJoinPanel.tsx
Normal file
@@ -0,0 +1,409 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, Pencil, Trash2, Link2, Database } from "lucide-react";
|
||||
import {
|
||||
getFieldJoins,
|
||||
createFieldJoin,
|
||||
updateFieldJoin,
|
||||
deleteFieldJoin,
|
||||
FieldJoin,
|
||||
} from "@/lib/api/screenGroup";
|
||||
|
||||
interface FieldJoinPanelProps {
|
||||
screenId: number;
|
||||
componentId?: string;
|
||||
layoutId?: number;
|
||||
}
|
||||
|
||||
export default function FieldJoinPanel({ screenId, componentId, layoutId }: FieldJoinPanelProps) {
|
||||
// 상태 관리
|
||||
const [fieldJoins, setFieldJoins] = useState<FieldJoin[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedJoin, setSelectedJoin] = useState<FieldJoin | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
field_name: "",
|
||||
save_table: "",
|
||||
save_column: "",
|
||||
join_table: "",
|
||||
join_column: "",
|
||||
display_column: "",
|
||||
join_type: "LEFT",
|
||||
filter_condition: "",
|
||||
sort_column: "",
|
||||
sort_direction: "ASC",
|
||||
is_active: "Y",
|
||||
});
|
||||
|
||||
// 데이터 로드
|
||||
const loadFieldJoins = useCallback(async () => {
|
||||
if (!screenId) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getFieldJoins(screenId);
|
||||
if (response.success && response.data) {
|
||||
// 현재 컴포넌트에 해당하는 조인만 필터링
|
||||
const filtered = componentId
|
||||
? response.data.filter(join => join.component_id === componentId)
|
||||
: response.data;
|
||||
setFieldJoins(filtered);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("필드 조인 로드 실패:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [screenId, componentId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadFieldJoins();
|
||||
}, [loadFieldJoins]);
|
||||
|
||||
// 모달 열기
|
||||
const openModal = (join?: FieldJoin) => {
|
||||
if (join) {
|
||||
setSelectedJoin(join);
|
||||
setFormData({
|
||||
field_name: join.field_name || "",
|
||||
save_table: join.save_table,
|
||||
save_column: join.save_column,
|
||||
join_table: join.join_table,
|
||||
join_column: join.join_column,
|
||||
display_column: join.display_column,
|
||||
join_type: join.join_type,
|
||||
filter_condition: join.filter_condition || "",
|
||||
sort_column: join.sort_column || "",
|
||||
sort_direction: join.sort_direction || "ASC",
|
||||
is_active: join.is_active,
|
||||
});
|
||||
} else {
|
||||
setSelectedJoin(null);
|
||||
setFormData({
|
||||
field_name: "",
|
||||
save_table: "",
|
||||
save_column: "",
|
||||
join_table: "",
|
||||
join_column: "",
|
||||
display_column: "",
|
||||
join_type: "LEFT",
|
||||
filter_condition: "",
|
||||
sort_column: "",
|
||||
sort_direction: "ASC",
|
||||
is_active: "Y",
|
||||
});
|
||||
}
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
if (!formData.save_table || !formData.save_column || !formData.join_table || !formData.join_column || !formData.display_column) {
|
||||
toast.error("필수 필드를 모두 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
screen_id: screenId,
|
||||
layout_id: layoutId,
|
||||
component_id: componentId,
|
||||
...formData,
|
||||
};
|
||||
|
||||
let response;
|
||||
if (selectedJoin) {
|
||||
response = await updateFieldJoin(selectedJoin.id, payload);
|
||||
} else {
|
||||
response = await createFieldJoin(payload);
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
toast.success(selectedJoin ? "조인 설정이 수정되었습니다." : "조인 설정이 추가되었습니다.");
|
||||
setIsModalOpen(false);
|
||||
loadFieldJoins();
|
||||
} else {
|
||||
toast.error(response.message || "저장에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("저장 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm("이 조인 설정을 삭제하시겠습니까?")) return;
|
||||
|
||||
try {
|
||||
const response = await deleteFieldJoin(id);
|
||||
if (response.success) {
|
||||
toast.success("조인 설정이 삭제되었습니다.");
|
||||
loadFieldJoins();
|
||||
} else {
|
||||
toast.error(response.message || "삭제에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("삭제 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-semibold">필드 조인 설정</h3>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => openModal()} className="h-8 gap-1 text-xs">
|
||||
<Plus className="h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 설명 */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
이 필드가 다른 테이블의 값을 참조하여 표시할 때 조인 설정을 추가하세요.
|
||||
</p>
|
||||
|
||||
{/* 조인 목록 */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : fieldJoins.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-8">
|
||||
<Database className="h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="mt-2 text-xs text-muted-foreground">설정된 조인이 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead className="h-8 text-xs">저장 테이블.컬럼</TableHead>
|
||||
<TableHead className="h-8 text-xs">조인 테이블.컬럼</TableHead>
|
||||
<TableHead className="h-8 text-xs">표시 컬럼</TableHead>
|
||||
<TableHead className="h-8 w-[60px] text-xs">관리</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{fieldJoins.map((join) => (
|
||||
<TableRow key={join.id} className="text-xs">
|
||||
<TableCell className="py-2">
|
||||
<span className="font-mono">{join.save_table}.{join.save_column}</span>
|
||||
</TableCell>
|
||||
<TableCell className="py-2">
|
||||
<span className="font-mono">{join.join_table}.{join.join_column}</span>
|
||||
</TableCell>
|
||||
<TableCell className="py-2">
|
||||
<span className="font-mono">{join.display_column}</span>
|
||||
</TableCell>
|
||||
<TableCell className="py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => openModal(join)}>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-destructive hover:text-destructive"
|
||||
onClick={() => handleDelete(join.id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 추가/수정 모달 */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
{selectedJoin ? "조인 설정 수정" : "조인 설정 추가"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
필드가 참조할 테이블과 컬럼을 설정합니다
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 필드명 */}
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">필드명</Label>
|
||||
<Input
|
||||
value={formData.field_name}
|
||||
onChange={(e) => setFormData({ ...formData, field_name: e.target.value })}
|
||||
placeholder="화면에 표시될 필드명"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 저장 테이블/컬럼 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">저장 테이블 *</Label>
|
||||
<Input
|
||||
value={formData.save_table}
|
||||
onChange={(e) => setFormData({ ...formData, save_table: e.target.value })}
|
||||
placeholder="예: work_orders"
|
||||
className="h-8 font-mono text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">저장 컬럼 *</Label>
|
||||
<Input
|
||||
value={formData.save_column}
|
||||
onChange={(e) => setFormData({ ...formData, save_column: e.target.value })}
|
||||
placeholder="예: item_code"
|
||||
className="h-8 font-mono text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 조인 테이블/컬럼 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">조인 테이블 *</Label>
|
||||
<Input
|
||||
value={formData.join_table}
|
||||
onChange={(e) => setFormData({ ...formData, join_table: e.target.value })}
|
||||
placeholder="예: item_mng"
|
||||
className="h-8 font-mono text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">조인 컬럼 *</Label>
|
||||
<Input
|
||||
value={formData.join_column}
|
||||
onChange={(e) => setFormData({ ...formData, join_column: e.target.value })}
|
||||
placeholder="예: id"
|
||||
className="h-8 font-mono text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 표시 컬럼 */}
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">표시 컬럼 *</Label>
|
||||
<Input
|
||||
value={formData.display_column}
|
||||
onChange={(e) => setFormData({ ...formData, display_column: e.target.value })}
|
||||
placeholder="예: item_name (화면에 표시될 컬럼)"
|
||||
className="h-8 font-mono text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 조인 타입/정렬 */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">조인 타입</Label>
|
||||
<Select
|
||||
value={formData.join_type}
|
||||
onValueChange={(value) => setFormData({ ...formData, join_type: value })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="LEFT">LEFT JOIN</SelectItem>
|
||||
<SelectItem value="INNER">INNER JOIN</SelectItem>
|
||||
<SelectItem value="RIGHT">RIGHT JOIN</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">정렬 컬럼</Label>
|
||||
<Input
|
||||
value={formData.sort_column}
|
||||
onChange={(e) => setFormData({ ...formData, sort_column: e.target.value })}
|
||||
placeholder="예: name"
|
||||
className="h-8 font-mono text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">정렬 방향</Label>
|
||||
<Select
|
||||
value={formData.sort_direction}
|
||||
onValueChange={(value) => setFormData({ ...formData, sort_direction: value })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ASC">오름차순</SelectItem>
|
||||
<SelectItem value="DESC">내림차순</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필터 조건 */}
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">필터 조건 (선택)</Label>
|
||||
<Textarea
|
||||
value={formData.filter_condition}
|
||||
onChange={(e) => setFormData({ ...formData, filter_condition: e.target.value })}
|
||||
placeholder="예: is_active = 'Y'"
|
||||
className="min-h-[60px] font-mono text-xs sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{selectedJoin ? "수정" : "추가"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user