feat: 화면 그룹 관리 기능 추가

- 화면 그룹 CRUD API 및 라우트 구현
- 화면 그룹 목록 조회, 생성, 수정, 삭제 기능 추가
- 화면-그룹 연결 및 데이터 흐름 관리 기능 포함
- 프론트엔드에서 화면 그룹 필터링 및 시각화 기능
This commit is contained in:
DDD1542
2026-01-05 10:05:31 +09:00
parent ad76bfe3b0
commit 7caf2dea94
12 changed files with 3853 additions and 78 deletions

View 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>
);
}

View File

@@ -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">

View 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>
);
};

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}