Merge branch 'gbpark-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node
This commit is contained in:
commit
d3e62912e7
|
|
@ -2058,6 +2058,119 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
|
|||
});
|
||||
});
|
||||
|
||||
// 6. v2-repeater 컴포넌트에서 selectedTable/foreignKey 추출
|
||||
const v2RepeaterQuery = `
|
||||
SELECT DISTINCT
|
||||
sd.screen_id,
|
||||
sd.screen_name,
|
||||
sd.table_name as main_table,
|
||||
comp->'overrides'->>'type' as component_type,
|
||||
comp->'overrides'->>'selectedTable' as sub_table,
|
||||
comp->'overrides'->>'foreignKey' as foreign_key,
|
||||
comp->'overrides'->>'parentTable' as parent_table
|
||||
FROM screen_definitions sd
|
||||
JOIN screen_layouts_v2 slv2 ON sd.screen_id = slv2.screen_id,
|
||||
jsonb_array_elements(slv2.layout_data->'components') as comp
|
||||
WHERE sd.screen_id = ANY($1)
|
||||
AND comp->'overrides'->>'type' = 'v2-repeater'
|
||||
AND comp->'overrides'->>'selectedTable' IS NOT NULL
|
||||
`;
|
||||
const v2RepeaterResult = await pool.query(v2RepeaterQuery, [screenIds]);
|
||||
v2RepeaterResult.rows.forEach((row: any) => {
|
||||
const screenId = row.screen_id;
|
||||
const mainTable = row.main_table;
|
||||
const subTable = row.sub_table;
|
||||
const foreignKey = row.foreign_key;
|
||||
if (!subTable || subTable === mainTable) return;
|
||||
if (!screenSubTables[screenId]) {
|
||||
screenSubTables[screenId] = {
|
||||
screenId,
|
||||
screenName: row.screen_name,
|
||||
mainTable: mainTable || '',
|
||||
subTables: [],
|
||||
};
|
||||
}
|
||||
const exists = screenSubTables[screenId].subTables.some(
|
||||
(st) => st.tableName === subTable
|
||||
);
|
||||
if (!exists) {
|
||||
screenSubTables[screenId].subTables.push({
|
||||
tableName: subTable,
|
||||
componentType: 'v2-repeater',
|
||||
relationType: 'rightPanelRelation',
|
||||
fieldMappings: foreignKey ? [{
|
||||
sourceField: 'id',
|
||||
targetField: foreignKey,
|
||||
sourceDisplayName: 'ID',
|
||||
targetDisplayName: foreignKey,
|
||||
}] : undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
logger.info("v2-repeater 서브 테이블 추출 완료", {
|
||||
screenIds,
|
||||
v2RepeaterCount: v2RepeaterResult.rows.length,
|
||||
});
|
||||
|
||||
// 7. rightPanel.components 내부의 componentConfig.detailTable 추출 (v2-bom-tree 등)
|
||||
const v2DetailTableQuery = `
|
||||
SELECT DISTINCT
|
||||
sd.screen_id,
|
||||
sd.screen_name,
|
||||
sd.table_name as main_table,
|
||||
inner_comp->>'type' as component_type,
|
||||
inner_comp->'componentConfig'->>'detailTable' as sub_table,
|
||||
inner_comp->'componentConfig'->>'foreignKey' as foreign_key
|
||||
FROM screen_definitions sd
|
||||
JOIN screen_layouts_v2 slv2 ON sd.screen_id = slv2.screen_id,
|
||||
jsonb_array_elements(slv2.layout_data->'components') as comp,
|
||||
jsonb_array_elements(
|
||||
COALESCE(
|
||||
comp->'overrides'->'rightPanel'->'components',
|
||||
comp->'overrides'->'leftPanel'->'components',
|
||||
'[]'::jsonb
|
||||
)
|
||||
) as inner_comp
|
||||
WHERE sd.screen_id = ANY($1)
|
||||
AND inner_comp->'componentConfig'->>'detailTable' IS NOT NULL
|
||||
`;
|
||||
const v2DetailTableResult = await pool.query(v2DetailTableQuery, [screenIds]);
|
||||
v2DetailTableResult.rows.forEach((row: any) => {
|
||||
const screenId = row.screen_id;
|
||||
const mainTable = row.main_table;
|
||||
const subTable = row.sub_table;
|
||||
const foreignKey = row.foreign_key;
|
||||
if (!subTable || subTable === mainTable) return;
|
||||
if (!screenSubTables[screenId]) {
|
||||
screenSubTables[screenId] = {
|
||||
screenId,
|
||||
screenName: row.screen_name,
|
||||
mainTable: mainTable || '',
|
||||
subTables: [],
|
||||
};
|
||||
}
|
||||
const exists = screenSubTables[screenId].subTables.some(
|
||||
(st) => st.tableName === subTable
|
||||
);
|
||||
if (!exists) {
|
||||
screenSubTables[screenId].subTables.push({
|
||||
tableName: subTable,
|
||||
componentType: row.component_type || 'v2-bom-tree',
|
||||
relationType: 'rightPanelRelation',
|
||||
fieldMappings: foreignKey ? [{
|
||||
sourceField: 'id',
|
||||
targetField: foreignKey,
|
||||
sourceDisplayName: 'ID',
|
||||
targetDisplayName: foreignKey,
|
||||
}] : undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
logger.info("v2-bom-tree/detailTable 서브 테이블 추출 완료", {
|
||||
screenIds,
|
||||
v2DetailTableCount: v2DetailTableResult.rows.length,
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 저장 테이블 정보 추출
|
||||
// ============================================================
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ services:
|
|||
environment:
|
||||
- NODE_ENV=development
|
||||
- PORT=8080
|
||||
- DATABASE_URL=postgresql://postgres:vexplor0909!!@211.115.91.141:11134/vexplor
|
||||
- DATABASE_URL=postgresql://postgres:ph0909!!@39.117.244.52:11132/plm
|
||||
- JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024
|
||||
- JWT_EXPIRES_IN=24h
|
||||
- CORS_ORIGIN=http://localhost:9771
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ArrowLeft, Plus, RefreshCw, Search, LayoutGrid, LayoutList, TestTube2 } from "lucide-react";
|
||||
import ScreenList from "@/components/screen/ScreenList";
|
||||
import { Plus, RefreshCw, Search, X, LayoutGrid, LayoutList, TestTube2, Database, MoreHorizontal, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||
import ScreenDesigner from "@/components/screen/ScreenDesigner";
|
||||
import TemplateManager from "@/components/screen/TemplateManager";
|
||||
import { ScreenGroupTreeView } from "@/components/screen/ScreenGroupTreeView";
|
||||
|
|
@ -15,11 +14,19 @@ import { ScrollToTop } from "@/components/common/ScrollToTop";
|
|||
import { ScreenDefinition } from "@/types/screen";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import CreateScreenModal from "@/components/screen/CreateScreenModal";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@/components/ui/sheet";
|
||||
|
||||
// 단계별 진행을 위한 타입 정의
|
||||
type Step = "list" | "design" | "template" | "v2-test";
|
||||
type ViewMode = "tree" | "table";
|
||||
type ViewMode = "flow" | "card";
|
||||
|
||||
export default function ScreenManagementPage() {
|
||||
const searchParams = useSearchParams();
|
||||
|
|
@ -28,11 +35,15 @@ export default function ScreenManagementPage() {
|
|||
const [selectedGroup, setSelectedGroup] = useState<{ id: number; name: string; company_code?: string } | null>(null);
|
||||
const [focusedScreenIdInGroup, setFocusedScreenIdInGroup] = useState<number | null>(null);
|
||||
const [stepHistory, setStepHistory] = useState<Step[]>(["list"]);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("tree");
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("flow");
|
||||
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
|
||||
const tableCount = useMemo(() => new Set(screens.map((s) => s.tableName).filter(Boolean)).size, [screens]);
|
||||
|
||||
// 화면 목록 로드
|
||||
const loadScreens = useCallback(async () => {
|
||||
|
|
@ -102,6 +113,7 @@ export default function ScreenManagementPage() {
|
|||
// 화면 선택 핸들러 (개별 화면 선택 시 그룹 선택 해제)
|
||||
const handleScreenSelect = (screen: ScreenDefinition) => {
|
||||
setSelectedScreen(screen);
|
||||
setIsDetailOpen(true);
|
||||
setSelectedGroup(null); // 그룹 선택 해제
|
||||
};
|
||||
|
||||
|
|
@ -159,96 +171,126 @@ export default function ScreenManagementPage() {
|
|||
return (
|
||||
<div className="flex h-screen flex-col bg-background overflow-hidden">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="flex-shrink-0 border-b bg-background px-6 py-4">
|
||||
<div className="flex-shrink-0 border-b border-border/50 bg-background/95 backdrop-blur-md px-6 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">화면 관리</h1>
|
||||
<p className="text-sm text-muted-foreground">화면을 그룹별로 관리하고 데이터 관계를 확인합니다</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl font-bold tracking-tight">화면 관리</h1>
|
||||
<Badge variant="secondary" className="text-xs">{screens.length}개 화면</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* V2 컴포넌트 테스트 버튼 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => goToNextStep("v2-test")}
|
||||
className="gap-2"
|
||||
>
|
||||
<TestTube2 className="h-4 w-4" />
|
||||
V2 테스트
|
||||
</Button>
|
||||
{/* 뷰 모드 전환 */}
|
||||
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as ViewMode)}>
|
||||
<TabsList className="h-9">
|
||||
<TabsTrigger value="tree" className="gap-1.5 px-3">
|
||||
<TabsList className="h-9 bg-muted/50 border border-border/50">
|
||||
<TabsTrigger value="flow" className="gap-1.5 px-3 text-xs">
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
트리
|
||||
관계도
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="table" className="gap-1.5 px-3">
|
||||
<TabsTrigger value="card" className="gap-1.5 px-3 text-xs">
|
||||
<LayoutList className="h-4 w-4" />
|
||||
테이블
|
||||
카드
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Button variant="outline" size="icon" onClick={loadScreens}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button onClick={() => setIsCreateOpen(true)} className="gap-2">
|
||||
<Button onClick={() => setIsCreateOpen(true)} className="gap-2 shadow-sm hover:shadow-md transition-shadow">
|
||||
<Plus className="h-4 w-4" />
|
||||
새 화면
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => goToNextStep("v2-test")}>
|
||||
<TestTube2 className="h-4 w-4 mr-2" />
|
||||
V2 테스트
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 콘텐츠 */}
|
||||
{viewMode === "tree" ? (
|
||||
{viewMode === "flow" ? (
|
||||
<div className="flex-1 overflow-hidden flex">
|
||||
{/* 왼쪽: 트리 구조 */}
|
||||
<div className="w-[350px] min-w-[280px] max-w-[450px] flex flex-col border-r bg-background">
|
||||
{/* 검색 */}
|
||||
<div className="flex-shrink-0 p-3 border-b">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="화면 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
{/* 왼쪽: 트리 구조 (접기/펼기 지원) */}
|
||||
<div className={`flex flex-col border-r border-border/50 bg-background/80 backdrop-blur-sm transition-all duration-300 ease-in-out ${
|
||||
sidebarCollapsed ? "w-[48px] min-w-[48px]" : "w-[320px] min-w-[280px] max-w-[400px]"
|
||||
}`}>
|
||||
{/* 사이드바 헤더 */}
|
||||
<div className="flex-shrink-0 flex items-center justify-between p-2 border-b border-border/50">
|
||||
{!sidebarCollapsed && <span className="text-xs font-medium text-muted-foreground px-1">탐색</span>}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`h-7 w-7 ${sidebarCollapsed ? "mx-auto" : "ml-auto"}`}
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
>
|
||||
{sidebarCollapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
{/* 사이드바 접힘 시 아이콘 컬럼 */}
|
||||
{sidebarCollapsed && (
|
||||
<div className="flex-1 flex flex-col items-center gap-2 py-3">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setSidebarCollapsed(false)}>
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
<div className="mt-auto pb-2">
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5">{screens.length}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 트리 뷰 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ScreenGroupTreeView
|
||||
screens={filteredScreens}
|
||||
selectedScreen={selectedScreen}
|
||||
onScreenSelect={handleScreenSelect}
|
||||
onScreenDesign={handleDesignScreen}
|
||||
searchTerm={searchTerm}
|
||||
onGroupSelect={(group) => {
|
||||
setSelectedGroup(group);
|
||||
setSelectedScreen(null); // 화면 선택 해제
|
||||
setFocusedScreenIdInGroup(null); // 포커스 초기화
|
||||
}}
|
||||
onScreenSelectInGroup={(group, screenId) => {
|
||||
// 그룹 내 화면 클릭 시
|
||||
const isNewGroup = selectedGroup?.id !== group.id;
|
||||
|
||||
if (isNewGroup) {
|
||||
// 새 그룹 진입: 포커싱 없이 시작 (첫 진입 시 망가지는 문제 방지)
|
||||
setSelectedGroup(group);
|
||||
setFocusedScreenIdInGroup(null);
|
||||
} else {
|
||||
// 같은 그룹 내에서 다른 화면 클릭: 포커싱 유지
|
||||
setFocusedScreenIdInGroup(screenId);
|
||||
}
|
||||
setSelectedScreen(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 사이드바 펼침 시 전체 UI */}
|
||||
{!sidebarCollapsed && (
|
||||
<>
|
||||
{/* 검색 */}
|
||||
<div className="flex-shrink-0 p-3 border-b border-border/50">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="화면 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9 h-9 rounded-xl bg-muted/30 border-border/50 focus:bg-background focus:ring-2 focus:ring-primary/30 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* 트리 뷰 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ScreenGroupTreeView
|
||||
screens={filteredScreens}
|
||||
selectedScreen={selectedScreen}
|
||||
onScreenSelect={handleScreenSelect}
|
||||
onScreenDesign={handleDesignScreen}
|
||||
searchTerm={searchTerm}
|
||||
onGroupSelect={(group) => {
|
||||
setSelectedGroup(group);
|
||||
setSelectedScreen(null);
|
||||
setFocusedScreenIdInGroup(null);
|
||||
}}
|
||||
onScreenSelectInGroup={(group, screenId) => {
|
||||
const isNewGroup = selectedGroup?.id !== group.id;
|
||||
if (isNewGroup) {
|
||||
setSelectedGroup(group);
|
||||
setFocusedScreenIdInGroup(null);
|
||||
} else {
|
||||
setFocusedScreenIdInGroup(screenId);
|
||||
}
|
||||
setSelectedScreen(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 관계 시각화 (React Flow) */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="flex-1 overflow-hidden bg-muted/10">
|
||||
<ScreenRelationFlow
|
||||
screen={selectedScreen}
|
||||
selectedGroup={selectedGroup}
|
||||
|
|
@ -257,21 +299,150 @@ export default function ScreenManagementPage() {
|
|||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 테이블 뷰 (기존 ScreenList 사용)
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<ScreenList
|
||||
onScreenSelect={handleScreenSelect}
|
||||
selectedScreen={selectedScreen}
|
||||
onDesignScreen={handleDesignScreen}
|
||||
/>
|
||||
<div className="flex-1 overflow-auto p-6 bg-muted/30 dark:bg-background">
|
||||
{/* 카드 뷰 상단: 검색 + 카운트 */}
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="화면 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9 h-9 rounded-xl bg-card dark:bg-card border-border/50 shadow-sm focus:bg-card focus:ring-2 focus:ring-primary/30 transition-colors"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchTerm("")}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label="검색어 지우기"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{filteredScreens.length}개 화면</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-3">
|
||||
{filteredScreens.map((screen) => {
|
||||
const screenType = (screen as { screenType?: string }).screenType || "form";
|
||||
const isSelected = selectedScreen?.screenId === screen.screenId;
|
||||
const isRecentlyModified = screen.updatedDate && (Date.now() - new Date(screen.updatedDate).getTime()) < 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const typeColorClass = screenType === "grid"
|
||||
? "from-primary to-primary/20"
|
||||
: screenType === "dashboard"
|
||||
? "from-warning to-warning/20"
|
||||
: "from-success to-success/20";
|
||||
|
||||
const glowClass = screenType === "grid"
|
||||
? "hover:shadow-[0_8px_24px_-4px_rgba(0,0,0,0.1),0_0_20px_hsl(var(--primary)/0.1)] dark:hover:shadow-[0_8px_24px_-4px_rgba(0,0,0,0.4),0_0_24px_hsl(var(--primary)/0.15)]"
|
||||
: screenType === "dashboard"
|
||||
? "hover:shadow-[0_8px_24px_-4px_rgba(0,0,0,0.1),0_0_20px_hsl(var(--warning)/0.1)] dark:hover:shadow-[0_8px_24px_-4px_rgba(0,0,0,0.4),0_0_24px_hsl(var(--warning)/0.12)]"
|
||||
: "hover:shadow-[0_8px_24px_-4px_rgba(0,0,0,0.1),0_0_20px_hsl(var(--success)/0.1)] dark:hover:shadow-[0_8px_24px_-4px_rgba(0,0,0,0.4),0_0_24px_hsl(var(--success)/0.12)]";
|
||||
|
||||
const badgeBgClass = screenType === "grid"
|
||||
? "bg-primary/8 dark:bg-primary/15 text-primary"
|
||||
: screenType === "dashboard"
|
||||
? "bg-warning/8 dark:bg-warning/15 text-warning"
|
||||
: "bg-success/8 dark:bg-success/15 text-success";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={screen.screenId}
|
||||
className={`group relative overflow-hidden rounded-[12px] cursor-pointer transition-all duration-250 ease-[cubic-bezier(0.4,0,0.2,1)] ${
|
||||
isSelected
|
||||
? "border border-primary bg-primary/5 dark:bg-primary/8 shadow-[0_0_0_2px_hsl(var(--primary)/0.22),0_1px_3px_rgba(0,0,0,0.06)] dark:shadow-[0_0_0_2px_hsl(var(--primary)/0.3),0_1px_4px_rgba(0,0,0,0.3)]"
|
||||
: `border border-transparent bg-card shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] dark:shadow-[0_1px_4px_rgba(0,0,0,0.35),0_0_1px_rgba(0,0,0,0.2)] hover:-translate-y-[2px] ${glowClass}`
|
||||
}`}
|
||||
onClick={() => handleScreenSelect(screen)}
|
||||
onDoubleClick={() => handleDesignScreen(screen)}
|
||||
>
|
||||
{/* 좌측 그라데이션 액센트 바 */}
|
||||
<div className={`absolute left-0 top-3 bottom-3 w-[3px] rounded-r-full bg-gradient-to-b ${typeColorClass} transition-all duration-250 group-hover:top-1 group-hover:bottom-1 group-hover:w-[4px]`} />
|
||||
{isSelected && (
|
||||
<div className={`absolute left-0 top-0 bottom-0 w-[4px] bg-gradient-to-b ${typeColorClass}`} />
|
||||
)}
|
||||
<div className="pl-[14px] pr-4 py-4">
|
||||
{/* Row 1: 이름 + 타입 뱃지 */}
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="text-[15px] font-bold leading-snug truncate flex-1 min-w-0 tracking-[-0.3px]">{screen.screenName}</div>
|
||||
<span className={`text-[11px] font-semibold px-2.5 py-[3px] rounded-md flex-shrink-0 ${badgeBgClass}`}>
|
||||
{screenType === "grid" ? "그리드" : screenType === "dashboard" ? "대시보드" : "폼"}
|
||||
</span>
|
||||
</div>
|
||||
{/* Row 2: 스크린 코드 */}
|
||||
<div className="text-[12px] font-mono text-muted-foreground tracking-[-0.3px] truncate mb-3">{screen.screenCode}</div>
|
||||
{/* Row 3: 테이블 칩 + 메타 */}
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="inline-flex items-center gap-1.5 text-[12px] font-medium text-foreground/80 dark:text-foreground/70 px-2.5 py-1 rounded-md bg-muted/60 dark:bg-muted/40">
|
||||
<Database className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="font-mono text-[11px]">{screen.tableLabel || screen.tableName || "—"}</span>
|
||||
</span>
|
||||
</div>
|
||||
{/* Row 4: 날짜 + 수정 상태 */}
|
||||
<div className="flex items-center justify-between mt-3 pt-2.5 border-t border-border/20 dark:border-border/10">
|
||||
<span className="text-[12px] font-mono text-muted-foreground">
|
||||
{screen.updatedDate ? new Date(screen.updatedDate).toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" }) : screen.createdDate ? new Date(screen.createdDate).toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" }) : ""}
|
||||
</span>
|
||||
{isRecentlyModified && (
|
||||
<span className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span className="relative inline-block w-[6px] h-[6px] rounded-full bg-success screen-card-pulse-dot" />
|
||||
수정됨
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{filteredScreens.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Search className="h-8 w-8 mb-3 opacity-30" />
|
||||
<p className="text-sm">검색 결과가 없습니다</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 화면 디테일 Sheet */}
|
||||
<Sheet open={isDetailOpen} onOpenChange={setIsDetailOpen}>
|
||||
<SheetContent className="w-[420px] sm:max-w-[420px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="text-base">{selectedScreen?.screenName || "화면 상세"}</SheetTitle>
|
||||
<SheetDescription className="text-xs font-mono">{selectedScreen?.screenCode}</SheetDescription>
|
||||
</SheetHeader>
|
||||
{selectedScreen && (
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">테이블</span>
|
||||
<span className="text-xs font-mono">{selectedScreen.tableName || "없음"}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">화면 ID</span>
|
||||
<span className="text-xs font-mono">{selectedScreen.screenId}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-4 border-t border-border/50">
|
||||
<Button className="flex-1" onClick={() => { handleDesignScreen(selectedScreen); setIsDetailOpen(false); }}>
|
||||
편집하기
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setIsDetailOpen(false)}>
|
||||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* 화면 생성 모달 */}
|
||||
<CreateScreenModal
|
||||
isOpen={isCreateOpen}
|
||||
onClose={() => setIsCreateOpen(false)}
|
||||
onSuccess={() => {
|
||||
open={isCreateOpen}
|
||||
onOpenChange={setIsCreateOpen}
|
||||
onCreated={() => {
|
||||
setIsCreateOpen(false);
|
||||
loadScreens();
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -418,6 +418,21 @@ select {
|
|||
border-spacing: 0 !important;
|
||||
}
|
||||
|
||||
/* ===== 카드 펄스 도트 애니메이션 ===== */
|
||||
@keyframes screen-card-pulse {
|
||||
0%, 100% { opacity: 0; transform: scale(1); }
|
||||
50% { opacity: 0.35; transform: scale(2); }
|
||||
}
|
||||
.screen-card-pulse-dot::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -3px;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--success));
|
||||
opacity: 0;
|
||||
animation: screen-card-pulse 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ===== 저장 테이블 막대기 애니메이션 ===== */
|
||||
@keyframes saveBarDrop {
|
||||
0% {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { BaseEdge, getBezierPath, type EdgeProps } from "@xyflow/react";
|
||||
|
||||
// 커스텀 애니메이션 엣지 — bezier 곡선 + 흐르는 파티클 + 글로우 레이어
|
||||
export function AnimatedFlowEdge({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
style,
|
||||
markerEnd,
|
||||
data,
|
||||
}: EdgeProps) {
|
||||
const [edgePath] = getBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
});
|
||||
|
||||
const strokeColor = (style?.stroke as string) || "hsl(var(--primary))";
|
||||
const strokeW = (style?.strokeWidth as number) || 2;
|
||||
const isActive = data?.active !== false;
|
||||
const duration = data?.duration || "3s";
|
||||
const filterId = `edge-glow-${id}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 글로우용 SVG 필터 정의 (엣지별 고유 ID) */}
|
||||
<defs>
|
||||
<filter id={filterId} x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur in="SourceGraphic" stdDeviation="2" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
{/* 글로우 레이어 */}
|
||||
<path
|
||||
d={edgePath}
|
||||
fill="none"
|
||||
stroke={strokeColor}
|
||||
strokeWidth={strokeW + 4}
|
||||
strokeOpacity={0.12}
|
||||
filter={`url(#${filterId})`}
|
||||
/>
|
||||
{/* 메인 엣지 */}
|
||||
<BaseEdge id={id} path={edgePath} style={style} markerEnd={markerEnd} />
|
||||
{/* 흐르는 파티클 */}
|
||||
{isActive && (
|
||||
<>
|
||||
<circle r="3" fill={strokeColor} filter={`url(#${filterId})`}>
|
||||
<animateMotion dur={duration} repeatCount="indefinite" path={edgePath} />
|
||||
</circle>
|
||||
<circle r="1.5" fill="white" opacity="0.85">
|
||||
<animateMotion dur={duration} repeatCount="indefinite" path={edgePath} />
|
||||
</circle>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -37,7 +37,8 @@ import {
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { getCompanyList, Company } from "@/lib/api/company";
|
||||
import { getCompanyList } from "@/lib/api/company";
|
||||
import type { Company } from "@/types/company";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -1106,7 +1107,7 @@ export function ScreenGroupTreeView({
|
|||
{/* 그룹 헤더 */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer hover:bg-accent transition-colors",
|
||||
"flex items-center gap-2 rounded-lg px-2 py-1.5 cursor-pointer hover:bg-muted/40 transition-colors",
|
||||
"text-sm font-medium group/item",
|
||||
isMatching && "bg-primary/5 dark:bg-primary/10" // 검색 일치 하이라이트 (연한 배경)
|
||||
)}
|
||||
|
|
@ -1119,12 +1120,12 @@ export function ScreenGroupTreeView({
|
|||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
{isExpanded ? (
|
||||
<FolderOpen className="h-4 w-4 shrink-0 text-amber-500" />
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-warning/15"><FolderOpen className="h-3.5 w-3.5 text-warning" /></span>
|
||||
) : (
|
||||
<Folder className="h-4 w-4 shrink-0 text-amber-500" />
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-warning/15"><Folder className="h-3.5 w-3.5 text-warning" /></span>
|
||||
)}
|
||||
<span className={cn("truncate flex-1", isMatching && "font-medium text-primary/80")}>{group.group_name}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<Badge variant="secondary" className="text-xs font-mono">
|
||||
{groupScreens.length}
|
||||
</Badge>
|
||||
{/* 그룹 메뉴 버튼 */}
|
||||
|
|
@ -1157,7 +1158,8 @@ export function ScreenGroupTreeView({
|
|||
|
||||
{/* 그룹 내 하위 그룹들 */}
|
||||
{isExpanded && childGroups.length > 0 && (
|
||||
<div className="ml-6 mt-1 space-y-0.5">
|
||||
<div className="relative ml-6 mt-1 space-y-0.5">
|
||||
<div className="absolute left-[14px] top-0 bottom-0 w-px bg-border/40" />
|
||||
{childGroups.map((childGroup) => {
|
||||
const childGroupId = String(childGroup.id);
|
||||
const isChildExpanded = expandedGroups.has(childGroupId) || shouldAutoExpandForSearch.has(childGroup.id); // 검색 시 상위 그룹만 자동 확장
|
||||
|
|
@ -1172,7 +1174,7 @@ export function ScreenGroupTreeView({
|
|||
{/* 중분류 헤더 */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer hover:bg-accent transition-colors",
|
||||
"flex items-center gap-2 rounded-lg px-2 py-1.5 cursor-pointer hover:bg-muted/40 transition-colors",
|
||||
"text-xs font-medium group/item",
|
||||
isChildMatching && "bg-primary/5 dark:bg-primary/10"
|
||||
)}
|
||||
|
|
@ -1185,12 +1187,12 @@ export function ScreenGroupTreeView({
|
|||
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
{isChildExpanded ? (
|
||||
<FolderOpen className="h-3 w-3 shrink-0 text-primary" />
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-primary/15"><FolderOpen className="h-3.5 w-3.5 text-primary" /></span>
|
||||
) : (
|
||||
<Folder className="h-3 w-3 shrink-0 text-primary" />
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-primary/15"><Folder className="h-3.5 w-3.5 text-primary" /></span>
|
||||
)}
|
||||
<span className={cn("truncate flex-1", isChildMatching && "font-medium text-primary/80")}>{childGroup.group_name}</span>
|
||||
<Badge variant="secondary" className="text-[10px] h-4">
|
||||
<Badge variant="secondary" className="text-[10px] h-4 font-mono">
|
||||
{childScreens.length}
|
||||
</Badge>
|
||||
<DropdownMenu>
|
||||
|
|
@ -1222,7 +1224,8 @@ export function ScreenGroupTreeView({
|
|||
|
||||
{/* 중분류 내 손자 그룹들 (소분류) */}
|
||||
{isChildExpanded && grandChildGroups.length > 0 && (
|
||||
<div className="ml-6 mt-1 space-y-0.5">
|
||||
<div className="relative ml-6 mt-1 space-y-0.5">
|
||||
<div className="absolute left-[14px] top-0 bottom-0 w-px bg-border/30" />
|
||||
{grandChildGroups.map((grandChild) => {
|
||||
const grandChildId = String(grandChild.id);
|
||||
const isGrandExpanded = expandedGroups.has(grandChildId) || shouldAutoExpandForSearch.has(grandChild.id); // 검색 시 상위 그룹만 자동 확장
|
||||
|
|
@ -1234,7 +1237,7 @@ export function ScreenGroupTreeView({
|
|||
{/* 소분류 헤더 */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer hover:bg-accent transition-colors",
|
||||
"flex items-center gap-2 rounded-lg px-2 py-1.5 cursor-pointer hover:bg-muted/40 transition-colors",
|
||||
"text-xs group/item",
|
||||
isGrandMatching && "bg-primary/5 dark:bg-primary/10"
|
||||
)}
|
||||
|
|
@ -1247,12 +1250,12 @@ export function ScreenGroupTreeView({
|
|||
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
{isGrandExpanded ? (
|
||||
<FolderOpen className="h-3 w-3 shrink-0 text-emerald-500" />
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-success/15"><FolderOpen className="h-3.5 w-3.5 text-success" /></span>
|
||||
) : (
|
||||
<Folder className="h-3 w-3 shrink-0 text-emerald-500" />
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-success/15"><Folder className="h-3.5 w-3.5 text-success" /></span>
|
||||
)}
|
||||
<span className={cn("truncate flex-1", isGrandMatching && "font-medium text-primary/80")}>{grandChild.group_name}</span>
|
||||
<Badge variant="outline" className="text-[10px] h-4">
|
||||
<Badge variant="outline" className="text-[10px] h-4 font-mono">
|
||||
{grandScreens.length}
|
||||
</Badge>
|
||||
<DropdownMenu>
|
||||
|
|
@ -1294,9 +1297,9 @@ export function ScreenGroupTreeView({
|
|||
<div
|
||||
key={screen.screenId}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors",
|
||||
"text-xs hover:bg-accent",
|
||||
selectedScreen?.screenId === screen.screenId && "bg-accent"
|
||||
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors duration-150",
|
||||
"text-xs hover:bg-muted/60",
|
||||
selectedScreen?.screenId === screen.screenId && "bg-primary/10 border-l-2 border-primary"
|
||||
)}
|
||||
onClick={() => handleScreenClickInGroup(screen, grandChild)}
|
||||
onDoubleClick={() => handleScreenDoubleClick(screen)}
|
||||
|
|
@ -1330,9 +1333,9 @@ export function ScreenGroupTreeView({
|
|||
<div
|
||||
key={screen.screenId}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors",
|
||||
"text-xs hover:bg-accent",
|
||||
selectedScreen?.screenId === screen.screenId && "bg-accent"
|
||||
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors duration-150",
|
||||
"text-xs hover:bg-muted/60",
|
||||
selectedScreen?.screenId === screen.screenId && "bg-primary/10 border-l-2 border-primary"
|
||||
)}
|
||||
onClick={() => handleScreenClickInGroup(screen, childGroup)}
|
||||
onDoubleClick={() => handleScreenDoubleClick(screen)}
|
||||
|
|
@ -1366,9 +1369,9 @@ export function ScreenGroupTreeView({
|
|||
<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 group/screen",
|
||||
selectedScreen?.screenId === screen.screenId && "bg-accent"
|
||||
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors duration-150",
|
||||
"text-sm hover:bg-muted/60 group/screen",
|
||||
selectedScreen?.screenId === screen.screenId && "bg-primary/10 border-l-2 border-primary"
|
||||
)}
|
||||
onClick={() => handleScreenClickInGroup(screen, group)}
|
||||
onDoubleClick={() => handleScreenDoubleClick(screen)}
|
||||
|
|
@ -1393,7 +1396,7 @@ export function ScreenGroupTreeView({
|
|||
<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",
|
||||
"flex items-center gap-2 rounded-lg px-2 py-1.5 cursor-pointer hover:bg-muted/40 transition-colors",
|
||||
"text-sm font-medium text-muted-foreground"
|
||||
)}
|
||||
onClick={() => toggleGroup("ungrouped")}
|
||||
|
|
@ -1405,7 +1408,7 @@ export function ScreenGroupTreeView({
|
|||
)}
|
||||
<Folder className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate flex-1">미분류</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<Badge variant="outline" className="text-xs font-mono">
|
||||
{ungroupedScreens.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
|
@ -1416,9 +1419,9 @@ export function ScreenGroupTreeView({
|
|||
<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"
|
||||
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors duration-150",
|
||||
"text-sm hover:bg-muted/60",
|
||||
selectedScreen?.screenId === screen.screenId && "bg-primary/10 border-l-2 border-primary"
|
||||
)}
|
||||
onClick={() => handleScreenClick(screen)}
|
||||
onDoubleClick={() => handleScreenDoubleClick(screen)}
|
||||
|
|
@ -2096,15 +2099,15 @@ export function ScreenGroupTreeView({
|
|||
onClick={() => handleSync("menu-to-screen")}
|
||||
disabled={isSyncing}
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-2 border-emerald-200 bg-emerald-50/50 hover:bg-emerald-100/70 hover:border-emerald-300"
|
||||
className="w-full justify-start gap-2 border-success/20 bg-success/5 hover:bg-success/10 hover:border-success/30"
|
||||
>
|
||||
{isSyncing && syncDirection === "menu-to-screen" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-emerald-600" />
|
||||
<Loader2 className="h-4 w-4 animate-spin text-success" />
|
||||
) : (
|
||||
<FolderInput className="h-4 w-4 text-emerald-600" />
|
||||
<FolderInput className="h-4 w-4 text-success" />
|
||||
)}
|
||||
<span className="flex-1 text-left text-emerald-700">메뉴 → 화면관리 동기화</span>
|
||||
<span className="text-xs text-emerald-500/70">
|
||||
<span className="flex-1 text-left text-success">메뉴 → 화면관리 동기화</span>
|
||||
<span className="text-xs text-success/70">
|
||||
메뉴 구조를 폴더에 반영
|
||||
</span>
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -11,10 +11,25 @@ import {
|
|||
MousePointer2,
|
||||
Key,
|
||||
Link2,
|
||||
Columns3,
|
||||
} from "lucide-react";
|
||||
import { ScreenLayoutSummary } from "@/lib/api/screenGroup";
|
||||
|
||||
// 글로우 펄스 애니메이션 CSS 주입
|
||||
if (typeof document !== "undefined") {
|
||||
const styleId = "glow-pulse-animation";
|
||||
if (!document.getElementById(styleId)) {
|
||||
const style = document.createElement("style");
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
@keyframes glow-pulse {
|
||||
from { filter: drop-shadow(0 0 4px hsl(var(--primary) / 0.25)) drop-shadow(0 0 10px hsl(var(--primary) / 0.12)); }
|
||||
to { filter: drop-shadow(0 0 6px hsl(var(--primary) / 0.35)) drop-shadow(0 0 16px hsl(var(--primary) / 0.18)); }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 타입 정의 ==========
|
||||
|
||||
// 화면 노드 데이터 인터페이스
|
||||
|
|
@ -107,42 +122,14 @@ const getScreenTypeIcon = (screenType?: string) => {
|
|||
}
|
||||
};
|
||||
|
||||
// 화면 타입별 색상 (헤더)
|
||||
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-primary";
|
||||
}
|
||||
// 화면 타입별 색상 (헤더) - 더 이상 그라데이션 미사용
|
||||
const getScreenTypeColor = (_screenType?: string, _isMain?: boolean) => {
|
||||
return "";
|
||||
};
|
||||
|
||||
// 화면 역할(screenRole)에 따른 색상
|
||||
const getScreenRoleColor = (screenRole?: string) => {
|
||||
if (!screenRole) return "bg-slate-400";
|
||||
|
||||
// 역할명에 포함된 키워드로 색상 결정
|
||||
const role = screenRole.toLowerCase();
|
||||
|
||||
if (role.includes("그리드") || role.includes("grid") || role.includes("메인") || role.includes("main") || role.includes("list")) {
|
||||
return "bg-violet-500"; // 보라색 - 메인 그리드
|
||||
}
|
||||
if (role.includes("등록") || role.includes("폼") || role.includes("form") || role.includes("register") || role.includes("input")) {
|
||||
return "bg-primary"; // 파란색 - 등록 폼
|
||||
}
|
||||
if (role.includes("액션") || role.includes("action") || role.includes("이벤트") || role.includes("event") || role.includes("클릭")) {
|
||||
return "bg-rose-500"; // 빨간색 - 액션/이벤트
|
||||
}
|
||||
if (role.includes("상세") || role.includes("detail") || role.includes("popup") || role.includes("팝업")) {
|
||||
return "bg-amber-500"; // 주황색 - 상세/팝업
|
||||
}
|
||||
|
||||
return "bg-slate-400"; // 기본 회색
|
||||
// 화면 역할(screenRole)에 따른 색상 - 더 이상 그라데이션 미사용
|
||||
const getScreenRoleColor = (_screenRole?: string) => {
|
||||
return "";
|
||||
};
|
||||
|
||||
// 화면 타입별 라벨
|
||||
|
|
@ -161,36 +148,26 @@ const getScreenTypeLabel = (screenType?: string) => {
|
|||
|
||||
// ========== 화면 노드 (상단) - 미리보기 표시 ==========
|
||||
export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
|
||||
const { label, subLabel, isMain, tableName, layoutSummary, isInGroup, isFocused, isFaded, screenRole } = data;
|
||||
const { label, isMain, tableName, layoutSummary, isFocused, isFaded } = data;
|
||||
const screenType = layoutSummary?.screenType || "form";
|
||||
|
||||
// 그룹 모드에서는 screenRole 기반 색상, 그렇지 않으면 screenType 기반 색상
|
||||
// isFocused일 때 색상 활성화, isFaded일 때 회색
|
||||
let headerColor: string;
|
||||
if (isInGroup) {
|
||||
if (isFaded) {
|
||||
headerColor = "bg-muted/60"; // 흑백 처리 - 더 확실한 회색
|
||||
} else {
|
||||
// 포커스되었거나 아직 아무것도 선택 안됐을 때: 역할별 색상
|
||||
headerColor = getScreenRoleColor(screenRole);
|
||||
}
|
||||
} else {
|
||||
headerColor = getScreenTypeColor(screenType, isMain);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative flex h-[320px] w-[260px] flex-col overflow-hidden rounded-lg border bg-card shadow-md transition-all cursor-pointer ${
|
||||
className={`group relative flex h-[240px] w-[240px] flex-col overflow-hidden rounded-[10px] border bg-card dark:bg-card/80 backdrop-blur-sm transition-all cursor-pointer ${
|
||||
isFocused
|
||||
? "border-2 border-primary ring-4 ring-primary/50 shadow-xl scale-105"
|
||||
? "border-primary/40 shadow-[0_0_0_1px_hsl(var(--primary)/0.4)] scale-[1.03]"
|
||||
: isFaded
|
||||
? "border-border opacity-50"
|
||||
: "border-border hover:shadow-lg hover:ring-2 hover:ring-primary/20"
|
||||
? "opacity-40 border-border/40 dark:border-border/10 shadow-[0_4px_24px_-8px_rgba(0,0,0,0.08)] dark:shadow-[0_4px_24px_-8px_rgba(0,0,0,0.5)]"
|
||||
: "border-border/40 dark:border-border/10 shadow-[0_4px_24px_-8px_rgba(0,0,0,0.08)] dark:shadow-[0_4px_24px_-8px_rgba(0,0,0,0.5)] hover:border-border/50 dark:hover:border-border/20 hover:shadow-[0_4px_24px_-8px_rgba(0,0,0,0.08)] dark:hover:shadow-[0_4px_24px_-8px_rgba(0,0,0,0.5)] hover:-translate-y-0.5"
|
||||
}`}
|
||||
style={{
|
||||
filter: isFaded ? "grayscale(100%)" : "none",
|
||||
filter: isFaded
|
||||
? "grayscale(100%)"
|
||||
: isFocused
|
||||
? "drop-shadow(0 0 8px hsl(var(--primary) / 0.5)) drop-shadow(0 0 20px hsl(var(--primary) / 0.25))"
|
||||
: "none",
|
||||
transition: "all 0.3s ease",
|
||||
transform: isFocused ? "scale(1.02)" : "scale(1)",
|
||||
animation: isFocused ? "glow-pulse 2s ease-in-out infinite alternate" : "none",
|
||||
}}
|
||||
>
|
||||
{/* Handles */}
|
||||
|
|
@ -198,78 +175,49 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
|
|||
type="target"
|
||||
position={Position.Left}
|
||||
id="left"
|
||||
className="!h-2 !w-2 !border-2 !border-background !bg-primary opacity-0 transition-opacity group-hover:opacity-100"
|
||||
className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-all duration-300 group-hover:opacity-100 group-hover:shadow-[0_0_6px_hsl(var(--primary)/0.5)]"
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="right"
|
||||
className="!h-2 !w-2 !border-2 !border-background !bg-primary opacity-0 transition-opacity group-hover:opacity-100"
|
||||
className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-all duration-300 group-hover:opacity-100 group-hover:shadow-[0_0_6px_hsl(var(--primary)/0.5)]"
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id="bottom"
|
||||
className="!h-2 !w-2 !border-2 !border-background !bg-primary opacity-0 transition-opacity group-hover:opacity-100"
|
||||
className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-all duration-300 group-hover:opacity-100 group-hover:shadow-[0_0_6px_hsl(var(--primary)/0.5)]"
|
||||
/>
|
||||
|
||||
{/* 헤더 (컬러) */}
|
||||
<div className={`flex items-center gap-2 px-3 py-2 text-white ${headerColor} transition-colors duration-300`}>
|
||||
<Monitor className="h-4 w-4" />
|
||||
<span className="flex-1 truncate text-xs font-semibold">{label}</span>
|
||||
{(isMain || isFocused) && <span className="flex h-2 w-2 rounded-full bg-white/80 animate-pulse" />}
|
||||
{/* 헤더: 그라디언트 제거, 모노크롬 */}
|
||||
<div className="flex items-center gap-2 border-b border-border/40 dark:border-border/10 bg-muted/50 dark:bg-muted/30 px-3 py-2 transition-colors duration-300">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded bg-primary/10 text-primary">
|
||||
<Monitor className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate text-xs font-bold text-foreground">{label}</div>
|
||||
{tableName && <div className="truncate text-[9px] text-muted-foreground font-mono">{tableName}</div>}
|
||||
</div>
|
||||
{(isMain || isFocused) && <span className="flex h-2 w-2 rounded-full bg-foreground/[0.12] dark:bg-foreground/8 animate-pulse" />}
|
||||
</div>
|
||||
|
||||
{/* 화면 미리보기 영역 (컴팩트) */}
|
||||
<div className="h-[140px] overflow-hidden bg-muted/50 p-2">
|
||||
<div className="h-[110px] overflow-hidden p-2.5">
|
||||
{layoutSummary ? (
|
||||
<ScreenPreview layoutSummary={layoutSummary} screenType={screenType} />
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground/70 dark:text-muted-foreground/40">
|
||||
{getScreenTypeIcon(screenType)}
|
||||
<span className="mt-1 text-[10px]">화면: {label}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 필드 매핑 영역 */}
|
||||
<div className="flex-1 overflow-hidden border-t border-border bg-card px-2 py-1.5">
|
||||
<div className="mb-1 flex items-center gap-1 text-[9px] font-medium text-muted-foreground">
|
||||
<Columns3 className="h-3 w-3" />
|
||||
<span>필드 매핑</span>
|
||||
<span className="ml-auto text-[8px] text-muted-foreground/70">
|
||||
{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 className="flex items-center justify-between border-t border-border/40 dark:border-border/10 bg-background dark:bg-background/50 px-3 py-1.5">
|
||||
<span className="text-[9px] font-medium px-[7px] py-[2px] rounded bg-primary/10 text-primary">{getScreenTypeLabel(screenType)}</span>
|
||||
<span className="text-[9px] text-muted-foreground">{layoutSummary?.totalComponents ?? 0}개 컴포넌트</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -280,33 +228,33 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
|
|||
const getComponentColor = (componentKind: string) => {
|
||||
// 테이블/그리드 관련
|
||||
if (componentKind === "table-list" || componentKind === "data-grid") {
|
||||
return "bg-violet-200 border-violet-400";
|
||||
return "bg-primary/20 border-primary/40";
|
||||
}
|
||||
// 검색 필터
|
||||
if (componentKind === "table-search-widget" || componentKind === "search-filter") {
|
||||
return "bg-pink-200 border-pink-400";
|
||||
return "bg-destructive/20 border-destructive/40";
|
||||
}
|
||||
// 버튼 관련
|
||||
if (componentKind?.includes("button")) {
|
||||
return "bg-blue-300 border-primary";
|
||||
return "bg-primary/30 border-primary";
|
||||
}
|
||||
// 입력 필드
|
||||
if (componentKind?.includes("input") || componentKind?.includes("text")) {
|
||||
return "bg-slate-200 border-slate-400";
|
||||
return "bg-muted border-border";
|
||||
}
|
||||
// 셀렉트/드롭다운
|
||||
if (componentKind?.includes("select") || componentKind?.includes("dropdown")) {
|
||||
return "bg-amber-200 border-amber-400";
|
||||
return "bg-warning/20 border-warning/40";
|
||||
}
|
||||
// 차트
|
||||
if (componentKind?.includes("chart")) {
|
||||
return "bg-emerald-200 border-emerald-400";
|
||||
return "bg-success/20 border-success/40";
|
||||
}
|
||||
// 커스텀 위젯
|
||||
if (componentKind === "custom") {
|
||||
return "bg-pink-200 border-pink-400";
|
||||
return "bg-destructive/20 border-destructive/40";
|
||||
}
|
||||
return "bg-slate-100 border-slate-300";
|
||||
return "bg-muted/50 border-border";
|
||||
};
|
||||
|
||||
// ========== 화면 미리보기 컴포넌트 - 화면 타입별 간단한 일러스트 ==========
|
||||
|
|
@ -316,130 +264,114 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType:
|
|||
}) => {
|
||||
const { totalComponents, widgetCounts } = layoutSummary;
|
||||
|
||||
// 그리드 화면 일러스트
|
||||
// 그리드 화면 일러스트 (모노크롬)
|
||||
if (screenType === "grid") {
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-2 rounded-lg border border-border bg-muted/30 p-3">
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-2 rounded-lg border border-border/30 dark:border-border/5 bg-muted/30 dark:bg-muted/10 p-3">
|
||||
{/* 상단 툴바 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-16 rounded bg-pink-400/80 shadow-sm" />
|
||||
<div className="h-4 w-16 rounded bg-foreground/[0.15] dark:bg-foreground/10" />
|
||||
<div className="flex-1" />
|
||||
<div className="h-4 w-8 rounded bg-primary shadow-sm" />
|
||||
<div className="h-4 w-8 rounded bg-primary shadow-sm" />
|
||||
<div className="h-4 w-8 rounded bg-rose-500 shadow-sm" />
|
||||
<div className="h-4 w-8 rounded bg-foreground/[0.18] dark:bg-foreground/12" />
|
||||
<div className="h-4 w-8 rounded bg-foreground/[0.18] dark:bg-foreground/12" />
|
||||
<div className="h-4 w-8 rounded bg-foreground/[0.12] dark:bg-foreground/8" />
|
||||
</div>
|
||||
{/* 테이블 헤더 */}
|
||||
<div className="flex gap-1 rounded-t-md bg-violet-500 px-2 py-2 shadow-sm">
|
||||
<div className="flex gap-1 rounded-t-md bg-foreground/[0.18] dark:bg-foreground/12 px-2 py-2">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="h-2.5 flex-1 rounded bg-white/40" />
|
||||
<div key={i} className="h-2.5 flex-1 rounded bg-foreground/[0.12] dark:bg-foreground/8" />
|
||||
))}
|
||||
</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-muted" : "bg-card"}`}>
|
||||
<div key={i} className={`flex gap-1 rounded px-2 py-1.5 ${i % 2 === 0 ? "bg-muted/30 dark:bg-muted/10" : "bg-card"}`}>
|
||||
{[...Array(5)].map((_, j) => (
|
||||
<div key={j} className="h-2 flex-1 rounded bg-muted-foreground/30" />
|
||||
<div key={j} className="h-2 flex-1 rounded bg-foreground/[0.1] dark:bg-foreground/6" />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* 페이지네이션 */}
|
||||
<div className="flex items-center justify-center gap-2 pt-1">
|
||||
<div className="h-2.5 w-4 rounded bg-muted-foreground/40" />
|
||||
<div className="h-2.5 w-4 rounded bg-primary" />
|
||||
<div className="h-2.5 w-4 rounded bg-muted-foreground/40" />
|
||||
<div className="h-2.5 w-4 rounded bg-muted-foreground/40" />
|
||||
</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 className="h-2.5 w-4 rounded bg-foreground/[0.12] dark:bg-foreground/8" />
|
||||
<div className="h-2.5 w-4 rounded bg-foreground/[0.18] dark:bg-foreground/12" />
|
||||
<div className="h-2.5 w-4 rounded bg-foreground/[0.12] dark:bg-foreground/8" />
|
||||
<div className="h-2.5 w-4 rounded bg-foreground/[0.12] dark:bg-foreground/8" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 폼 화면 일러스트
|
||||
// 폼 화면 일러스트 (모노크롬)
|
||||
if (screenType === "form") {
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<div className="flex h-full flex-col gap-3 rounded-lg border border-border/30 dark:border-border/5 bg-muted/30 dark:bg-muted/10 p-3">
|
||||
{/* 폼 필드들 */}
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<div className="h-2.5 w-14 rounded bg-muted-foreground/50" />
|
||||
<div className="h-5 flex-1 rounded-md border border-border bg-card shadow-sm" />
|
||||
<div className="h-2.5 w-14 rounded bg-foreground/[0.12] dark:bg-foreground/8" />
|
||||
<div className="h-5 flex-1 rounded-md border border-border/30 dark:border-border/5 bg-card" />
|
||||
</div>
|
||||
))}
|
||||
{/* 버튼 영역 */}
|
||||
<div className="mt-auto flex justify-end gap-2 border-t border-border pt-3">
|
||||
<div className="h-5 w-14 rounded-md bg-muted-foreground/40 shadow-sm" />
|
||||
<div className="h-5 w-14 rounded-md bg-primary 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 className="mt-auto flex justify-end gap-2 border-t border-border/30 dark:border-border/5 pt-3">
|
||||
<div className="h-5 w-14 rounded-md bg-foreground/[0.12] dark:bg-foreground/8" />
|
||||
<div className="h-5 w-14 rounded-md bg-foreground/[0.18] dark:bg-foreground/12" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 대시보드 화면 일러스트
|
||||
// 대시보드 화면 일러스트 (모노크롬)
|
||||
if (screenType === "dashboard") {
|
||||
return (
|
||||
<div className="grid h-full grid-cols-2 gap-2 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<div className="grid h-full grid-cols-2 gap-2 rounded-lg border border-border/30 dark:border-border/5 bg-muted/30 dark:bg-muted/10 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 className="rounded-lg bg-foreground/[0.08] dark:bg-foreground/5 p-2">
|
||||
<div className="mb-2 h-2.5 w-10 rounded bg-foreground/[0.15] dark:bg-foreground/10" />
|
||||
<div className="h-10 rounded-md bg-foreground/[0.12] dark:bg-foreground/8" />
|
||||
</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-primary/10 p-2 shadow-sm">
|
||||
<div className="mb-2 h-2.5 w-12 rounded bg-primary/70" />
|
||||
<div className="rounded-lg bg-foreground/[0.08] dark:bg-foreground/5 p-2">
|
||||
<div className="mb-2 h-2.5 w-10 rounded bg-foreground/[0.15] dark:bg-foreground/10" />
|
||||
<div className="h-10 rounded-md bg-foreground/[0.12] dark:bg-foreground/8" />
|
||||
</div>
|
||||
<div className="col-span-2 rounded-lg bg-foreground/[0.08] dark:bg-foreground/5 p-2">
|
||||
<div className="mb-2 h-2.5 w-12 rounded bg-foreground/[0.15] dark:bg-foreground/10" />
|
||||
<div className="flex h-14 items-end gap-1">
|
||||
{[...Array(10)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 rounded-t bg-primary/70/80"
|
||||
className="flex-1 rounded-t bg-foreground/[0.15] dark:bg-foreground/10"
|
||||
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-border bg-muted/30 p-3">
|
||||
<div className="rounded-full bg-muted p-4 text-muted-foreground">
|
||||
<MousePointer2 className="h-10 w-10" />
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="h-7 w-16 rounded-md bg-primary shadow-sm" />
|
||||
<div className="h-7 w-16 rounded-md bg-muted-foreground/40 shadow-sm" />
|
||||
</div>
|
||||
<div className="text-xs font-medium text-muted-foreground">액션 화면</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-border/30 dark:border-border/5 bg-muted/30 dark:bg-muted/10 p-3">
|
||||
<div className="rounded-full bg-foreground/[0.08] dark:bg-foreground/5 p-4 text-muted-foreground">
|
||||
<MousePointer2 className="h-10 w-10" />
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="h-7 w-16 rounded-md bg-foreground/[0.18] dark:bg-foreground/12" />
|
||||
<div className="h-7 w-16 rounded-md bg-foreground/[0.12] dark:bg-foreground/8" />
|
||||
</div>
|
||||
<div className="text-xs font-medium text-muted-foreground">액션 화면</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 기본 (알 수 없는 타입, 모노크롬)
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 rounded-lg border border-slate-200 bg-muted/30 text-slate-400">
|
||||
<div className="rounded-full bg-slate-100 p-4">
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 rounded-lg border border-border/30 dark:border-border/5 bg-muted/30 dark:bg-muted/10 text-muted-foreground">
|
||||
<div className="rounded-full bg-foreground/[0.08] dark:bg-foreground/5 p-4">
|
||||
{getScreenTypeIcon(screenType)}
|
||||
</div>
|
||||
<span className="text-sm font-medium">{totalComponents}개 컴포넌트</span>
|
||||
|
|
@ -574,21 +506,21 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`group relative flex w-[260px] flex-col overflow-visible rounded-xl border shadow-md ${
|
||||
// 1. 필터 테이블 (마스터-디테일의 디테일 테이블): 항상 보라색 테두리
|
||||
className={`group relative flex w-[260px] flex-col overflow-visible rounded-[10px] border bg-card dark:bg-card/80 backdrop-blur-sm shadow-[0_4px_24px_-8px_rgba(0,0,0,0.08)] dark:shadow-[0_4px_24px_-8px_rgba(0,0,0,0.5)] ${
|
||||
// 1. 필터 테이블 (마스터-디테일의 디테일 테이블)
|
||||
isFilterTable
|
||||
? "border-2 border-violet-500 ring-2 ring-violet-500/20 shadow-lg bg-violet-50/50"
|
||||
// 2. 필터 관련 테이블 (마스터 또는 디테일) 포커스 시: 진한 보라색
|
||||
? "border-primary/30 shadow-[0_0_0_1px_hsl(var(--primary)/0.3)]"
|
||||
// 2. 필터 관련 테이블 포커스 시
|
||||
: (hasFilterRelation || isFilterSource)
|
||||
? "border-2 border-violet-500 ring-4 ring-violet-500/30 shadow-xl bg-violet-50"
|
||||
// 3. 순수 포커스 (필터 관계 없음): 초록색
|
||||
? "border-primary/30 shadow-[0_0_0_1px_hsl(var(--primary)/0.3),0_0_24px_-8px_hsl(var(--primary)/0.1)]"
|
||||
// 3. 순수 포커스
|
||||
: isFocused
|
||||
? "border-2 border-emerald-500 ring-4 ring-emerald-500/30 shadow-xl bg-card"
|
||||
? "border-primary/30 shadow-[0_0_0_1px_hsl(var(--primary)/0.3),0_0_24px_-8px_hsl(var(--primary)/0.1)] bg-card"
|
||||
// 4. 흐리게 처리
|
||||
: isFaded
|
||||
? "border-border opacity-60 bg-card"
|
||||
? "opacity-60 bg-card border-border/40 dark:border-border/10"
|
||||
// 5. 기본
|
||||
: "border-border hover:shadow-lg hover:ring-2 hover:ring-emerald-500/20 bg-card"
|
||||
: "border-border/40 dark:border-border/10 hover:border-border/50 dark:hover:border-border/20"
|
||||
}`}
|
||||
style={{
|
||||
filter: isFaded ? "grayscale(80%)" : "none",
|
||||
|
|
@ -602,7 +534,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
|||
className="absolute -left-1.5 top-1 bottom-1 w-0.5 z-20 rounded-full transition-all duration-500 ease-out"
|
||||
title={hasSaveTarget ? "저장 대상 테이블" : undefined}
|
||||
style={{
|
||||
background: 'linear-gradient(to bottom, transparent 0%, #f472b6 15%, #f472b6 85%, transparent 100%)',
|
||||
background: `linear-gradient(to bottom, transparent 0%, hsl(var(--destructive)) 15%, hsl(var(--destructive)) 85%, transparent 100%)`,
|
||||
opacity: hasSaveTarget ? 1 : 0,
|
||||
transform: hasSaveTarget ? 'scaleY(1)' : 'scaleY(0)',
|
||||
transformOrigin: 'top',
|
||||
|
|
@ -616,7 +548,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
|||
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"
|
||||
className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
{/* top source: 메인테이블 → 메인테이블 연결용 (위쪽으로 나가는 선) */}
|
||||
<Handle
|
||||
|
|
@ -624,25 +556,25 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
|||
position={Position.Top}
|
||||
id="top_source"
|
||||
style={{ top: -4 }}
|
||||
className="!h-2 !w-2 !border-2 !border-background !bg-amber-500 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 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"
|
||||
className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 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"
|
||||
className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 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-amber-500 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
{/* bottom target: 메인테이블 ← 메인테이블 연결용 (아래에서 들어오는 선) */}
|
||||
<Handle
|
||||
|
|
@ -650,18 +582,18 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
|||
position={Position.Bottom}
|
||||
id="bottom_target"
|
||||
style={{ bottom: -4 }}
|
||||
className="!h-2 !w-2 !border-2 !border-background !bg-amber-500 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
|
||||
{/* 헤더 (필터 관계: 보라색, 필터 소스: 보라색, 메인: 초록색, 기본: 슬레이트) */}
|
||||
<div className={`flex items-center gap-2 px-3 py-1.5 text-white rounded-t-xl transition-colors duration-700 ease-in-out ${
|
||||
isFaded ? "bg-muted-foreground" : (hasFilterRelation || isFilterSource) ? "bg-violet-600" : isMain ? "bg-emerald-600" : "bg-slate-500"
|
||||
}`}>
|
||||
<Database className="h-3.5 w-3.5 shrink-0" />
|
||||
{/* 헤더: 그라디언트 제거, bg-muted/30 + 아이콘 박스 */}
|
||||
<div className="flex items-center gap-2.5 px-3.5 py-2.5 border-b border-border/40 dark:border-border/10 bg-muted/50 dark:bg-muted/30 rounded-t-[10px] transition-colors duration-700 ease-in-out">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-[7px] bg-cyan-500/10 shrink-0">
|
||||
<Database className="h-3.5 w-3.5 text-cyan-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate text-[11px] font-semibold">{label}</div>
|
||||
<div className="truncate text-[11px] font-semibold text-foreground font-mono">{label}</div>
|
||||
{/* 필터 관계에 따른 문구 변경 */}
|
||||
<div className="truncate text-[9px] opacity-80">
|
||||
<div className="truncate text-[9px] font-mono text-muted-foreground/70 dark:text-muted-foreground/40 tracking-[-0.3px]">
|
||||
{isFilterSource
|
||||
? "마스터 테이블 (필터 소스)"
|
||||
: hasFilterRelation
|
||||
|
|
@ -670,8 +602,8 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
|||
</div>
|
||||
</div>
|
||||
{hasActiveColumns && (
|
||||
<span className="rounded-full bg-white/20 px-1.5 py-0.5 text-[8px] shrink-0">
|
||||
{displayColumns.length}개 활성
|
||||
<span className="text-[9px] font-mono text-muted-foreground/70 dark:text-muted-foreground/40 px-1.5 py-0.5 rounded bg-foreground/[0.08] dark:bg-foreground/5 border border-border/40 dark:border-border/10 tracking-[-0.3px] shrink-0">
|
||||
{displayColumns.length} ref
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -679,7 +611,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
|||
{/* 컬럼 목록 - 컴팩트하게 (부드러운 높이 전환 + 스크롤) */}
|
||||
{/* 뱃지도 이 영역 안에 포함되어 높이 계산에 반영됨 */}
|
||||
<div
|
||||
className="p-1.5 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-transparent"
|
||||
className="p-1.5 overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent"
|
||||
style={{
|
||||
height: `${debouncedHeight}px`,
|
||||
maxHeight: `${MAX_HEIGHT}px`,
|
||||
|
|
@ -699,7 +631,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
|||
{/* 필터 뱃지 */}
|
||||
{filterRefs.length > 0 && (
|
||||
<span
|
||||
className="flex items-center gap-1 rounded-full bg-violet-600 px-2 py-px text-white font-semibold shadow-sm"
|
||||
className="flex items-center gap-1 rounded-full bg-primary px-2 py-px text-primary-foreground font-semibold shadow-sm"
|
||||
title={`마스터-디테일 필터링\n${filterRefs.map(r => `${r.fromTable}.${r.fromColumn || 'id'} → ${r.toColumn}`).join('\n')}`}
|
||||
>
|
||||
<Link2 className="h-3 w-3" />
|
||||
|
|
@ -707,14 +639,14 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
|||
</span>
|
||||
)}
|
||||
{filterRefs.length > 0 && (
|
||||
<span className="text-violet-700 font-medium truncate">
|
||||
<span className="text-primary font-medium truncate">
|
||||
{filterRefs.map(r => `${r.fromTableLabel || r.fromTable}.${r.fromColumnLabel || r.fromColumn || 'id'}`).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
{/* 참조 뱃지 */}
|
||||
{lookupRefs.length > 0 && (
|
||||
<span
|
||||
className="flex items-center gap-1 rounded-full bg-amber-500 px-2 py-px text-white font-semibold shadow-sm"
|
||||
className="flex items-center gap-1 rounded-full bg-warning px-2 py-px text-warning-foreground font-semibold shadow-sm"
|
||||
title={`코드 참조 (lookup)\n${lookupRefs.map(r => `${r.fromTable} → ${r.toColumn}`).join('\n')}`}
|
||||
>
|
||||
{lookupRefs.length}곳 참조
|
||||
|
|
@ -745,33 +677,37 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
|||
key={col.name}
|
||||
className={`flex items-center gap-1 rounded px-1.5 py-0.5 transition-all duration-300 ${
|
||||
isJoinColumn
|
||||
? "bg-amber-100 border border-orange-300 shadow-sm"
|
||||
? "bg-warning/10 border border-warning/20 shadow-sm"
|
||||
: isFilterColumn || isFilterSourceColumn
|
||||
? "bg-violet-100 border border-violet-300 shadow-sm" // 필터 컬럼/필터 소스: 보라색
|
||||
? "bg-primary/10 border border-primary/20 shadow-sm" // 필터 컬럼/필터 소스
|
||||
: isHighlighted
|
||||
? "bg-primary/10 border border-primary/40 shadow-sm"
|
||||
: hasActiveColumns
|
||||
? "bg-slate-100"
|
||||
: "bg-slate-50 hover:bg-slate-100"
|
||||
? "bg-muted"
|
||||
: "bg-muted/50 hover:bg-muted/80 transition-colors"
|
||||
}`}
|
||||
style={{
|
||||
animation: hasActiveColumns ? `fadeIn 0.5s ease-out ${idx * 80}ms forwards` : undefined,
|
||||
opacity: hasActiveColumns ? 0 : 1,
|
||||
}}
|
||||
>
|
||||
{/* PK/FK/조인/필터 아이콘 */}
|
||||
{isJoinColumn && <Link2 className="h-2.5 w-2.5 text-amber-500" />}
|
||||
{(isFilterColumn || isFilterSourceColumn) && !isJoinColumn && <Link2 className="h-2.5 w-2.5 text-violet-500" />}
|
||||
{!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && col.isPrimaryKey && <Key className="h-2.5 w-2.5 text-amber-500" />}
|
||||
{!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && col.isForeignKey && !col.isPrimaryKey && <Link2 className="h-2.5 w-2.5 text-primary" />}
|
||||
{!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && !col.isPrimaryKey && !col.isForeignKey && <div className="w-2.5" />}
|
||||
{/* 3px 세로 마커 (PK/FK/조인/필터) */}
|
||||
<div
|
||||
className={`w-[3px] h-[14px] rounded-sm flex-shrink-0 ${
|
||||
isJoinColumn ? "bg-amber-400"
|
||||
: (isFilterColumn || isFilterSourceColumn) ? "bg-primary opacity-80"
|
||||
: col.isPrimaryKey ? "bg-amber-400"
|
||||
: col.isForeignKey ? "bg-primary opacity-80"
|
||||
: "bg-muted-foreground/20"
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* 컬럼명 */}
|
||||
<span className={`flex-1 truncate font-mono text-[9px] font-medium ${
|
||||
isJoinColumn ? "text-orange-700"
|
||||
: (isFilterColumn || isFilterSourceColumn) ? "text-violet-700"
|
||||
: isHighlighted ? "text-primary"
|
||||
: "text-slate-700"
|
||||
isJoinColumn ? "text-amber-400"
|
||||
: (isFilterColumn || isFilterSourceColumn) ? "text-primary"
|
||||
: isHighlighted ? "text-primary"
|
||||
: "text-foreground"
|
||||
}`}>
|
||||
{col.name}
|
||||
</span>
|
||||
|
|
@ -781,63 +717,74 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
|||
<>
|
||||
{/* 조인 참조 테이블 표시 (joinColumnRefs에서) */}
|
||||
{joinRefMap.has(colOriginal) && (
|
||||
<span className="rounded bg-amber-100 px-1 text-[7px] text-amber-600">
|
||||
<span className="rounded bg-warning/20 px-1 text-[7px] text-warning">
|
||||
← {joinRefMap.get(colOriginal)?.refTableLabel}
|
||||
</span>
|
||||
)}
|
||||
{/* 필드 매핑 참조 표시 (fieldMappingMap에서, joinRefMap에 없는 경우) */}
|
||||
{!joinRefMap.has(colOriginal) && fieldMappingMap.has(colOriginal) && (
|
||||
<span className="rounded bg-amber-100 px-1 text-[7px] text-amber-600">
|
||||
<span className="rounded bg-warning/20 px-1 text-[7px] text-warning">
|
||||
← {fieldMappingMap.get(colOriginal)?.sourceDisplayName}
|
||||
</span>
|
||||
)}
|
||||
<span className="rounded bg-orange-200 px-1 text-[7px] text-orange-700">조인</span>
|
||||
<span className="rounded bg-warning/20 px-1 text-[7px] text-warning">조인</span>
|
||||
</>
|
||||
)}
|
||||
{isFilterColumn && !isJoinColumn && (
|
||||
<span className="rounded bg-violet-200 px-1 text-[7px] text-violet-700">필터</span>
|
||||
<span className="rounded bg-primary/20 px-1 text-[7px] text-primary">필터</span>
|
||||
)}
|
||||
{/* 메인 테이블에서 필터 소스로 사용되는 컬럼: "필터" + "사용" 둘 다 표시 */}
|
||||
{isFilterSourceColumn && !isJoinColumn && !isFilterColumn && (
|
||||
<>
|
||||
<span className="rounded bg-violet-200 px-1 text-[7px] text-violet-700">필터</span>
|
||||
<span className="rounded bg-primary/20 px-1 text-[7px] text-primary">필터</span>
|
||||
{isHighlighted && (
|
||||
<span className="rounded bg-blue-200 px-1 text-[7px] text-primary">사용</span>
|
||||
<span className="rounded bg-primary/20 px-1 text-[7px] text-primary">사용</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isHighlighted && !isJoinColumn && !isFilterColumn && !isFilterSourceColumn && (
|
||||
<span className="rounded bg-blue-200 px-1 text-[7px] text-primary">사용</span>
|
||||
<span className="rounded bg-primary/20 px-1 text-[7px] text-primary">사용</span>
|
||||
)}
|
||||
|
||||
{/* 타입 */}
|
||||
<span className="text-[8px] text-slate-400">{col.type}</span>
|
||||
<span className="text-[8px] text-muted-foreground/60 dark:text-muted-foreground/30 font-mono tracking-[-0.3px]">{col.type}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* 더 많은 컬럼이 있을 경우 표시 */}
|
||||
{remainingCount > 0 && (
|
||||
<div className="text-center text-[8px] text-slate-400 py-0.5">
|
||||
<div className="text-center text-[8px] text-muted-foreground py-0.5">
|
||||
+ {remainingCount}개 더
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-2 text-muted-foreground">
|
||||
<Database className="h-4 w-4 text-slate-300" />
|
||||
<span className="mt-0.5 text-[8px] text-slate-400">컬럼 정보 없음</span>
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="mt-0.5 text-[8px] text-muted-foreground">컬럼 정보 없음</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 푸터 (컴팩트) */}
|
||||
<div className="flex items-center justify-between border-t border-border bg-muted/30 px-2 py-1">
|
||||
<span className="text-[9px] text-muted-foreground">PostgreSQL</span>
|
||||
{columns && (
|
||||
<span className="text-[9px] text-muted-foreground">
|
||||
{hasActiveColumns ? `${displayColumns.length}/${totalCount}` : totalCount}개 컬럼
|
||||
</span>
|
||||
)}
|
||||
{/* 푸터: cols + PK/FK 카운트 */}
|
||||
<div className="flex items-center justify-between border-t border-border/40 dark:border-border/10 px-3.5 py-1.5 bg-background dark:bg-background/50">
|
||||
<span className="text-[9px] text-muted-foreground/70 dark:text-muted-foreground/40 font-mono tracking-[-0.3px]">
|
||||
{hasActiveColumns ? `${displayColumns.length}/${totalCount}` : totalCount} cols
|
||||
</span>
|
||||
<div className="flex gap-2.5 text-[9px] font-mono tracking-[-0.3px]">
|
||||
{columns?.some(c => c.isPrimaryKey) && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-1 h-1 rounded-full bg-amber-400" />
|
||||
<span className="text-muted-foreground/70 dark:text-muted-foreground/40">PK {columns.filter(c => c.isPrimaryKey).length}</span>
|
||||
</span>
|
||||
)}
|
||||
{columns?.some(c => c.isForeignKey) && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-1 h-1 rounded-full bg-primary" />
|
||||
<span className="text-muted-foreground/70 dark:text-muted-foreground/40">FK {columns.filter(c => c.isForeignKey).length}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CSS 애니메이션 정의 */}
|
||||
|
|
@ -861,10 +808,10 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
|||
export const LegacyScreenNode = ScreenNode;
|
||||
export const AggregateNode: React.FC<{ data: any }> = ({ data }) => {
|
||||
return (
|
||||
<div className="rounded-lg border-2 border-purple-300 bg-card 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">
|
||||
<div className="rounded-lg border-2 border-primary/40 bg-card p-3 shadow-lg">
|
||||
<Handle type="target" position={Position.Left} id="left" className="!h-3 !w-3 !bg-primary" />
|
||||
<Handle type="source" position={Position.Right} id="right" className="!h-3 !w-3 !bg-primary" />
|
||||
<div className="flex items-center gap-2 text-primary">
|
||||
<Table2 className="h-4 w-4" />
|
||||
<span className="text-sm font-semibold">{data.label || "Aggregate"}</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import React, { useState, useEffect, useCallback } from "react";
|
|||
import {
|
||||
ReactFlow,
|
||||
Controls,
|
||||
MiniMap,
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Node,
|
||||
|
|
@ -34,22 +35,31 @@ import {
|
|||
import { getTableColumns, ColumnTypeInfo } from "@/lib/api/tableManagement";
|
||||
import { ScreenSettingModal } from "./ScreenSettingModal";
|
||||
import { TableSettingModal } from "./TableSettingModal";
|
||||
import { AnimatedFlowEdge } from "./AnimatedFlowEdge";
|
||||
import { Monitor, Database, FolderOpen } from "lucide-react";
|
||||
|
||||
// 관계 유형별 색상 정의
|
||||
// 관계 유형별 색상 정의 (CSS 변수 기반 - 다크모드 자동 대응)
|
||||
const RELATION_COLORS: Record<VisualRelationType, { stroke: string; strokeLight: string; label: string }> = {
|
||||
filter: { stroke: '#8b5cf6', strokeLight: '#c4b5fd', label: '마스터-디테일' }, // 보라색
|
||||
hierarchy: { stroke: '#06b6d4', strokeLight: '#a5f3fc', label: '계층 구조' }, // 시안색
|
||||
lookup: { stroke: '#f59e0b', strokeLight: '#fcd34d', label: '코드 참조' }, // 주황색 (기존)
|
||||
mapping: { stroke: '#10b981', strokeLight: '#6ee7b7', label: '데이터 매핑' }, // 녹색
|
||||
join: { stroke: '#f97316', strokeLight: '#fdba74', label: '엔티티 조인' }, // orange-500 (기존 주황색)
|
||||
filter: { stroke: 'hsl(var(--primary))', strokeLight: 'hsl(var(--primary) / 0.4)', label: '마스터-디테일' },
|
||||
hierarchy: { stroke: 'hsl(var(--info))', strokeLight: 'hsl(var(--info) / 0.4)', label: '계층 구조' },
|
||||
lookup: { stroke: 'hsl(var(--warning))', strokeLight: 'hsl(var(--warning) / 0.4)', label: '코드 참조' },
|
||||
mapping: { stroke: 'hsl(var(--success))', strokeLight: 'hsl(var(--success) / 0.4)', label: '데이터 매핑' },
|
||||
join: { stroke: 'hsl(var(--warning))', strokeLight: 'hsl(var(--warning) / 0.4)', label: '엔티티 조인' },
|
||||
};
|
||||
|
||||
// 엣지 필터 카테고리 (UI 토글용)
|
||||
type EdgeCategory = 'main' | 'filter' | 'join' | 'lookup' | 'flow';
|
||||
|
||||
// 노드 타입 등록
|
||||
const nodeTypes = {
|
||||
screenNode: ScreenNode,
|
||||
tableNode: TableNode,
|
||||
};
|
||||
|
||||
const edgeTypes = {
|
||||
animatedFlow: AnimatedFlowEdge,
|
||||
};
|
||||
|
||||
// 레이아웃 상수
|
||||
const SCREEN_Y = 50; // 화면 노드 Y 위치 (상단)
|
||||
const TABLE_Y = 420; // 메인 테이블 노드 Y 위치 (중단)
|
||||
|
|
@ -89,6 +99,15 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
// 그룹 내 포커스된 화면 ID (그룹 모드에서만 사용)
|
||||
const [focusedScreenId, setFocusedScreenId] = useState<number | null>(null);
|
||||
|
||||
// 엣지 필터 상태 (유형별 표시/숨김)
|
||||
const [edgeFilterState, setEdgeFilterState] = useState<Record<EdgeCategory, boolean>>({
|
||||
main: true,
|
||||
filter: true,
|
||||
join: true,
|
||||
lookup: false,
|
||||
flow: true,
|
||||
});
|
||||
|
||||
// 노드 설정 모달 상태
|
||||
const [isSettingModalOpen, setIsSettingModalOpen] = useState(false);
|
||||
const [settingModalNode, setSettingModalNode] = useState<{
|
||||
|
|
@ -414,7 +433,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
isFaded = focusedScreenId !== null && !isFocused;
|
||||
} else {
|
||||
// 개별 화면 모드: 메인 화면(선택된 화면)만 포커스, 연결 화면은 흐리게
|
||||
isFocused = isMain;
|
||||
isFocused = !!isMain;
|
||||
isFaded = !isMain && screenList.length > 1;
|
||||
}
|
||||
|
||||
|
|
@ -426,7 +445,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
label: scr.screenName,
|
||||
subLabel: selectedGroup ? `${roleLabel} (#${scr.displayOrder || idx + 1})` : (isMain ? "메인 화면" : "연결 화면"),
|
||||
type: "screen",
|
||||
isMain: selectedGroup ? idx === 0 : isMain,
|
||||
isMain: selectedGroup ? idx === 0 : !!isMain,
|
||||
tableName: scr.tableName,
|
||||
layoutSummary: summary,
|
||||
// 화면 포커스 관련 속성 (그룹 모드 & 개별 모드 공통)
|
||||
|
|
@ -687,14 +706,15 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
target: `screen-${nextScreen.screenId}`,
|
||||
sourceHandle: "right",
|
||||
targetHandle: "left",
|
||||
type: "smoothstep",
|
||||
type: "animatedFlow",
|
||||
label: `${i + 1}`,
|
||||
labelStyle: { fontSize: 11, fill: "#0ea5e9", fontWeight: 600 },
|
||||
labelStyle: { fontSize: 11, fill: "hsl(var(--info))", fontWeight: 600 },
|
||||
labelBgStyle: { fill: "hsl(var(--card))", stroke: "hsl(var(--border))", strokeWidth: 1 },
|
||||
labelBgPadding: [4, 2] as [number, number],
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: "#0ea5e9" },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: "hsl(var(--info))" },
|
||||
animated: true,
|
||||
style: { stroke: "#0ea5e9", strokeWidth: 2 },
|
||||
style: { stroke: "hsl(var(--info))", strokeWidth: 2 },
|
||||
data: { edgeCategory: 'flow' as EdgeCategory },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -709,12 +729,13 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
target: `table-${scr.tableName}`,
|
||||
sourceHandle: "bottom",
|
||||
targetHandle: "top",
|
||||
type: "smoothstep",
|
||||
type: "animatedFlow",
|
||||
animated: true, // 모든 메인 테이블 연결은 애니메이션
|
||||
style: {
|
||||
stroke: "#3b82f6",
|
||||
stroke: "hsl(var(--primary))",
|
||||
strokeWidth: 2,
|
||||
},
|
||||
data: { edgeCategory: 'main' as EdgeCategory },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -748,15 +769,16 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
target: targetNodeId,
|
||||
sourceHandle: "bottom",
|
||||
targetHandle: "top",
|
||||
type: "smoothstep",
|
||||
type: "animatedFlow",
|
||||
animated: true,
|
||||
style: {
|
||||
stroke: "#3b82f6",
|
||||
stroke: "hsl(var(--primary))",
|
||||
strokeWidth: 2,
|
||||
strokeDasharray: "5,5", // 점선으로 필터 관계 표시
|
||||
},
|
||||
data: {
|
||||
sourceScreenId,
|
||||
edgeCategory: 'filter' as EdgeCategory,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -793,7 +815,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
target: refTargetNodeId,
|
||||
sourceHandle: "bottom",
|
||||
targetHandle: "bottom_target",
|
||||
type: "smoothstep",
|
||||
type: "animatedFlow",
|
||||
animated: false,
|
||||
style: {
|
||||
stroke: RELATION_COLORS.join.strokeLight, // 초기값 (연한색)
|
||||
|
|
@ -809,6 +831,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
sourceScreenId,
|
||||
isFilterJoin: true,
|
||||
visualRelationType: 'join',
|
||||
edgeCategory: 'join' as EdgeCategory,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
@ -901,7 +924,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
target: `table-${referencedTable}`, // 참조당하는 테이블
|
||||
sourceHandle: "bottom", // 하단에서 나감 (서브테이블 구간으로)
|
||||
targetHandle: "bottom_target", // 하단으로 들어감
|
||||
type: "smoothstep",
|
||||
type: "animatedFlow",
|
||||
animated: false,
|
||||
style: {
|
||||
stroke: relationColor.strokeLight, // 관계 유형별 연한 색상
|
||||
|
|
@ -919,6 +942,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
referrerTable,
|
||||
referencedTable,
|
||||
visualRelationType, // 관계 유형 저장
|
||||
edgeCategory: (visualRelationType === 'lookup' ? 'lookup' : 'join') as EdgeCategory,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -944,7 +968,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
target: `subtable-${subTable.tableName}`,
|
||||
sourceHandle: "bottom",
|
||||
targetHandle: "top",
|
||||
type: "smoothstep",
|
||||
type: "animatedFlow",
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: relationColor.strokeLight
|
||||
|
|
@ -959,6 +983,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
data: {
|
||||
sourceScreenId,
|
||||
visualRelationType,
|
||||
edgeCategory: (visualRelationType === 'lookup' ? 'lookup' : 'join') as EdgeCategory,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
@ -973,7 +998,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
target: `table-${join.join_table}`,
|
||||
sourceHandle: "bottom",
|
||||
targetHandle: "bottom_target",
|
||||
type: "smoothstep",
|
||||
type: "animatedFlow",
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: RELATION_COLORS.join.strokeLight
|
||||
|
|
@ -985,31 +1010,33 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
strokeDasharray: "8,4",
|
||||
opacity: 0.5,
|
||||
},
|
||||
data: { visualRelationType: 'join' },
|
||||
data: { visualRelationType: 'join', edgeCategory: 'join' as EdgeCategory },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 테이블 관계 엣지 (추가 관계)
|
||||
// 테이블 관계 엣지 (추가 관계) - 참조용 화면(개별 모드: screen, 그룹 모드: screenList[0])
|
||||
const refScreen = screen ?? screenList[0];
|
||||
relations.forEach((rel: any, idx: number) => {
|
||||
if (rel.table_name && rel.table_name !== screen.tableName) {
|
||||
if (rel.table_name && rel.table_name !== refScreen.tableName) {
|
||||
// 화면 → 연결 테이블
|
||||
const edgeExists = newEdges.some(
|
||||
(e) => e.source === `screen-${screen.screenId}` && e.target === `table-${rel.table_name}`
|
||||
(e) => e.source === `screen-${refScreen.screenId}` && e.target === `table-${rel.table_name}`
|
||||
);
|
||||
if (!edgeExists) {
|
||||
newEdges.push({
|
||||
id: `edge-rel-${idx}`,
|
||||
source: `screen-${screen.screenId}`,
|
||||
source: `screen-${refScreen.screenId}`,
|
||||
target: `table-${rel.table_name}`,
|
||||
sourceHandle: "bottom",
|
||||
targetHandle: "top",
|
||||
type: "smoothstep",
|
||||
type: "animatedFlow",
|
||||
label: rel.relation_type === "join" ? "조인" : rel.crud_operations || "",
|
||||
labelStyle: { fontSize: 9, fill: "#10b981" },
|
||||
labelStyle: { fontSize: 9, fill: "hsl(var(--success))" },
|
||||
labelBgStyle: { fill: "hsl(var(--card))", stroke: "hsl(var(--border))", strokeWidth: 1 },
|
||||
labelBgPadding: [3, 2] as [number, number],
|
||||
style: { stroke: "#10b981", strokeWidth: 1.5 },
|
||||
style: { stroke: "hsl(var(--success))", strokeWidth: 1.5 },
|
||||
data: { edgeCategory: (rel.relation_type === 'lookup' ? 'lookup' : 'join') as EdgeCategory },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1017,23 +1044,24 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
|
||||
// 데이터 흐름 엣지 (화면 간)
|
||||
flows
|
||||
.filter((flow: any) => flow.source_screen_id === screen.screenId)
|
||||
.filter((flow: any) => flow.source_screen_id === refScreen.screenId)
|
||||
.forEach((flow: any, idx: number) => {
|
||||
if (flow.target_screen_id) {
|
||||
newEdges.push({
|
||||
id: `edge-flow-${idx}`,
|
||||
source: `screen-${screen.screenId}`,
|
||||
source: `screen-${refScreen.screenId}`,
|
||||
target: `screen-${flow.target_screen_id}`,
|
||||
sourceHandle: "right",
|
||||
targetHandle: "left",
|
||||
type: "smoothstep",
|
||||
type: "animatedFlow",
|
||||
animated: true,
|
||||
label: flow.flow_label || flow.flow_type || "이동",
|
||||
labelStyle: { fontSize: 10, fill: "#8b5cf6", fontWeight: 500 },
|
||||
labelStyle: { fontSize: 10, fill: "hsl(var(--primary))", fontWeight: 500 },
|
||||
labelBgStyle: { fill: "hsl(var(--card))", stroke: "hsl(var(--border))", strokeWidth: 1 },
|
||||
labelBgPadding: [4, 2] as [number, number],
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: "#8b5cf6" },
|
||||
style: { stroke: "#8b5cf6", strokeWidth: 2 },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: "hsl(var(--primary))" },
|
||||
style: { stroke: "hsl(var(--primary))", strokeWidth: 2 },
|
||||
data: { edgeCategory: 'flow' as EdgeCategory },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -1134,7 +1162,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
// 화면 노드 우클릭
|
||||
if (node.id.startsWith("screen-")) {
|
||||
const screenId = parseInt(node.id.replace("screen-", ""));
|
||||
const nodeData = node.data as ScreenNodeData;
|
||||
const nodeData = node.data as unknown as ScreenNodeData;
|
||||
const mainTable = screenTableMap[screenId];
|
||||
|
||||
// 해당 화면의 서브 테이블 (필터 테이블) 정보
|
||||
|
|
@ -1248,7 +1276,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
// 메인 테이블 노드 더블클릭
|
||||
if (node.id.startsWith("table-") && !node.id.startsWith("table-sub-")) {
|
||||
const tableName = node.id.replace("table-", "");
|
||||
const nodeData = node.data as TableNodeData;
|
||||
const nodeData = node.data as unknown as TableNodeData;
|
||||
|
||||
// 이 테이블을 사용하는 화면 찾기
|
||||
const screenId = Object.entries(screenTableMap).find(
|
||||
|
|
@ -1293,7 +1321,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
// 서브 테이블 노드 더블클릭
|
||||
if (node.id.startsWith("subtable-")) {
|
||||
const tableName = node.id.replace("subtable-", "");
|
||||
const nodeData = node.data as TableNodeData;
|
||||
const nodeData = node.data as unknown as TableNodeData;
|
||||
|
||||
// 이 서브 테이블을 사용하는 화면 찾기
|
||||
const screenId = Object.entries(screenSubTableMap).find(
|
||||
|
|
@ -1460,6 +1488,32 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
});
|
||||
}
|
||||
|
||||
// lookup 필터 OFF일 때: lookup 연결만 있는 테이블 노드를 dim 처리
|
||||
const lookupOnlyNodes = new Set<string>();
|
||||
if (!edgeFilterState.lookup) {
|
||||
const nodeEdgeCategories = new Map<string, Set<EdgeCategory>>();
|
||||
edges.forEach((edge) => {
|
||||
const category = (edge.data as any)?.edgeCategory as EdgeCategory | undefined;
|
||||
if (!category) return;
|
||||
[edge.source, edge.target].forEach((nodeId) => {
|
||||
if (!nodeEdgeCategories.has(nodeId)) {
|
||||
nodeEdgeCategories.set(nodeId, new Set());
|
||||
}
|
||||
nodeEdgeCategories.get(nodeId)!.add(category);
|
||||
});
|
||||
});
|
||||
nodeEdgeCategories.forEach((categories, nodeId) => {
|
||||
if (nodeId.startsWith("table-") || nodeId.startsWith("subtable-")) {
|
||||
const hasVisibleCategory = Array.from(categories).some(
|
||||
(cat) => cat !== "lookup" && edgeFilterState[cat]
|
||||
);
|
||||
if (!hasVisibleCategory) {
|
||||
lookupOnlyNodes.add(nodeId);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return nodes.map((node) => {
|
||||
// 화면 노드 스타일링 (포커스가 있을 때만)
|
||||
if (node.id.startsWith("screen-")) {
|
||||
|
|
@ -1755,7 +1809,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
...node.data,
|
||||
isFocused: isFocusedTable,
|
||||
isRelated: isRelatedTable,
|
||||
isFaded: focusedScreenId !== null && !isActiveTable,
|
||||
isFaded: (focusedScreenId !== null && !isActiveTable) || lookupOnlyNodes.has(node.id),
|
||||
highlightedColumns: isActiveTable ? highlightedColumns : [],
|
||||
joinColumns: isActiveTable ? joinColumns : [],
|
||||
joinColumnRefs: focusedJoinColumnRefs.length > 0 ? focusedJoinColumnRefs : undefined, // 조인 컬럼 참조 정보
|
||||
|
|
@ -1798,12 +1852,6 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 디버깅 로그
|
||||
console.log(`서브테이블 ${subTableName} (${subTableInfo?.relationType}):`, {
|
||||
fieldMappings: subTableInfo?.fieldMappings,
|
||||
extractedJoinColumns: subTableJoinColumns
|
||||
});
|
||||
}
|
||||
|
||||
// 서브 테이블의 highlightedColumns도 추가 (화면에서 서브테이블 컬럼을 직접 사용하는 경우)
|
||||
|
|
@ -1872,7 +1920,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
data: {
|
||||
...node.data,
|
||||
isFocused: isActiveSubTable,
|
||||
isFaded: !isActiveSubTable,
|
||||
isFaded: !isActiveSubTable || lookupOnlyNodes.has(node.id),
|
||||
highlightedColumns: isActiveSubTable ? subTableHighlightedColumns : [],
|
||||
joinColumns: isActiveSubTable ? subTableJoinColumns : [],
|
||||
fieldMappings: isActiveSubTable ? displayFieldMappings : [],
|
||||
|
|
@ -1883,7 +1931,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
|
||||
return node;
|
||||
});
|
||||
}, [nodes, selectedGroup, focusedScreenId, screenSubTableMap, subTablesDataMap, screenUsedColumnsMap, screenTableMap, tableColumns]);
|
||||
}, [nodes, selectedGroup, focusedScreenId, screenSubTableMap, subTablesDataMap, screenUsedColumnsMap, screenTableMap, tableColumns, edgeFilterState, edges]);
|
||||
|
||||
// 포커스에 따른 엣지 스타일링 (그룹 모드 & 개별 화면 모드)
|
||||
const styledEdges = React.useMemo(() => {
|
||||
|
|
@ -1903,9 +1951,9 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
animated: isConnected,
|
||||
style: {
|
||||
...edge.style,
|
||||
stroke: isConnected ? "#8b5cf6" : "#d1d5db",
|
||||
strokeWidth: isConnected ? 2 : 1,
|
||||
opacity: isConnected ? 1 : 0.3,
|
||||
stroke: isConnected ? "hsl(var(--primary))" : "hsl(var(--border))",
|
||||
strokeWidth: isConnected ? 2.5 : 1,
|
||||
opacity: isConnected ? 1 : 0.2,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1920,10 +1968,10 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
animated: isMyConnection,
|
||||
style: {
|
||||
...edge.style,
|
||||
stroke: isMyConnection ? "#3b82f6" : "#d1d5db",
|
||||
strokeWidth: isMyConnection ? 2 : 1,
|
||||
stroke: isMyConnection ? "hsl(var(--primary))" : "hsl(var(--border))",
|
||||
strokeWidth: isMyConnection ? 2.5 : 1,
|
||||
strokeDasharray: isMyConnection ? undefined : "5,5",
|
||||
opacity: isMyConnection ? 1 : 0.3,
|
||||
opacity: isMyConnection ? 1 : 0.2,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1998,11 +2046,11 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
target: targetNodeId,
|
||||
sourceHandle: 'bottom', // 고정: 서브테이블 구간 통과
|
||||
targetHandle: 'bottom_target', // 고정: 서브테이블 구간 통과
|
||||
type: 'smoothstep',
|
||||
type: "animatedFlow",
|
||||
animated: true,
|
||||
style: {
|
||||
stroke: relationColor.stroke, // 관계 유형별 색상
|
||||
strokeWidth: 2,
|
||||
strokeWidth: 2.5,
|
||||
strokeDasharray: '8,4',
|
||||
},
|
||||
markerEnd: {
|
||||
|
|
@ -2040,9 +2088,9 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
animated: isConnected,
|
||||
style: {
|
||||
...edge.style,
|
||||
stroke: isConnected ? "#8b5cf6" : "#d1d5db",
|
||||
strokeWidth: isConnected ? 2 : 1,
|
||||
opacity: isConnected ? 1 : 0.3,
|
||||
stroke: isConnected ? "hsl(var(--primary))" : "hsl(var(--border))",
|
||||
strokeWidth: isConnected ? 2.5 : 1,
|
||||
opacity: isConnected ? 1 : 0.2,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -2076,8 +2124,8 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
animated: true,
|
||||
style: {
|
||||
...edge.style,
|
||||
stroke: "#3b82f6",
|
||||
strokeWidth: 2,
|
||||
stroke: "hsl(var(--primary))",
|
||||
strokeWidth: 2.5,
|
||||
strokeDasharray: "5,5",
|
||||
opacity: 1,
|
||||
},
|
||||
|
|
@ -2095,10 +2143,10 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
animated: isMyConnection,
|
||||
style: {
|
||||
...edge.style,
|
||||
stroke: isMyConnection ? "#3b82f6" : "#d1d5db",
|
||||
strokeWidth: isMyConnection ? 2 : 1,
|
||||
stroke: isMyConnection ? "hsl(var(--primary))" : "hsl(var(--border))",
|
||||
strokeWidth: isMyConnection ? 2.5 : 1,
|
||||
strokeDasharray: isMyConnection ? undefined : "5,5",
|
||||
opacity: isMyConnection ? 1 : 0.3,
|
||||
opacity: isMyConnection ? 1 : 0.2,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -2155,7 +2203,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
stroke: isActive ? relationColor.stroke : relationColor.strokeLight,
|
||||
strokeWidth: isActive ? 2.5 : 1.5,
|
||||
strokeDasharray: "8,4",
|
||||
opacity: isActive ? 1 : 0.3,
|
||||
opacity: isActive ? 1 : 0.2,
|
||||
},
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
|
|
@ -2179,7 +2227,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
stroke: RELATION_COLORS.join.strokeLight,
|
||||
strokeWidth: 1.5,
|
||||
strokeDasharray: "6,4",
|
||||
opacity: 0.3,
|
||||
opacity: 0.2,
|
||||
},
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
|
|
@ -2206,7 +2254,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
style: {
|
||||
...edge.style,
|
||||
stroke: RELATION_COLORS.join.stroke,
|
||||
strokeWidth: 2,
|
||||
strokeWidth: 2.5,
|
||||
strokeDasharray: "6,4",
|
||||
opacity: 1,
|
||||
},
|
||||
|
|
@ -2282,8 +2330,19 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
});
|
||||
|
||||
// 기존 엣지 + 조인 관계 엣지 합치기
|
||||
return [...styledOriginalEdges, ...joinEdges];
|
||||
}, [edges, nodes, selectedGroup, focusedScreenId, screen, screenSubTableMap, subTablesDataMap, screenTableMap]);
|
||||
const allEdges = [...styledOriginalEdges, ...joinEdges];
|
||||
// 엣지 필터 적용 (edgeFilterState에 따라 숨김)
|
||||
return allEdges.map((edge) => {
|
||||
const category = (edge.data as any)?.edgeCategory as EdgeCategory | undefined;
|
||||
if (category && !edgeFilterState[category]) {
|
||||
return {
|
||||
...edge,
|
||||
hidden: true,
|
||||
};
|
||||
}
|
||||
return edge;
|
||||
});
|
||||
}, [edges, nodes, selectedGroup, focusedScreenId, screen, screenSubTableMap, subTablesDataMap, screenTableMap, edgeFilterState]);
|
||||
|
||||
// 그룹의 화면 목록 (데이터 흐름 설정용) - 모든 조건부 return 전에 선언해야 함
|
||||
const groupScreensList = React.useMemo(() => {
|
||||
|
|
@ -2300,10 +2359,38 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
// 조건부 렌더링 (모든 훅 선언 후에 위치해야 함)
|
||||
if (!screen && !selectedGroup) {
|
||||
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 className="flex h-full flex-col items-center justify-center gap-6 p-8">
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-4 opacity-30">
|
||||
<div className="h-16 w-24 rounded-lg border-2 border-dashed border-primary/40 flex items-center justify-center">
|
||||
<Monitor className="h-6 w-6 text-primary/60" />
|
||||
</div>
|
||||
<div className="h-px w-12 border-t-2 border-dashed border-border" />
|
||||
<div className="h-12 w-20 rounded-lg border-2 border-dashed border-info/40 flex items-center justify-center">
|
||||
<Database className="h-5 w-5 text-info/60" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center max-w-sm">
|
||||
<h3 className="text-lg font-semibold mb-2">화면 관계 시각화</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
좌측에서 그룹 또는 화면을 선택하면<br/>
|
||||
테이블 관계가 자동으로 시각화됩니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-8 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary/10 text-primary text-[10px] font-bold">1</span>
|
||||
<span>그룹 선택</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary/10 text-primary text-[10px] font-bold">2</span>
|
||||
<span>관계 확인</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary/10 text-primary text-[10px] font-bold">3</span>
|
||||
<span>화면 편집</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -2318,10 +2405,60 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="relative h-full w-full">
|
||||
{/* 선택 정보 바 (캔버스 상단) */}
|
||||
{(screen || selectedGroup) && (
|
||||
<div className="absolute top-0 left-0 right-0 z-10 flex items-center gap-3 border-b bg-card dark:bg-card/80 backdrop-blur-sm px-4 py-2">
|
||||
{selectedGroup && (
|
||||
<>
|
||||
<FolderOpen className="h-4 w-4 text-warning" />
|
||||
<span className="text-sm font-medium">{selectedGroup.name}</span>
|
||||
</>
|
||||
)}
|
||||
{screen && !selectedGroup && (
|
||||
<>
|
||||
<Monitor className="h-4 w-4 text-primary" />
|
||||
<span className="text-sm font-medium">{screen.screenName}</span>
|
||||
<span className="text-xs text-muted-foreground/80 dark:text-muted-foreground/50 font-mono">{screen.screenCode}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="h-4 w-px bg-border/50 dark:bg-border/30 mx-1" />
|
||||
<span className="text-[10px] font-medium text-muted-foreground/80 dark:text-muted-foreground/50">연결</span>
|
||||
|
||||
{(
|
||||
[
|
||||
{ key: "main" as EdgeCategory, label: "메인", color: "bg-primary", defaultOn: true },
|
||||
{ key: "filter" as EdgeCategory, label: "마스터-디테일", color: "bg-[hsl(var(--info))]", defaultOn: true },
|
||||
{ key: "join" as EdgeCategory, label: "엔티티 조인", color: "bg-amber-400", defaultOn: true },
|
||||
{ key: "lookup" as EdgeCategory, label: "코드 참조", color: "bg-warning", defaultOn: false },
|
||||
] as const
|
||||
).map(({ key, label, color, defaultOn }) => {
|
||||
const isOn = edgeFilterState[key];
|
||||
const count = edges.filter((e) => (e.data as any)?.edgeCategory === key).length;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => setEdgeFilterState((prev) => ({ ...prev, [key]: !prev[key] }))}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-medium transition-all duration-200 ${
|
||||
isOn
|
||||
? "bg-foreground/[0.08] dark:bg-foreground/5 border border-border/40 dark:border-border/20 text-foreground/80"
|
||||
: `border text-muted-foreground/70 dark:text-muted-foreground/40 ${!defaultOn ? "border-dashed border-border/40 dark:border-border/20" : "border-border/40 dark:border-border/10"}`
|
||||
}`}
|
||||
>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${color} transition-opacity ${isOn ? "opacity-100 shadow-sm" : "opacity-50 dark:opacity-30"}`} />
|
||||
{label}
|
||||
<span className="text-[9px] text-muted-foreground/70 dark:text-muted-foreground/40 font-mono">{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* isViewReady가 false면 숨김 처리하여 깜빡임 방지 */}
|
||||
<div className={`h-full w-full transition-opacity duration-0 ${isViewReady ? "opacity-100" : "opacity-0"}`}>
|
||||
<ReactFlow
|
||||
className="[&_.react-flow__node]:transition-all [&_.react-flow__node]:duration-300"
|
||||
nodes={styledNodes}
|
||||
edges={styledEdges}
|
||||
onNodesChange={onNodesChange}
|
||||
|
|
@ -2329,12 +2466,42 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
onNodeClick={handleNodeClick}
|
||||
onNodeContextMenu={handleNodeContextMenu}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
minZoom={0.3}
|
||||
maxZoom={1.5}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="hsl(var(--border))" />
|
||||
<Controls position="bottom-right" />
|
||||
<svg style={{ position: "absolute", width: 0, height: 0 }}>
|
||||
<defs>
|
||||
<filter id="edge-glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
<Background id="bg-dots" variant={BackgroundVariant.Dots} gap={16} size={0.5} color="hsl(var(--border) / 0.3)" />
|
||||
<Background id="bg-lines" variant={BackgroundVariant.Lines} gap={120} color="hsl(var(--border) / 0.08)" />
|
||||
<Controls position="top-right" />
|
||||
<MiniMap
|
||||
position="bottom-right"
|
||||
nodeColor={(node) => {
|
||||
if (node.type === "screenNode") return "hsl(var(--primary))";
|
||||
if (node.type === "tableNode") return "hsl(var(--warning))";
|
||||
return "hsl(var(--muted-foreground))";
|
||||
}}
|
||||
nodeStrokeWidth={2}
|
||||
zoomable
|
||||
pannable
|
||||
style={{
|
||||
background: "hsl(var(--card) / 0.8)",
|
||||
border: "1px solid hsl(var(--border) / 0.5)",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
/>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
|
|
@ -2353,7 +2520,6 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
fieldMappings={settingModalNode.existingConfig?.fieldMappings}
|
||||
componentCount={0}
|
||||
onSaveSuccess={handleRefreshVisualization}
|
||||
isPop={isPop}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -2367,7 +2533,13 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
screenId={settingModalNode.screenId}
|
||||
joinColumnRefs={settingModalNode.existingConfig?.joinColumnRefs}
|
||||
referencedBy={settingModalNode.existingConfig?.referencedBy}
|
||||
columns={settingModalNode.existingConfig?.columns}
|
||||
columns={settingModalNode.existingConfig?.columns?.map((col) => ({
|
||||
column: col.originalName ?? col.name,
|
||||
label: col.name,
|
||||
type: col.type,
|
||||
isPK: col.isPrimaryKey,
|
||||
isFK: col.isForeignKey,
|
||||
}))}
|
||||
filterColumns={settingModalNode.existingConfig?.filterColumns}
|
||||
onSaveSuccess={handleRefreshVisualization}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent
|
|||
|
||||
// ComponentRegistry import (동적 ConfigPanel 가져오기용)
|
||||
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
|
||||
import { columnMetaCache } from "@/lib/registry/DynamicComponentRenderer";
|
||||
import { DynamicComponentConfigPanel, hasComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel";
|
||||
import StyleEditor from "../StyleEditor";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
|
|
@ -207,28 +208,36 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
|||
onUpdateProperty(selectedComponent.id, "componentConfig", { ...currentConfig, ...newConfig });
|
||||
};
|
||||
|
||||
// 컬럼의 inputType 가져오기 (entity 타입인지 확인용)
|
||||
const inputType = currentConfig.inputType || currentConfig.webType || (selectedComponent as any).inputType;
|
||||
|
||||
// 현재 화면의 테이블명 가져오기
|
||||
const currentTableName = tables?.[0]?.tableName;
|
||||
|
||||
// DB input_type 가져오기 (columnMetaCache에서 최신값 조회)
|
||||
const colName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName;
|
||||
const tblName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
|
||||
const dbMeta = colName && tblName && !colName.includes(".") ? columnMetaCache[tblName]?.[colName] : undefined;
|
||||
const dbInputType = dbMeta ? (() => { const raw = dbMeta.input_type || dbMeta.inputType; return raw === "direct" || raw === "auto" ? undefined : raw; })() : undefined;
|
||||
const inputType = dbInputType || currentConfig.inputType || currentConfig.webType || (selectedComponent as any).inputType;
|
||||
|
||||
// 컴포넌트별 추가 props
|
||||
const extraProps: Record<string, any> = {};
|
||||
if (componentId === "v2-select") {
|
||||
const resolvedTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
|
||||
const resolvedColumnName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName;
|
||||
|
||||
if (componentId === "v2-input" || componentId === "v2-select") {
|
||||
extraProps.inputType = inputType;
|
||||
extraProps.tableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
|
||||
extraProps.columnName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName;
|
||||
extraProps.tableName = resolvedTableName;
|
||||
extraProps.columnName = resolvedColumnName;
|
||||
extraProps.screenTableName = resolvedTableName;
|
||||
}
|
||||
if (componentId === "v2-input") {
|
||||
extraProps.allComponents = allComponents;
|
||||
}
|
||||
if (componentId === "v2-list") {
|
||||
extraProps.currentTableName = currentTableName;
|
||||
}
|
||||
if (componentId === "v2-bom-item-editor" || componentId === "v2-bom-tree") {
|
||||
extraProps.currentTableName = currentTableName;
|
||||
extraProps.screenTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
|
||||
}
|
||||
if (componentId === "v2-input") {
|
||||
extraProps.allComponents = allComponents;
|
||||
extraProps.screenTableName = resolvedTableName;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -78,7 +78,15 @@ interface CategoryValueOption {
|
|||
}
|
||||
|
||||
// ─── 하위 호환: 기존 config에서 fieldType 추론 ───
|
||||
function resolveFieldType(config: Record<string, any>, componentType?: string): FieldType {
|
||||
function resolveFieldType(config: Record<string, any>, componentType?: string, metaInputType?: string): FieldType {
|
||||
// DB input_type이 전달된 경우 (데이터타입관리에서 변경 시) 우선 적용
|
||||
if (metaInputType && metaInputType !== "direct" && metaInputType !== "auto") {
|
||||
const dbType = metaInputType as FieldType;
|
||||
if (["text", "number", "textarea", "numbering", "select", "category", "entity"].includes(dbType)) {
|
||||
return dbType;
|
||||
}
|
||||
}
|
||||
|
||||
if (config.fieldType) return config.fieldType as FieldType;
|
||||
|
||||
// v2-select 계열
|
||||
|
|
@ -207,7 +215,7 @@ export const V2FieldConfigPanel: React.FC<V2FieldConfigPanelProps> = ({
|
|||
inputType: metaInputType,
|
||||
componentType,
|
||||
}) => {
|
||||
const fieldType = resolveFieldType(config, componentType);
|
||||
const fieldType = resolveFieldType(config, componentType, metaInputType);
|
||||
const isSelectGroup = ["select", "category", "entity"].includes(fieldType);
|
||||
|
||||
// ─── 채번 관련 상태 (테이블 기반) ───
|
||||
|
|
|
|||
|
|
@ -13,13 +13,34 @@ import { apiClient } from "@/lib/api/client";
|
|||
import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor";
|
||||
|
||||
// 컬럼 메타데이터 캐시 (테이블명 → 컬럼 설정 맵)
|
||||
const columnMetaCache: Record<string, Record<string, any>> = {};
|
||||
export const columnMetaCache: Record<string, Record<string, any>> = {};
|
||||
const columnMetaLoading: Record<string, Promise<void>> = {};
|
||||
const columnMetaTimestamp: Record<string, number> = {};
|
||||
const CACHE_TTL_MS = 5000;
|
||||
|
||||
async function loadColumnMeta(tableName: string): Promise<void> {
|
||||
if (columnMetaCache[tableName]) return;
|
||||
export function invalidateColumnMetaCache(tableName?: string): void {
|
||||
if (tableName) {
|
||||
delete columnMetaCache[tableName];
|
||||
delete columnMetaLoading[tableName];
|
||||
delete columnMetaTimestamp[tableName];
|
||||
} else {
|
||||
for (const key of Object.keys(columnMetaCache)) delete columnMetaCache[key];
|
||||
for (const key of Object.keys(columnMetaLoading)) delete columnMetaLoading[key];
|
||||
for (const key of Object.keys(columnMetaTimestamp)) delete columnMetaTimestamp[key];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadColumnMeta(tableName: string, forceReload = false): Promise<void> {
|
||||
const now = Date.now();
|
||||
const isStale = columnMetaTimestamp[tableName] && (now - columnMetaTimestamp[tableName] > CACHE_TTL_MS);
|
||||
|
||||
if (!forceReload && !isStale && columnMetaCache[tableName]) return;
|
||||
|
||||
if (forceReload || isStale) {
|
||||
delete columnMetaCache[tableName];
|
||||
delete columnMetaLoading[tableName];
|
||||
}
|
||||
|
||||
// 이미 로딩 중이면 해당 Promise를 대기 (race condition 방지)
|
||||
if (columnMetaLoading[tableName]) {
|
||||
await columnMetaLoading[tableName];
|
||||
return;
|
||||
|
|
@ -36,6 +57,7 @@ async function loadColumnMeta(tableName: string): Promise<void> {
|
|||
if (name) map[name] = col;
|
||||
}
|
||||
columnMetaCache[tableName] = map;
|
||||
columnMetaTimestamp[tableName] = Date.now();
|
||||
} catch (e) {
|
||||
console.error(`[columnMeta] ${tableName} 로드 실패:`, e);
|
||||
columnMetaCache[tableName] = {};
|
||||
|
|
@ -56,43 +78,59 @@ export function isColumnRequiredByMeta(tableName?: string, columnName?: string):
|
|||
return nullable === "NO" || nullable === "N";
|
||||
}
|
||||
|
||||
// table_type_columns 기반 componentConfig 병합 (기존 설정이 없을 때만 DB 메타데이터로 보완)
|
||||
// table_type_columns 기반 componentConfig 병합 (DB input_type 우선 적용)
|
||||
function mergeColumnMeta(tableName: string | undefined, columnName: string | undefined, componentConfig: any): any {
|
||||
if (!tableName || !columnName) return componentConfig;
|
||||
|
||||
const meta = columnMetaCache[tableName]?.[columnName];
|
||||
if (!meta) return componentConfig;
|
||||
|
||||
const inputType = meta.input_type || meta.inputType;
|
||||
if (!inputType) return componentConfig;
|
||||
|
||||
// 이미 source가 올바르게 설정된 경우 건드리지 않음
|
||||
const existingSource = componentConfig?.source;
|
||||
if (existingSource && existingSource !== "static" && existingSource !== "distinct" && existingSource !== "select") {
|
||||
return componentConfig;
|
||||
}
|
||||
const rawType = meta.input_type || meta.inputType;
|
||||
const dbInputType = rawType === "direct" || rawType === "auto" ? undefined : rawType;
|
||||
if (!dbInputType) return componentConfig;
|
||||
|
||||
const merged = { ...componentConfig };
|
||||
const savedFieldType = merged.fieldType;
|
||||
|
||||
// source가 미설정/기본값일 때만 DB 메타데이터로 보완
|
||||
if (inputType === "entity") {
|
||||
// savedFieldType이 있고 DB와 같으면 변경 불필요
|
||||
if (savedFieldType && savedFieldType === dbInputType) return merged;
|
||||
// savedFieldType이 있고 DB와 다르면 — 사용자가 V2FieldConfigPanel에서 설정한 값 존중
|
||||
if (savedFieldType) return merged;
|
||||
|
||||
// savedFieldType이 없으면: DB input_type 기준으로 동기화
|
||||
// 기존 overrides의 source/inputType이 DB와 불일치하면 덮어씀
|
||||
if (dbInputType === "entity") {
|
||||
const refTable = meta.reference_table || meta.referenceTable;
|
||||
const refColumn = meta.reference_column || meta.referenceColumn;
|
||||
const displayCol = meta.display_column || meta.displayColumn;
|
||||
if (refTable && !merged.entityTable) {
|
||||
if (refTable) {
|
||||
merged.source = "entity";
|
||||
merged.entityTable = refTable;
|
||||
merged.entityValueColumn = refColumn || "id";
|
||||
merged.entityLabelColumn = displayCol || "name";
|
||||
merged.fieldType = "entity";
|
||||
merged.inputType = "entity";
|
||||
}
|
||||
} else if (inputType === "category" && !existingSource) {
|
||||
} else if (dbInputType === "category") {
|
||||
merged.source = "category";
|
||||
} else if (inputType === "select" && !existingSource) {
|
||||
merged.fieldType = "category";
|
||||
merged.inputType = "category";
|
||||
} else if (dbInputType === "select") {
|
||||
if (!merged.source || merged.source === "category" || merged.source === "entity") {
|
||||
merged.source = "static";
|
||||
}
|
||||
const detail =
|
||||
typeof meta.detail_settings === "string" ? JSON.parse(meta.detail_settings || "{}") : meta.detail_settings || {};
|
||||
if (detail.options && !merged.options?.length) {
|
||||
merged.options = detail.options;
|
||||
}
|
||||
merged.fieldType = "select";
|
||||
merged.inputType = "select";
|
||||
} else {
|
||||
// text, number, textarea 등 input 계열 — 카테고리 잔류 속성 제거
|
||||
merged.fieldType = dbInputType;
|
||||
merged.inputType = dbInputType;
|
||||
delete merged.source;
|
||||
}
|
||||
|
||||
return merged;
|
||||
|
|
@ -266,15 +304,27 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
children,
|
||||
...props
|
||||
}) => {
|
||||
// 컬럼 메타데이터 로드 트리거 (테이블명이 있으면 비동기 로드)
|
||||
// 컬럼 메타데이터 로드 트리거 (TTL 기반 자동 갱신)
|
||||
const screenTableName = props.tableName || (component as any).tableName;
|
||||
const [, forceUpdate] = React.useState(0);
|
||||
const [metaVersion, forceUpdate] = React.useState(0);
|
||||
React.useEffect(() => {
|
||||
if (screenTableName) {
|
||||
loadColumnMeta(screenTableName).then(() => forceUpdate((v) => v + 1));
|
||||
}
|
||||
}, [screenTableName]);
|
||||
|
||||
// table-columns-refresh 이벤트 수신 시 캐시 무효화 후 최신 메타 다시 로드
|
||||
React.useEffect(() => {
|
||||
const handler = () => {
|
||||
if (screenTableName) {
|
||||
invalidateColumnMetaCache(screenTableName);
|
||||
loadColumnMeta(screenTableName, true).then(() => forceUpdate((v) => v + 1));
|
||||
}
|
||||
};
|
||||
window.addEventListener("table-columns-refresh", handler);
|
||||
return () => window.removeEventListener("table-columns-refresh", handler);
|
||||
}, [screenTableName]);
|
||||
|
||||
// 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 type 사용
|
||||
// 🆕 V2 레이아웃의 경우 url에서 컴포넌트 타입 추출 (예: "@/lib/registry/components/v2-input" → "v2-input")
|
||||
const extractTypeFromUrl = (url: string | undefined): string | undefined => {
|
||||
|
|
@ -306,12 +356,40 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
|
||||
const mappedComponentType = mapToV2ComponentType(rawComponentType);
|
||||
|
||||
// fieldType 기반 동적 컴포넌트 전환 (통합 필드 설정 패널에서 설정된 값)
|
||||
// fieldType 기반 동적 컴포넌트 전환 (사용자 설정 > DB input_type > 기본값)
|
||||
const componentType = (() => {
|
||||
const ft = (component as any).componentConfig?.fieldType;
|
||||
if (!ft) return mappedComponentType;
|
||||
if (["text", "number", "password", "textarea", "slider", "color", "numbering"].includes(ft)) return "v2-input";
|
||||
if (["select", "category", "entity"].includes(ft)) return "v2-select";
|
||||
const configFieldType = (component as any).componentConfig?.fieldType;
|
||||
const fieldName = (component as any).columnName || (component as any).componentConfig?.fieldKey || (component as any).componentConfig?.columnName;
|
||||
const isEntityJoin = fieldName?.includes(".");
|
||||
const baseCol = isEntityJoin ? undefined : fieldName;
|
||||
const rawDbType = baseCol && screenTableName
|
||||
? (columnMetaCache[screenTableName]?.[baseCol]?.input_type || columnMetaCache[screenTableName]?.[baseCol]?.inputType)
|
||||
: undefined;
|
||||
const dbInputType = rawDbType === "direct" || rawDbType === "auto" ? undefined : rawDbType;
|
||||
|
||||
// 디버그 (division, unit 필드만) - 문제 확인 후 제거
|
||||
if (baseCol && (baseCol === "division" || baseCol === "unit")) {
|
||||
const result = configFieldType
|
||||
? (["text","number","password","textarea","slider","color","numbering"].includes(configFieldType) ? "v2-input" : "v2-select")
|
||||
: dbInputType
|
||||
? (["text","number","password","textarea","slider","color","numbering"].includes(dbInputType) ? "v2-input" : "v2-select")
|
||||
: mappedComponentType;
|
||||
const skipCat = dbInputType && !["category", "entity", "select"].includes(dbInputType);
|
||||
console.log(`[DCR] ${baseCol}: dbInputType=${dbInputType}, RESULT=${result}, skipCat=${skipCat}`);
|
||||
}
|
||||
|
||||
// 사용자가 V2FieldConfigPanel에서 명시적으로 설정한 fieldType 최우선
|
||||
if (configFieldType) {
|
||||
if (["text", "number", "password", "textarea", "slider", "color", "numbering"].includes(configFieldType)) return "v2-input";
|
||||
if (["select", "category", "entity"].includes(configFieldType)) return "v2-select";
|
||||
}
|
||||
|
||||
// componentConfig.fieldType 없으면 DB input_type 참조 (초기 로드 시)
|
||||
if (dbInputType) {
|
||||
if (["text", "number", "password", "textarea", "slider", "color", "numbering"].includes(dbInputType)) return "v2-input";
|
||||
if (["select", "category", "entity"].includes(dbInputType)) return "v2-select";
|
||||
}
|
||||
|
||||
return mappedComponentType;
|
||||
})();
|
||||
|
||||
|
|
@ -376,15 +454,24 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
// (v2-input, v2-select, v2-repeat-container 등 모두 동일하게 처리)
|
||||
|
||||
// 🎯 카테고리 타입 우선 처리 (inputType 또는 webType 확인)
|
||||
const inputType = (component as any).componentConfig?.inputType || (component as any).inputType;
|
||||
// DB input_type이 "text" 등 비-카테고리로 변경된 경우 이 분기를 건너뜀
|
||||
const savedInputType = (component as any).componentConfig?.inputType || (component as any).inputType;
|
||||
const webType = (component as any).componentConfig?.webType;
|
||||
const tableName = (component as any).tableName;
|
||||
const columnName = (component as any).columnName;
|
||||
|
||||
// 카테고리 셀렉트: webType이 "category"이고 tableName과 columnName이 있는 경우만
|
||||
// ⚠️ 단, 다음 경우는 V2SelectRenderer로 직접 처리 (고급 모드 지원):
|
||||
// 1. componentType이 "select-basic" 또는 "v2-select"인 경우
|
||||
// 2. config.mode가 dropdown이 아닌 경우 (radio, check, tagbox 등)
|
||||
// DB input_type 확인: 데이터타입관리에서 변경한 최신 값이 레이아웃 저장값보다 우선
|
||||
const dbMetaForField = columnName && screenTableName && !columnName.includes(".")
|
||||
? columnMetaCache[screenTableName]?.[columnName]
|
||||
: undefined;
|
||||
const dbFieldInputType = dbMetaForField
|
||||
? (() => { const raw = dbMetaForField.input_type || dbMetaForField.inputType; return raw === "direct" || raw === "auto" ? undefined : raw; })()
|
||||
: undefined;
|
||||
// DB에서 확인된 타입이 있으면 그걸 사용, 없으면 저장된 값 사용
|
||||
const inputType = dbFieldInputType || savedInputType;
|
||||
// webType도 DB 값으로 대체 (레이아웃에 webType: "category" 하드코딩되어 있을 수 있음)
|
||||
const effectiveWebType = dbFieldInputType || webType;
|
||||
|
||||
const componentMode = (component as any).componentConfig?.mode || (component as any).config?.mode;
|
||||
const isMultipleSelect = (component as any).componentConfig?.multiple;
|
||||
const nonDropdownModes = ["radio", "check", "checkbox", "tag", "tagbox", "toggle", "swap", "combobox"];
|
||||
|
|
@ -392,7 +479,11 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
const shouldUseV2Select =
|
||||
componentType === "select-basic" || componentType === "v2-select" || isNonDropdownMode || isMultipleSelect;
|
||||
|
||||
if ((inputType === "category" || webType === "category") && tableName && columnName && shouldUseV2Select) {
|
||||
// DB input_type이 비-카테고리(text 등)로 확인된 경우, 레이아웃에 category가 남아있어도 카테고리 분기 강제 스킵
|
||||
// dbFieldInputType이 있으면(캐시 로드됨) 그 값으로 판단, 없으면 기존 로직 유지
|
||||
const isDbConfirmedNonCategory = dbFieldInputType && !["category", "entity", "select"].includes(dbFieldInputType);
|
||||
|
||||
if (!isDbConfirmedNonCategory && (inputType === "category" || effectiveWebType === "category") && tableName && columnName && shouldUseV2Select) {
|
||||
// V2SelectRenderer로 직접 렌더링 (카테고리 + 고급 모드)
|
||||
try {
|
||||
const { V2SelectRenderer } = require("@/lib/registry/components/v2-select/V2SelectRenderer");
|
||||
|
|
@ -491,7 +582,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
} catch (error) {
|
||||
console.error("❌ V2SelectRenderer 로드 실패:", error);
|
||||
}
|
||||
} else if ((inputType === "category" || webType === "category") && tableName && columnName) {
|
||||
} else if (!isDbConfirmedNonCategory && (inputType === "category" || effectiveWebType === "category") && tableName && columnName) {
|
||||
try {
|
||||
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
|
||||
const fieldName = columnName || component.id;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,165 @@
|
|||
# Screen 149 필드 타입 검증 가이드
|
||||
|
||||
## 배경
|
||||
- **화면 149**: 품목정보 (item_info 테이블) 폼
|
||||
- **division 컬럼**: DB에서 `input_type = 'text'`로 변경했으나 화면에는 여전히 SELECT 드롭다운으로 표시
|
||||
- **unit 컬럼**: `input_type = 'category'` → SELECT 드롭다운으로 표시되어야 함
|
||||
|
||||
## DB 현황 (vexplor-dev 조회 결과)
|
||||
|
||||
| column_name | company_code | input_type |
|
||||
|-------------|--------------|------------|
|
||||
| division | * | category |
|
||||
| division | COMPANY_7 | **text** |
|
||||
| division | COMPANY_8, 9, 10, 18, 19, 20, 21 | category |
|
||||
| unit | * | text |
|
||||
| unit | COMPANY_18, 19, 20, 21, 7, 8, 9 | **category** |
|
||||
|
||||
**주의:** `wace` 사용자는 `company_code = '*'` (최고 관리자)입니다.
|
||||
- division: company * → **category** (text 아님)
|
||||
- unit: company * → **text** (category 아님)
|
||||
|
||||
**회사별로 다릅니다.** 예: COMPANY_7의 division은 text, unit은 category.
|
||||
|
||||
---
|
||||
|
||||
## 수동 검증 절차
|
||||
|
||||
### 1. 로그인
|
||||
- URL: `http://localhost:9771/login`
|
||||
- User ID: `wace`
|
||||
- Password: `wace0909!!`
|
||||
- 회사: "탑씰" (해당 회사 코드 확인 필요)
|
||||
|
||||
### 2. 화면 149 접속
|
||||
- URL: `http://localhost:9771/screens/149`
|
||||
- 페이지 로드 대기
|
||||
|
||||
### 3. 필드 확인
|
||||
|
||||
#### 구분 (division)
|
||||
- **예상 (DB 기준):**
|
||||
- company *: SELECT (category)
|
||||
- COMPANY_7: TEXT INPUT (text)
|
||||
- **실제:** TEXT INPUT 또는 SELECT 중 어느 쪽인지 확인
|
||||
|
||||
#### 단위 (unit)
|
||||
- **예상 (DB 기준):**
|
||||
- company *: TEXT INPUT (text)
|
||||
- COMPANY_18~21, 7~9: SELECT (category)
|
||||
- **실제:** TEXT INPUT 또는 SELECT 중 어느 쪽인지 확인
|
||||
|
||||
### 4. 스크린샷
|
||||
- 구분, 단위 필드가 함께 보이도록 캡처
|
||||
|
||||
---
|
||||
|
||||
## 코드 흐름 (input_type → 렌더링)
|
||||
|
||||
### 1. 컬럼 메타 로드
|
||||
```
|
||||
DynamicComponentRenderer
|
||||
→ loadColumnMeta(screenTableName)
|
||||
→ GET /api/table-management/tables/item_info/columns?size=1000
|
||||
→ columnMetaCache[tableName][columnName] = { inputType, ... }
|
||||
```
|
||||
|
||||
### 2. 렌더 타입 결정 (357~369행)
|
||||
```javascript
|
||||
const dbInputType = columnMetaCache[screenTableName]?.[baseCol]?.inputType;
|
||||
const ft = dbInputType || componentConfig?.fieldType;
|
||||
|
||||
if (["text", "number", ...].includes(ft)) return "v2-input"; // 텍스트 입력
|
||||
if (["select", "category", "entity"].includes(ft)) return "v2-select"; // 드롭다운
|
||||
```
|
||||
|
||||
### 3. mergeColumnMeta (81~130행)
|
||||
- DB `input_type`이 화면 저장값보다 우선
|
||||
- `needsSync`이면 DB 값으로 덮어씀
|
||||
|
||||
---
|
||||
|
||||
## 캐시 관련
|
||||
|
||||
### 1. 프론트엔드 (DynamicComponentRenderer)
|
||||
- `columnMetaCache`: TTL 5초
|
||||
- `table-columns-refresh` 이벤트 시 즉시 무효화 및 재로드
|
||||
|
||||
### 2. 백엔드 (tableManagementService)
|
||||
- 컬럼 목록: 5분 TTL
|
||||
- `updateColumnInputType` 호출 시 해당 테이블 캐시 삭제
|
||||
|
||||
### 3. 캐시 무효화가 필요한 경우
|
||||
- 데이터 타입 관리에서 변경 후 화면이 갱신되지 않을 때
|
||||
- **대응:** 페이지 새로고침 또는 `?_t=timestamp`로 API 재요청
|
||||
|
||||
---
|
||||
|
||||
## 가능한 원인
|
||||
|
||||
### 1. 회사 코드 불일치
|
||||
- 로그인한 사용자 회사와 DB의 `company_code`가 다를 수 있음
|
||||
- `wace`는 `company_code = '*'` → division은 category, unit은 text
|
||||
|
||||
### 2. 화면 레이아웃에 저장된 값
|
||||
- `componentConfig.fieldType`이 있으면 DB보다 우선될 수 있음
|
||||
- 코드상으로는 `dbInputType`이 우선이므로, DB가 제대로 로드되면 덮어씀
|
||||
|
||||
### 3. 캐시
|
||||
- 백엔드 5분, 프론트 5초
|
||||
- 데이터 타입 변경 후 곧바로 화면을 열면 이전 캐시가 사용될 수 있음
|
||||
|
||||
### 4. API 응답 구조
|
||||
- `columnMetaCache`에 넣을 때 `col.column_name || col.columnName` 사용
|
||||
- `mergeColumnMeta`는 `meta.input_type || meta.inputType` 사용
|
||||
- 백엔드는 `inputType`(camelCase) 반환 → `columnMetaCache`에 `inputType` 유지
|
||||
|
||||
---
|
||||
|
||||
## 디버깅용 Console 스크립트
|
||||
|
||||
화면 149 로드 후 브라우저 Console에서 실행:
|
||||
|
||||
```javascript
|
||||
// 1. columnMetaCache 조회 (DynamicComponentRenderer 내부)
|
||||
// React DevTools로 DynamicComponentRenderer 선택 후
|
||||
// 또는 전역에 노출해 둔 경우:
|
||||
const meta = window.__COLUMN_META_CACHE__?.item_info;
|
||||
if (meta) {
|
||||
console.log("division:", meta.division?.inputType || meta.division?.input_type);
|
||||
console.log("unit:", meta.unit?.inputType || meta.unit?.input_type);
|
||||
}
|
||||
|
||||
// 2. API 직접 호출
|
||||
fetch("/api/table-management/tables/item_info/columns?size=1000", {
|
||||
credentials: "include"
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
const cols = d.data?.columns || d.columns || [];
|
||||
const div = cols.find(c => (c.columnName || c.column_name) === "division");
|
||||
const unit = cols.find(c => (c.columnName || c.column_name) === "unit");
|
||||
console.log("API division:", div?.inputType || div?.input_type);
|
||||
console.log("API unit:", unit?.inputType || unit?.input_type);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 권장 사항
|
||||
|
||||
1. **회사 코드 확인**
|
||||
- 로그인한 사용자의 `company_code` 확인
|
||||
- `division`/`unit`을 text/category로 바꾼 회사가 맞는지 확인
|
||||
|
||||
2. **캐시 우회**
|
||||
- 데이터 타입 변경 후 페이지 새로고침
|
||||
- 또는 5초 이상 대기 후 다시 접속
|
||||
|
||||
3. **데이터 타입 관리에서 변경 시**
|
||||
- 저장 후 `table-columns-refresh` 이벤트 발생 여부 확인
|
||||
- 화면 디자이너의 V2FieldConfigPanel에서 변경 시에는 이벤트가 발생함
|
||||
|
||||
4. **테이블 관리 UI에서 변경 시**
|
||||
- `table-columns-refresh` 이벤트가 발생하는지 확인
|
||||
- 없으면 해당 화면에서 수동으로 `window.dispatchEvent(new CustomEvent("table-columns-refresh"))` 호출 후 재검증
|
||||
Loading…
Reference in New Issue