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:
|
environment:
|
||||||
- NODE_ENV=development
|
- NODE_ENV=development
|
||||||
- PORT=8080
|
- 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_SECRET=ilshin-plm-super-secret-jwt-key-2024
|
||||||
- JWT_EXPIRES_IN=24h
|
- JWT_EXPIRES_IN=24h
|
||||||
- CORS_ORIGIN=http://localhost:9771
|
- CORS_ORIGIN=http://localhost:9771
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { ArrowLeft, Plus, RefreshCw, Search, LayoutGrid, LayoutList, TestTube2 } from "lucide-react";
|
import { Plus, RefreshCw, Search, X, LayoutGrid, LayoutList, TestTube2, Database, MoreHorizontal, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||||
import ScreenList from "@/components/screen/ScreenList";
|
|
||||||
import ScreenDesigner from "@/components/screen/ScreenDesigner";
|
import ScreenDesigner from "@/components/screen/ScreenDesigner";
|
||||||
import TemplateManager from "@/components/screen/TemplateManager";
|
import TemplateManager from "@/components/screen/TemplateManager";
|
||||||
import { ScreenGroupTreeView } from "@/components/screen/ScreenGroupTreeView";
|
import { ScreenGroupTreeView } from "@/components/screen/ScreenGroupTreeView";
|
||||||
|
|
@ -15,11 +14,19 @@ import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||||
import { ScreenDefinition } from "@/types/screen";
|
import { ScreenDefinition } from "@/types/screen";
|
||||||
import { screenApi } from "@/lib/api/screen";
|
import { screenApi } from "@/lib/api/screen";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
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 CreateScreenModal from "@/components/screen/CreateScreenModal";
|
||||||
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@/components/ui/sheet";
|
||||||
|
|
||||||
// 단계별 진행을 위한 타입 정의
|
// 단계별 진행을 위한 타입 정의
|
||||||
type Step = "list" | "design" | "template" | "v2-test";
|
type Step = "list" | "design" | "template" | "v2-test";
|
||||||
type ViewMode = "tree" | "table";
|
type ViewMode = "flow" | "card";
|
||||||
|
|
||||||
export default function ScreenManagementPage() {
|
export default function ScreenManagementPage() {
|
||||||
const searchParams = useSearchParams();
|
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 [selectedGroup, setSelectedGroup] = useState<{ id: number; name: string; company_code?: string } | null>(null);
|
||||||
const [focusedScreenIdInGroup, setFocusedScreenIdInGroup] = useState<number | null>(null);
|
const [focusedScreenIdInGroup, setFocusedScreenIdInGroup] = useState<number | null>(null);
|
||||||
const [stepHistory, setStepHistory] = useState<Step[]>(["list"]);
|
const [stepHistory, setStepHistory] = useState<Step[]>(["list"]);
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>("tree");
|
const [viewMode, setViewMode] = useState<ViewMode>("flow");
|
||||||
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
|
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
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 () => {
|
const loadScreens = useCallback(async () => {
|
||||||
|
|
@ -102,6 +113,7 @@ export default function ScreenManagementPage() {
|
||||||
// 화면 선택 핸들러 (개별 화면 선택 시 그룹 선택 해제)
|
// 화면 선택 핸들러 (개별 화면 선택 시 그룹 선택 해제)
|
||||||
const handleScreenSelect = (screen: ScreenDefinition) => {
|
const handleScreenSelect = (screen: ScreenDefinition) => {
|
||||||
setSelectedScreen(screen);
|
setSelectedScreen(screen);
|
||||||
|
setIsDetailOpen(true);
|
||||||
setSelectedGroup(null); // 그룹 선택 해제
|
setSelectedGroup(null); // 그룹 선택 해제
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -159,60 +171,92 @@ export default function ScreenManagementPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col bg-background overflow-hidden">
|
<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 className="flex items-center justify-between">
|
||||||
<div>
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">화면 관리</h1>
|
<h1 className="text-xl font-bold tracking-tight">화면 관리</h1>
|
||||||
<p className="text-sm text-muted-foreground">화면을 그룹별로 관리하고 데이터 관계를 확인합니다</p>
|
<Badge variant="secondary" className="text-xs">{screens.length}개 화면</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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)}>
|
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as ViewMode)}>
|
||||||
<TabsList className="h-9">
|
<TabsList className="h-9 bg-muted/50 border border-border/50">
|
||||||
<TabsTrigger value="tree" className="gap-1.5 px-3">
|
<TabsTrigger value="flow" className="gap-1.5 px-3 text-xs">
|
||||||
<LayoutGrid className="h-4 w-4" />
|
<LayoutGrid className="h-4 w-4" />
|
||||||
트리
|
관계도
|
||||||
</TabsTrigger>
|
</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" />
|
<LayoutList className="h-4 w-4" />
|
||||||
테이블
|
카드
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<Button variant="outline" size="icon" onClick={loadScreens}>
|
<Button variant="outline" size="icon" onClick={loadScreens}>
|
||||||
<RefreshCw className="h-4 w-4" />
|
<RefreshCw className="h-4 w-4" />
|
||||||
</Button>
|
</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" />
|
<Plus className="h-4 w-4" />
|
||||||
새 화면
|
새 화면
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 메인 콘텐츠 */}
|
{/* 메인 콘텐츠 */}
|
||||||
{viewMode === "tree" ? (
|
{viewMode === "flow" ? (
|
||||||
<div className="flex-1 overflow-hidden flex">
|
<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 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>
|
||||||
|
)}
|
||||||
|
{/* 사이드바 펼침 시 전체 UI */}
|
||||||
|
{!sidebarCollapsed && (
|
||||||
|
<>
|
||||||
{/* 검색 */}
|
{/* 검색 */}
|
||||||
<div className="flex-shrink-0 p-3 border-b">
|
<div className="flex-shrink-0 p-3 border-b border-border/50">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="화면 검색..."
|
placeholder="화면 검색..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="pl-9 h-9"
|
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>
|
</div>
|
||||||
|
|
@ -226,29 +270,27 @@ export default function ScreenManagementPage() {
|
||||||
searchTerm={searchTerm}
|
searchTerm={searchTerm}
|
||||||
onGroupSelect={(group) => {
|
onGroupSelect={(group) => {
|
||||||
setSelectedGroup(group);
|
setSelectedGroup(group);
|
||||||
setSelectedScreen(null); // 화면 선택 해제
|
setSelectedScreen(null);
|
||||||
setFocusedScreenIdInGroup(null); // 포커스 초기화
|
setFocusedScreenIdInGroup(null);
|
||||||
}}
|
}}
|
||||||
onScreenSelectInGroup={(group, screenId) => {
|
onScreenSelectInGroup={(group, screenId) => {
|
||||||
// 그룹 내 화면 클릭 시
|
|
||||||
const isNewGroup = selectedGroup?.id !== group.id;
|
const isNewGroup = selectedGroup?.id !== group.id;
|
||||||
|
|
||||||
if (isNewGroup) {
|
if (isNewGroup) {
|
||||||
// 새 그룹 진입: 포커싱 없이 시작 (첫 진입 시 망가지는 문제 방지)
|
|
||||||
setSelectedGroup(group);
|
setSelectedGroup(group);
|
||||||
setFocusedScreenIdInGroup(null);
|
setFocusedScreenIdInGroup(null);
|
||||||
} else {
|
} else {
|
||||||
// 같은 그룹 내에서 다른 화면 클릭: 포커싱 유지
|
|
||||||
setFocusedScreenIdInGroup(screenId);
|
setFocusedScreenIdInGroup(screenId);
|
||||||
}
|
}
|
||||||
setSelectedScreen(null);
|
setSelectedScreen(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 오른쪽: 관계 시각화 (React Flow) */}
|
{/* 오른쪽: 관계 시각화 (React Flow) */}
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden bg-muted/10">
|
||||||
<ScreenRelationFlow
|
<ScreenRelationFlow
|
||||||
screen={selectedScreen}
|
screen={selectedScreen}
|
||||||
selectedGroup={selectedGroup}
|
selectedGroup={selectedGroup}
|
||||||
|
|
@ -257,21 +299,150 @@ export default function ScreenManagementPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// 테이블 뷰 (기존 ScreenList 사용)
|
<div className="flex-1 overflow-auto p-6 bg-muted/30 dark:bg-background">
|
||||||
<div className="flex-1 overflow-auto p-6">
|
{/* 카드 뷰 상단: 검색 + 카운트 */}
|
||||||
<ScreenList
|
<div className="flex items-center gap-3 mb-5">
|
||||||
onScreenSelect={handleScreenSelect}
|
<div className="relative flex-1 max-w-sm">
|
||||||
selectedScreen={selectedScreen}
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
onDesignScreen={handleDesignScreen}
|
<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>
|
</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
|
<CreateScreenModal
|
||||||
isOpen={isCreateOpen}
|
open={isCreateOpen}
|
||||||
onClose={() => setIsCreateOpen(false)}
|
onOpenChange={setIsCreateOpen}
|
||||||
onSuccess={() => {
|
onCreated={() => {
|
||||||
setIsCreateOpen(false);
|
setIsCreateOpen(false);
|
||||||
loadScreens();
|
loadScreens();
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -418,6 +418,21 @@ select {
|
||||||
border-spacing: 0 !important;
|
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 {
|
@keyframes saveBarDrop {
|
||||||
0% {
|
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 { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
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 {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
|
@ -1106,7 +1107,7 @@ export function ScreenGroupTreeView({
|
||||||
{/* 그룹 헤더 */}
|
{/* 그룹 헤더 */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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",
|
"text-sm font-medium group/item",
|
||||||
isMatching && "bg-primary/5 dark:bg-primary/10" // 검색 일치 하이라이트 (연한 배경)
|
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" />
|
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
{isExpanded ? (
|
{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>
|
<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}
|
{groupScreens.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
{/* 그룹 메뉴 버튼 */}
|
{/* 그룹 메뉴 버튼 */}
|
||||||
|
|
@ -1157,7 +1158,8 @@ export function ScreenGroupTreeView({
|
||||||
|
|
||||||
{/* 그룹 내 하위 그룹들 */}
|
{/* 그룹 내 하위 그룹들 */}
|
||||||
{isExpanded && childGroups.length > 0 && (
|
{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) => {
|
{childGroups.map((childGroup) => {
|
||||||
const childGroupId = String(childGroup.id);
|
const childGroupId = String(childGroup.id);
|
||||||
const isChildExpanded = expandedGroups.has(childGroupId) || shouldAutoExpandForSearch.has(childGroup.id); // 검색 시 상위 그룹만 자동 확장
|
const isChildExpanded = expandedGroups.has(childGroupId) || shouldAutoExpandForSearch.has(childGroup.id); // 검색 시 상위 그룹만 자동 확장
|
||||||
|
|
@ -1172,7 +1174,7 @@ export function ScreenGroupTreeView({
|
||||||
{/* 중분류 헤더 */}
|
{/* 중분류 헤더 */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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",
|
"text-xs font-medium group/item",
|
||||||
isChildMatching && "bg-primary/5 dark:bg-primary/10"
|
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" />
|
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
{isChildExpanded ? (
|
{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>
|
<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}
|
{childScreens.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|
@ -1222,7 +1224,8 @@ export function ScreenGroupTreeView({
|
||||||
|
|
||||||
{/* 중분류 내 손자 그룹들 (소분류) */}
|
{/* 중분류 내 손자 그룹들 (소분류) */}
|
||||||
{isChildExpanded && grandChildGroups.length > 0 && (
|
{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) => {
|
{grandChildGroups.map((grandChild) => {
|
||||||
const grandChildId = String(grandChild.id);
|
const grandChildId = String(grandChild.id);
|
||||||
const isGrandExpanded = expandedGroups.has(grandChildId) || shouldAutoExpandForSearch.has(grandChild.id); // 검색 시 상위 그룹만 자동 확장
|
const isGrandExpanded = expandedGroups.has(grandChildId) || shouldAutoExpandForSearch.has(grandChild.id); // 검색 시 상위 그룹만 자동 확장
|
||||||
|
|
@ -1234,7 +1237,7 @@ export function ScreenGroupTreeView({
|
||||||
{/* 소분류 헤더 */}
|
{/* 소분류 헤더 */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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",
|
"text-xs group/item",
|
||||||
isGrandMatching && "bg-primary/5 dark:bg-primary/10"
|
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" />
|
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
{isGrandExpanded ? (
|
{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>
|
<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}
|
{grandScreens.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|
@ -1294,9 +1297,9 @@ export function ScreenGroupTreeView({
|
||||||
<div
|
<div
|
||||||
key={screen.screenId}
|
key={screen.screenId}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors",
|
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors duration-150",
|
||||||
"text-xs hover:bg-accent",
|
"text-xs hover:bg-muted/60",
|
||||||
selectedScreen?.screenId === screen.screenId && "bg-accent"
|
selectedScreen?.screenId === screen.screenId && "bg-primary/10 border-l-2 border-primary"
|
||||||
)}
|
)}
|
||||||
onClick={() => handleScreenClickInGroup(screen, grandChild)}
|
onClick={() => handleScreenClickInGroup(screen, grandChild)}
|
||||||
onDoubleClick={() => handleScreenDoubleClick(screen)}
|
onDoubleClick={() => handleScreenDoubleClick(screen)}
|
||||||
|
|
@ -1330,9 +1333,9 @@ export function ScreenGroupTreeView({
|
||||||
<div
|
<div
|
||||||
key={screen.screenId}
|
key={screen.screenId}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors",
|
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors duration-150",
|
||||||
"text-xs hover:bg-accent",
|
"text-xs hover:bg-muted/60",
|
||||||
selectedScreen?.screenId === screen.screenId && "bg-accent"
|
selectedScreen?.screenId === screen.screenId && "bg-primary/10 border-l-2 border-primary"
|
||||||
)}
|
)}
|
||||||
onClick={() => handleScreenClickInGroup(screen, childGroup)}
|
onClick={() => handleScreenClickInGroup(screen, childGroup)}
|
||||||
onDoubleClick={() => handleScreenDoubleClick(screen)}
|
onDoubleClick={() => handleScreenDoubleClick(screen)}
|
||||||
|
|
@ -1366,9 +1369,9 @@ export function ScreenGroupTreeView({
|
||||||
<div
|
<div
|
||||||
key={screen.screenId}
|
key={screen.screenId}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors",
|
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors duration-150",
|
||||||
"text-sm hover:bg-accent group/screen",
|
"text-sm hover:bg-muted/60 group/screen",
|
||||||
selectedScreen?.screenId === screen.screenId && "bg-accent"
|
selectedScreen?.screenId === screen.screenId && "bg-primary/10 border-l-2 border-primary"
|
||||||
)}
|
)}
|
||||||
onClick={() => handleScreenClickInGroup(screen, group)}
|
onClick={() => handleScreenClickInGroup(screen, group)}
|
||||||
onDoubleClick={() => handleScreenDoubleClick(screen)}
|
onDoubleClick={() => handleScreenDoubleClick(screen)}
|
||||||
|
|
@ -1393,7 +1396,7 @@ export function ScreenGroupTreeView({
|
||||||
<div className="mb-1">
|
<div className="mb-1">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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"
|
"text-sm font-medium text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
onClick={() => toggleGroup("ungrouped")}
|
onClick={() => toggleGroup("ungrouped")}
|
||||||
|
|
@ -1405,7 +1408,7 @@ export function ScreenGroupTreeView({
|
||||||
)}
|
)}
|
||||||
<Folder className="h-4 w-4 shrink-0" />
|
<Folder className="h-4 w-4 shrink-0" />
|
||||||
<span className="truncate flex-1">미분류</span>
|
<span className="truncate flex-1">미분류</span>
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs font-mono">
|
||||||
{ungroupedScreens.length}
|
{ungroupedScreens.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1416,9 +1419,9 @@ export function ScreenGroupTreeView({
|
||||||
<div
|
<div
|
||||||
key={screen.screenId}
|
key={screen.screenId}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors",
|
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors duration-150",
|
||||||
"text-sm hover:bg-accent",
|
"text-sm hover:bg-muted/60",
|
||||||
selectedScreen?.screenId === screen.screenId && "bg-accent"
|
selectedScreen?.screenId === screen.screenId && "bg-primary/10 border-l-2 border-primary"
|
||||||
)}
|
)}
|
||||||
onClick={() => handleScreenClick(screen)}
|
onClick={() => handleScreenClick(screen)}
|
||||||
onDoubleClick={() => handleScreenDoubleClick(screen)}
|
onDoubleClick={() => handleScreenDoubleClick(screen)}
|
||||||
|
|
@ -2096,15 +2099,15 @@ export function ScreenGroupTreeView({
|
||||||
onClick={() => handleSync("menu-to-screen")}
|
onClick={() => handleSync("menu-to-screen")}
|
||||||
disabled={isSyncing}
|
disabled={isSyncing}
|
||||||
variant="outline"
|
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" ? (
|
{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="flex-1 text-left text-success">메뉴 → 화면관리 동기화</span>
|
||||||
<span className="text-xs text-emerald-500/70">
|
<span className="text-xs text-success/70">
|
||||||
메뉴 구조를 폴더에 반영
|
메뉴 구조를 폴더에 반영
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,25 @@ import {
|
||||||
MousePointer2,
|
MousePointer2,
|
||||||
Key,
|
Key,
|
||||||
Link2,
|
Link2,
|
||||||
Columns3,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { ScreenLayoutSummary } from "@/lib/api/screenGroup";
|
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) => {
|
const getScreenTypeColor = (_screenType?: string, _isMain?: boolean) => {
|
||||||
if (!isMain) return "bg-slate-400";
|
return "";
|
||||||
switch (screenType) {
|
|
||||||
case "grid":
|
|
||||||
return "bg-violet-500";
|
|
||||||
case "dashboard":
|
|
||||||
return "bg-amber-500";
|
|
||||||
case "action":
|
|
||||||
return "bg-rose-500";
|
|
||||||
default:
|
|
||||||
return "bg-primary";
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 화면 역할(screenRole)에 따른 색상
|
// 화면 역할(screenRole)에 따른 색상 - 더 이상 그라데이션 미사용
|
||||||
const getScreenRoleColor = (screenRole?: string) => {
|
const getScreenRoleColor = (_screenRole?: string) => {
|
||||||
if (!screenRole) return "bg-slate-400";
|
return "";
|
||||||
|
|
||||||
// 역할명에 포함된 키워드로 색상 결정
|
|
||||||
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"; // 기본 회색
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 화면 타입별 라벨
|
// 화면 타입별 라벨
|
||||||
|
|
@ -161,36 +148,26 @@ const getScreenTypeLabel = (screenType?: string) => {
|
||||||
|
|
||||||
// ========== 화면 노드 (상단) - 미리보기 표시 ==========
|
// ========== 화면 노드 (상단) - 미리보기 표시 ==========
|
||||||
export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
|
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";
|
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 (
|
return (
|
||||||
<div
|
<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
|
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
|
: isFaded
|
||||||
? "border-border opacity-50"
|
? "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 hover:shadow-lg hover:ring-2 hover:ring-primary/20"
|
: "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={{
|
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",
|
transition: "all 0.3s ease",
|
||||||
transform: isFocused ? "scale(1.02)" : "scale(1)",
|
animation: isFocused ? "glow-pulse 2s ease-in-out infinite alternate" : "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Handles */}
|
{/* Handles */}
|
||||||
|
|
@ -198,78 +175,49 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Left}
|
position={Position.Left}
|
||||||
id="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
|
<Handle
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Right}
|
position={Position.Right}
|
||||||
id="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
|
<Handle
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Bottom}
|
position={Position.Bottom}
|
||||||
id="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`}>
|
<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">
|
||||||
<Monitor className="h-4 w-4" />
|
<div className="flex h-6 w-6 items-center justify-center rounded bg-primary/10 text-primary">
|
||||||
<span className="flex-1 truncate text-xs font-semibold">{label}</span>
|
<Monitor className="h-3.5 w-3.5" />
|
||||||
{(isMain || isFocused) && <span className="flex h-2 w-2 rounded-full bg-white/80 animate-pulse" />}
|
</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>
|
||||||
|
|
||||||
{/* 화면 미리보기 영역 (컴팩트) */}
|
{/* 화면 미리보기 영역 (컴팩트) */}
|
||||||
<div className="h-[140px] overflow-hidden bg-muted/50 p-2">
|
<div className="h-[110px] overflow-hidden p-2.5">
|
||||||
{layoutSummary ? (
|
{layoutSummary ? (
|
||||||
<ScreenPreview layoutSummary={layoutSummary} screenType={screenType} />
|
<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)}
|
{getScreenTypeIcon(screenType)}
|
||||||
<span className="mt-1 text-[10px]">화면: {label}</span>
|
<span className="mt-1 text-[10px]">화면: {label}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 필드 매핑 영역 */}
|
{/* 푸터 (타입 칩 + 컴포넌트 수) */}
|
||||||
<div className="flex-1 overflow-hidden border-t border-border bg-card px-2 py-1.5">
|
<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">
|
||||||
<div className="mb-1 flex items-center gap-1 text-[9px] font-medium text-muted-foreground">
|
<span className="text-[9px] font-medium px-[7px] py-[2px] rounded bg-primary/10 text-primary">{getScreenTypeLabel(screenType)}</span>
|
||||||
<Columns3 className="h-3 w-3" />
|
<span className="text-[9px] text-muted-foreground">{layoutSummary?.totalComponents ?? 0}개 컴포넌트</span>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -280,33 +228,33 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
|
||||||
const getComponentColor = (componentKind: string) => {
|
const getComponentColor = (componentKind: string) => {
|
||||||
// 테이블/그리드 관련
|
// 테이블/그리드 관련
|
||||||
if (componentKind === "table-list" || componentKind === "data-grid") {
|
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") {
|
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")) {
|
if (componentKind?.includes("button")) {
|
||||||
return "bg-blue-300 border-primary";
|
return "bg-primary/30 border-primary";
|
||||||
}
|
}
|
||||||
// 입력 필드
|
// 입력 필드
|
||||||
if (componentKind?.includes("input") || componentKind?.includes("text")) {
|
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")) {
|
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")) {
|
if (componentKind?.includes("chart")) {
|
||||||
return "bg-emerald-200 border-emerald-400";
|
return "bg-success/20 border-success/40";
|
||||||
}
|
}
|
||||||
// 커스텀 위젯
|
// 커스텀 위젯
|
||||||
if (componentKind === "custom") {
|
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;
|
const { totalComponents, widgetCounts } = layoutSummary;
|
||||||
|
|
||||||
// 그리드 화면 일러스트
|
// 그리드 화면 일러스트 (모노크롬)
|
||||||
if (screenType === "grid") {
|
if (screenType === "grid") {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col gap-2 rounded-lg border border-border bg-muted/30 p-3">
|
<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="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="flex-1" />
|
||||||
<div className="h-4 w-8 rounded bg-primary 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-primary 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-rose-500 shadow-sm" />
|
<div className="h-4 w-8 rounded bg-foreground/[0.12] dark:bg-foreground/8" />
|
||||||
</div>
|
</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) => (
|
{[...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>
|
||||||
{/* 테이블 행들 */}
|
{/* 테이블 행들 */}
|
||||||
<div className="flex flex-1 flex-col gap-1 overflow-hidden">
|
<div className="flex flex-1 flex-col gap-1 overflow-hidden">
|
||||||
{[...Array(7)].map((_, i) => (
|
{[...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) => (
|
{[...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>
|
</div>
|
||||||
{/* 페이지네이션 */}
|
{/* 페이지네이션 */}
|
||||||
<div className="flex items-center justify-center gap-2 pt-1">
|
<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-foreground/[0.12] dark:bg-foreground/8" />
|
||||||
<div className="h-2.5 w-4 rounded bg-primary" />
|
<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-muted-foreground/40" />
|
<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-muted-foreground/40" />
|
<div className="h-2.5 w-4 rounded bg-foreground/[0.12] dark:bg-foreground/8" />
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 폼 화면 일러스트
|
// 폼 화면 일러스트 (모노크롬)
|
||||||
if (screenType === "form") {
|
if (screenType === "form") {
|
||||||
return (
|
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) => (
|
{[...Array(6)].map((_, i) => (
|
||||||
<div key={i} className="flex items-center gap-3">
|
<div key={i} className="flex items-center gap-3">
|
||||||
<div className="h-2.5 w-14 rounded bg-muted-foreground/50" />
|
<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 bg-card shadow-sm" />
|
<div className="h-5 flex-1 rounded-md border border-border/30 dark:border-border/5 bg-card" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{/* 버튼 영역 */}
|
{/* 버튼 영역 */}
|
||||||
<div className="mt-auto flex justify-end gap-2 border-t border-border pt-3">
|
<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-muted-foreground/40 shadow-sm" />
|
<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-primary shadow-sm" />
|
<div className="h-5 w-14 rounded-md bg-foreground/[0.18] dark:bg-foreground/12" />
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 대시보드 화면 일러스트
|
// 대시보드 화면 일러스트 (모노크롬)
|
||||||
if (screenType === "dashboard") {
|
if (screenType === "dashboard") {
|
||||||
return (
|
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="rounded-lg bg-foreground/[0.08] dark:bg-foreground/5 p-2">
|
||||||
<div className="mb-2 h-2.5 w-10 rounded bg-emerald-400" />
|
<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-emerald-300/80" />
|
<div className="h-10 rounded-md bg-foreground/[0.12] dark:bg-foreground/8" />
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg bg-amber-100 p-2 shadow-sm">
|
<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-amber-400" />
|
<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-amber-300/80" />
|
<div className="h-10 rounded-md bg-foreground/[0.12] dark:bg-foreground/8" />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2 rounded-lg bg-primary/10 p-2 shadow-sm">
|
<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-primary/70" />
|
<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">
|
<div className="flex h-14 items-end gap-1">
|
||||||
{[...Array(10)].map((_, i) => (
|
{[...Array(10)].map((_, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
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}%` }}
|
style={{ height: `${25 + Math.random() * 75}%` }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 액션 화면 일러스트 (버튼 중심)
|
// 액션 화면 일러스트 (모노크롬)
|
||||||
if (screenType === "action") {
|
if (screenType === "action") {
|
||||||
return (
|
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="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-muted p-4 text-muted-foreground">
|
<div className="rounded-full bg-foreground/[0.08] dark:bg-foreground/5 p-4 text-muted-foreground">
|
||||||
<MousePointer2 className="h-10 w-10" />
|
<MousePointer2 className="h-10 w-10" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<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-foreground/[0.18] dark:bg-foreground/12" />
|
||||||
<div className="h-7 w-16 rounded-md bg-muted-foreground/40 shadow-sm" />
|
<div className="h-7 w-16 rounded-md bg-foreground/[0.12] dark:bg-foreground/8" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs font-medium text-muted-foreground">액션 화면</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 기본 (알 수 없는 타입)
|
// 기본 (알 수 없는 타입, 모노크롬)
|
||||||
return (
|
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="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-slate-100 p-4">
|
<div className="rounded-full bg-foreground/[0.08] dark:bg-foreground/5 p-4">
|
||||||
{getScreenTypeIcon(screenType)}
|
{getScreenTypeIcon(screenType)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium">{totalComponents}개 컴포넌트</span>
|
<span className="text-sm font-medium">{totalComponents}개 컴포넌트</span>
|
||||||
|
|
@ -574,21 +506,21 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`group relative flex w-[260px] flex-col overflow-visible rounded-xl border shadow-md ${
|
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. 필터 테이블 (마스터-디테일의 디테일 테이블): 항상 보라색 테두리
|
// 1. 필터 테이블 (마스터-디테일의 디테일 테이블)
|
||||||
isFilterTable
|
isFilterTable
|
||||||
? "border-2 border-violet-500 ring-2 ring-violet-500/20 shadow-lg bg-violet-50/50"
|
? "border-primary/30 shadow-[0_0_0_1px_hsl(var(--primary)/0.3)]"
|
||||||
// 2. 필터 관련 테이블 (마스터 또는 디테일) 포커스 시: 진한 보라색
|
// 2. 필터 관련 테이블 포커스 시
|
||||||
: (hasFilterRelation || isFilterSource)
|
: (hasFilterRelation || isFilterSource)
|
||||||
? "border-2 border-violet-500 ring-4 ring-violet-500/30 shadow-xl bg-violet-50"
|
? "border-primary/30 shadow-[0_0_0_1px_hsl(var(--primary)/0.3),0_0_24px_-8px_hsl(var(--primary)/0.1)]"
|
||||||
// 3. 순수 포커스 (필터 관계 없음): 초록색
|
// 3. 순수 포커스
|
||||||
: isFocused
|
: 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. 흐리게 처리
|
// 4. 흐리게 처리
|
||||||
: isFaded
|
: isFaded
|
||||||
? "border-border opacity-60 bg-card"
|
? "opacity-60 bg-card border-border/40 dark:border-border/10"
|
||||||
// 5. 기본
|
// 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={{
|
style={{
|
||||||
filter: isFaded ? "grayscale(80%)" : "none",
|
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"
|
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}
|
title={hasSaveTarget ? "저장 대상 테이블" : undefined}
|
||||||
style={{
|
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,
|
opacity: hasSaveTarget ? 1 : 0,
|
||||||
transform: hasSaveTarget ? 'scaleY(1)' : 'scaleY(0)',
|
transform: hasSaveTarget ? 'scaleY(1)' : 'scaleY(0)',
|
||||||
transformOrigin: 'top',
|
transformOrigin: 'top',
|
||||||
|
|
@ -616,7 +548,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Top}
|
position={Position.Top}
|
||||||
id="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: 메인테이블 → 메인테이블 연결용 (위쪽으로 나가는 선) */}
|
{/* top source: 메인테이블 → 메인테이블 연결용 (위쪽으로 나가는 선) */}
|
||||||
<Handle
|
<Handle
|
||||||
|
|
@ -624,25 +556,25 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
||||||
position={Position.Top}
|
position={Position.Top}
|
||||||
id="top_source"
|
id="top_source"
|
||||||
style={{ top: -4 }}
|
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
|
<Handle
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Left}
|
position={Position.Left}
|
||||||
id="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
|
<Handle
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Right}
|
position={Position.Right}
|
||||||
id="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
|
<Handle
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Bottom}
|
position={Position.Bottom}
|
||||||
id="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: 메인테이블 ← 메인테이블 연결용 (아래에서 들어오는 선) */}
|
{/* bottom target: 메인테이블 ← 메인테이블 연결용 (아래에서 들어오는 선) */}
|
||||||
<Handle
|
<Handle
|
||||||
|
|
@ -650,18 +582,18 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
||||||
position={Position.Bottom}
|
position={Position.Bottom}
|
||||||
id="bottom_target"
|
id="bottom_target"
|
||||||
style={{ bottom: -4 }}
|
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"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 헤더 (필터 관계: 보라색, 필터 소스: 보라색, 메인: 초록색, 기본: 슬레이트) */}
|
{/* 헤더: 그라디언트 제거, bg-muted/30 + 아이콘 박스 */}
|
||||||
<div className={`flex items-center gap-2 px-3 py-1.5 text-white rounded-t-xl transition-colors duration-700 ease-in-out ${
|
<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">
|
||||||
isFaded ? "bg-muted-foreground" : (hasFilterRelation || isFilterSource) ? "bg-violet-600" : isMain ? "bg-emerald-600" : "bg-slate-500"
|
<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" />
|
||||||
<Database className="h-3.5 w-3.5 shrink-0" />
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<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
|
{isFilterSource
|
||||||
? "마스터 테이블 (필터 소스)"
|
? "마스터 테이블 (필터 소스)"
|
||||||
: hasFilterRelation
|
: hasFilterRelation
|
||||||
|
|
@ -670,8 +602,8 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{hasActiveColumns && (
|
{hasActiveColumns && (
|
||||||
<span className="rounded-full bg-white/20 px-1.5 py-0.5 text-[8px] shrink-0">
|
<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}개 활성
|
{displayColumns.length} ref
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -679,7 +611,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
||||||
{/* 컬럼 목록 - 컴팩트하게 (부드러운 높이 전환 + 스크롤) */}
|
{/* 컬럼 목록 - 컴팩트하게 (부드러운 높이 전환 + 스크롤) */}
|
||||||
{/* 뱃지도 이 영역 안에 포함되어 높이 계산에 반영됨 */}
|
{/* 뱃지도 이 영역 안에 포함되어 높이 계산에 반영됨 */}
|
||||||
<div
|
<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={{
|
style={{
|
||||||
height: `${debouncedHeight}px`,
|
height: `${debouncedHeight}px`,
|
||||||
maxHeight: `${MAX_HEIGHT}px`,
|
maxHeight: `${MAX_HEIGHT}px`,
|
||||||
|
|
@ -699,7 +631,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
||||||
{/* 필터 뱃지 */}
|
{/* 필터 뱃지 */}
|
||||||
{filterRefs.length > 0 && (
|
{filterRefs.length > 0 && (
|
||||||
<span
|
<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')}`}
|
title={`마스터-디테일 필터링\n${filterRefs.map(r => `${r.fromTable}.${r.fromColumn || 'id'} → ${r.toColumn}`).join('\n')}`}
|
||||||
>
|
>
|
||||||
<Link2 className="h-3 w-3" />
|
<Link2 className="h-3 w-3" />
|
||||||
|
|
@ -707,14 +639,14 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{filterRefs.length > 0 && (
|
{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(', ')}
|
{filterRefs.map(r => `${r.fromTableLabel || r.fromTable}.${r.fromColumnLabel || r.fromColumn || 'id'}`).join(', ')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{/* 참조 뱃지 */}
|
{/* 참조 뱃지 */}
|
||||||
{lookupRefs.length > 0 && (
|
{lookupRefs.length > 0 && (
|
||||||
<span
|
<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')}`}
|
title={`코드 참조 (lookup)\n${lookupRefs.map(r => `${r.fromTable} → ${r.toColumn}`).join('\n')}`}
|
||||||
>
|
>
|
||||||
{lookupRefs.length}곳 참조
|
{lookupRefs.length}곳 참조
|
||||||
|
|
@ -745,33 +677,37 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
||||||
key={col.name}
|
key={col.name}
|
||||||
className={`flex items-center gap-1 rounded px-1.5 py-0.5 transition-all duration-300 ${
|
className={`flex items-center gap-1 rounded px-1.5 py-0.5 transition-all duration-300 ${
|
||||||
isJoinColumn
|
isJoinColumn
|
||||||
? "bg-amber-100 border border-orange-300 shadow-sm"
|
? "bg-warning/10 border border-warning/20 shadow-sm"
|
||||||
: isFilterColumn || isFilterSourceColumn
|
: isFilterColumn || isFilterSourceColumn
|
||||||
? "bg-violet-100 border border-violet-300 shadow-sm" // 필터 컬럼/필터 소스: 보라색
|
? "bg-primary/10 border border-primary/20 shadow-sm" // 필터 컬럼/필터 소스
|
||||||
: isHighlighted
|
: isHighlighted
|
||||||
? "bg-primary/10 border border-primary/40 shadow-sm"
|
? "bg-primary/10 border border-primary/40 shadow-sm"
|
||||||
: hasActiveColumns
|
: hasActiveColumns
|
||||||
? "bg-slate-100"
|
? "bg-muted"
|
||||||
: "bg-slate-50 hover:bg-slate-100"
|
: "bg-muted/50 hover:bg-muted/80 transition-colors"
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
animation: hasActiveColumns ? `fadeIn 0.5s ease-out ${idx * 80}ms forwards` : undefined,
|
animation: hasActiveColumns ? `fadeIn 0.5s ease-out ${idx * 80}ms forwards` : undefined,
|
||||||
opacity: hasActiveColumns ? 0 : 1,
|
opacity: hasActiveColumns ? 0 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* PK/FK/조인/필터 아이콘 */}
|
{/* 3px 세로 마커 (PK/FK/조인/필터) */}
|
||||||
{isJoinColumn && <Link2 className="h-2.5 w-2.5 text-amber-500" />}
|
<div
|
||||||
{(isFilterColumn || isFilterSourceColumn) && !isJoinColumn && <Link2 className="h-2.5 w-2.5 text-violet-500" />}
|
className={`w-[3px] h-[14px] rounded-sm flex-shrink-0 ${
|
||||||
{!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && col.isPrimaryKey && <Key className="h-2.5 w-2.5 text-amber-500" />}
|
isJoinColumn ? "bg-amber-400"
|
||||||
{!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && col.isForeignKey && !col.isPrimaryKey && <Link2 className="h-2.5 w-2.5 text-primary" />}
|
: (isFilterColumn || isFilterSourceColumn) ? "bg-primary opacity-80"
|
||||||
{!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && !col.isPrimaryKey && !col.isForeignKey && <div className="w-2.5" />}
|
: 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 ${
|
<span className={`flex-1 truncate font-mono text-[9px] font-medium ${
|
||||||
isJoinColumn ? "text-orange-700"
|
isJoinColumn ? "text-amber-400"
|
||||||
: (isFilterColumn || isFilterSourceColumn) ? "text-violet-700"
|
: (isFilterColumn || isFilterSourceColumn) ? "text-primary"
|
||||||
: isHighlighted ? "text-primary"
|
: isHighlighted ? "text-primary"
|
||||||
: "text-slate-700"
|
: "text-foreground"
|
||||||
}`}>
|
}`}>
|
||||||
{col.name}
|
{col.name}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -781,63 +717,74 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
||||||
<>
|
<>
|
||||||
{/* 조인 참조 테이블 표시 (joinColumnRefs에서) */}
|
{/* 조인 참조 테이블 표시 (joinColumnRefs에서) */}
|
||||||
{joinRefMap.has(colOriginal) && (
|
{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}
|
← {joinRefMap.get(colOriginal)?.refTableLabel}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{/* 필드 매핑 참조 표시 (fieldMappingMap에서, joinRefMap에 없는 경우) */}
|
{/* 필드 매핑 참조 표시 (fieldMappingMap에서, joinRefMap에 없는 경우) */}
|
||||||
{!joinRefMap.has(colOriginal) && fieldMappingMap.has(colOriginal) && (
|
{!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}
|
← {fieldMappingMap.get(colOriginal)?.sourceDisplayName}
|
||||||
</span>
|
</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 && (
|
{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 && (
|
{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 && (
|
{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 && (
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{/* 더 많은 컬럼이 있을 경우 표시 */}
|
{/* 더 많은 컬럼이 있을 경우 표시 */}
|
||||||
{remainingCount > 0 && (
|
{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}개 더
|
+ {remainingCount}개 더
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center py-2 text-muted-foreground">
|
<div className="flex flex-col items-center justify-center py-2 text-muted-foreground">
|
||||||
<Database className="h-4 w-4 text-slate-300" />
|
<Database className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="mt-0.5 text-[8px] text-slate-400">컬럼 정보 없음</span>
|
<span className="mt-0.5 text-[8px] text-muted-foreground">컬럼 정보 없음</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 푸터 (컴팩트) */}
|
{/* 푸터: cols + PK/FK 카운트 */}
|
||||||
<div className="flex items-center justify-between border-t border-border bg-muted/30 px-2 py-1">
|
<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">PostgreSQL</span>
|
<span className="text-[9px] text-muted-foreground/70 dark:text-muted-foreground/40 font-mono tracking-[-0.3px]">
|
||||||
{columns && (
|
{hasActiveColumns ? `${displayColumns.length}/${totalCount}` : totalCount} cols
|
||||||
<span className="text-[9px] text-muted-foreground">
|
</span>
|
||||||
{hasActiveColumns ? `${displayColumns.length}/${totalCount}` : totalCount}개 컬럼
|
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* CSS 애니메이션 정의 */}
|
{/* CSS 애니메이션 정의 */}
|
||||||
|
|
@ -861,10 +808,10 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
||||||
export const LegacyScreenNode = ScreenNode;
|
export const LegacyScreenNode = ScreenNode;
|
||||||
export const AggregateNode: React.FC<{ data: any }> = ({ data }) => {
|
export const AggregateNode: React.FC<{ data: any }> = ({ data }) => {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border-2 border-purple-300 bg-card p-3 shadow-lg">
|
<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-purple-500" />
|
<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-purple-500" />
|
<Handle type="source" position={Position.Right} id="right" className="!h-3 !w-3 !bg-primary" />
|
||||||
<div className="flex items-center gap-2 text-purple-600">
|
<div className="flex items-center gap-2 text-primary">
|
||||||
<Table2 className="h-4 w-4" />
|
<Table2 className="h-4 w-4" />
|
||||||
<span className="text-sm font-semibold">{data.label || "Aggregate"}</span>
|
<span className="text-sm font-semibold">{data.label || "Aggregate"}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import React, { useState, useEffect, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
Controls,
|
Controls,
|
||||||
|
MiniMap,
|
||||||
Background,
|
Background,
|
||||||
BackgroundVariant,
|
BackgroundVariant,
|
||||||
Node,
|
Node,
|
||||||
|
|
@ -34,22 +35,31 @@ import {
|
||||||
import { getTableColumns, ColumnTypeInfo } from "@/lib/api/tableManagement";
|
import { getTableColumns, ColumnTypeInfo } from "@/lib/api/tableManagement";
|
||||||
import { ScreenSettingModal } from "./ScreenSettingModal";
|
import { ScreenSettingModal } from "./ScreenSettingModal";
|
||||||
import { TableSettingModal } from "./TableSettingModal";
|
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 }> = {
|
const RELATION_COLORS: Record<VisualRelationType, { stroke: string; strokeLight: string; label: string }> = {
|
||||||
filter: { stroke: '#8b5cf6', strokeLight: '#c4b5fd', label: '마스터-디테일' }, // 보라색
|
filter: { stroke: 'hsl(var(--primary))', strokeLight: 'hsl(var(--primary) / 0.4)', label: '마스터-디테일' },
|
||||||
hierarchy: { stroke: '#06b6d4', strokeLight: '#a5f3fc', label: '계층 구조' }, // 시안색
|
hierarchy: { stroke: 'hsl(var(--info))', strokeLight: 'hsl(var(--info) / 0.4)', label: '계층 구조' },
|
||||||
lookup: { stroke: '#f59e0b', strokeLight: '#fcd34d', label: '코드 참조' }, // 주황색 (기존)
|
lookup: { stroke: 'hsl(var(--warning))', strokeLight: 'hsl(var(--warning) / 0.4)', label: '코드 참조' },
|
||||||
mapping: { stroke: '#10b981', strokeLight: '#6ee7b7', label: '데이터 매핑' }, // 녹색
|
mapping: { stroke: 'hsl(var(--success))', strokeLight: 'hsl(var(--success) / 0.4)', label: '데이터 매핑' },
|
||||||
join: { stroke: '#f97316', strokeLight: '#fdba74', label: '엔티티 조인' }, // orange-500 (기존 주황색)
|
join: { stroke: 'hsl(var(--warning))', strokeLight: 'hsl(var(--warning) / 0.4)', label: '엔티티 조인' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 엣지 필터 카테고리 (UI 토글용)
|
||||||
|
type EdgeCategory = 'main' | 'filter' | 'join' | 'lookup' | 'flow';
|
||||||
|
|
||||||
// 노드 타입 등록
|
// 노드 타입 등록
|
||||||
const nodeTypes = {
|
const nodeTypes = {
|
||||||
screenNode: ScreenNode,
|
screenNode: ScreenNode,
|
||||||
tableNode: TableNode,
|
tableNode: TableNode,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const edgeTypes = {
|
||||||
|
animatedFlow: AnimatedFlowEdge,
|
||||||
|
};
|
||||||
|
|
||||||
// 레이아웃 상수
|
// 레이아웃 상수
|
||||||
const SCREEN_Y = 50; // 화면 노드 Y 위치 (상단)
|
const SCREEN_Y = 50; // 화면 노드 Y 위치 (상단)
|
||||||
const TABLE_Y = 420; // 메인 테이블 노드 Y 위치 (중단)
|
const TABLE_Y = 420; // 메인 테이블 노드 Y 위치 (중단)
|
||||||
|
|
@ -89,6 +99,15 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||||
// 그룹 내 포커스된 화면 ID (그룹 모드에서만 사용)
|
// 그룹 내 포커스된 화면 ID (그룹 모드에서만 사용)
|
||||||
const [focusedScreenId, setFocusedScreenId] = useState<number | null>(null);
|
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 [isSettingModalOpen, setIsSettingModalOpen] = useState(false);
|
||||||
const [settingModalNode, setSettingModalNode] = useState<{
|
const [settingModalNode, setSettingModalNode] = useState<{
|
||||||
|
|
@ -414,7 +433,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||||
isFaded = focusedScreenId !== null && !isFocused;
|
isFaded = focusedScreenId !== null && !isFocused;
|
||||||
} else {
|
} else {
|
||||||
// 개별 화면 모드: 메인 화면(선택된 화면)만 포커스, 연결 화면은 흐리게
|
// 개별 화면 모드: 메인 화면(선택된 화면)만 포커스, 연결 화면은 흐리게
|
||||||
isFocused = isMain;
|
isFocused = !!isMain;
|
||||||
isFaded = !isMain && screenList.length > 1;
|
isFaded = !isMain && screenList.length > 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -426,7 +445,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||||
label: scr.screenName,
|
label: scr.screenName,
|
||||||
subLabel: selectedGroup ? `${roleLabel} (#${scr.displayOrder || idx + 1})` : (isMain ? "메인 화면" : "연결 화면"),
|
subLabel: selectedGroup ? `${roleLabel} (#${scr.displayOrder || idx + 1})` : (isMain ? "메인 화면" : "연결 화면"),
|
||||||
type: "screen",
|
type: "screen",
|
||||||
isMain: selectedGroup ? idx === 0 : isMain,
|
isMain: selectedGroup ? idx === 0 : !!isMain,
|
||||||
tableName: scr.tableName,
|
tableName: scr.tableName,
|
||||||
layoutSummary: summary,
|
layoutSummary: summary,
|
||||||
// 화면 포커스 관련 속성 (그룹 모드 & 개별 모드 공통)
|
// 화면 포커스 관련 속성 (그룹 모드 & 개별 모드 공통)
|
||||||
|
|
@ -687,14 +706,15 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||||
target: `screen-${nextScreen.screenId}`,
|
target: `screen-${nextScreen.screenId}`,
|
||||||
sourceHandle: "right",
|
sourceHandle: "right",
|
||||||
targetHandle: "left",
|
targetHandle: "left",
|
||||||
type: "smoothstep",
|
type: "animatedFlow",
|
||||||
label: `${i + 1}`,
|
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 },
|
labelBgStyle: { fill: "hsl(var(--card))", stroke: "hsl(var(--border))", strokeWidth: 1 },
|
||||||
labelBgPadding: [4, 2] as [number, number],
|
labelBgPadding: [4, 2] as [number, number],
|
||||||
markerEnd: { type: MarkerType.ArrowClosed, color: "#0ea5e9" },
|
markerEnd: { type: MarkerType.ArrowClosed, color: "hsl(var(--info))" },
|
||||||
animated: true,
|
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}`,
|
target: `table-${scr.tableName}`,
|
||||||
sourceHandle: "bottom",
|
sourceHandle: "bottom",
|
||||||
targetHandle: "top",
|
targetHandle: "top",
|
||||||
type: "smoothstep",
|
type: "animatedFlow",
|
||||||
animated: true, // 모든 메인 테이블 연결은 애니메이션
|
animated: true, // 모든 메인 테이블 연결은 애니메이션
|
||||||
style: {
|
style: {
|
||||||
stroke: "#3b82f6",
|
stroke: "hsl(var(--primary))",
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
},
|
},
|
||||||
|
data: { edgeCategory: 'main' as EdgeCategory },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -748,15 +769,16 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||||
target: targetNodeId,
|
target: targetNodeId,
|
||||||
sourceHandle: "bottom",
|
sourceHandle: "bottom",
|
||||||
targetHandle: "top",
|
targetHandle: "top",
|
||||||
type: "smoothstep",
|
type: "animatedFlow",
|
||||||
animated: true,
|
animated: true,
|
||||||
style: {
|
style: {
|
||||||
stroke: "#3b82f6",
|
stroke: "hsl(var(--primary))",
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
strokeDasharray: "5,5", // 점선으로 필터 관계 표시
|
strokeDasharray: "5,5", // 점선으로 필터 관계 표시
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
sourceScreenId,
|
sourceScreenId,
|
||||||
|
edgeCategory: 'filter' as EdgeCategory,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -793,7 +815,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||||
target: refTargetNodeId,
|
target: refTargetNodeId,
|
||||||
sourceHandle: "bottom",
|
sourceHandle: "bottom",
|
||||||
targetHandle: "bottom_target",
|
targetHandle: "bottom_target",
|
||||||
type: "smoothstep",
|
type: "animatedFlow",
|
||||||
animated: false,
|
animated: false,
|
||||||
style: {
|
style: {
|
||||||
stroke: RELATION_COLORS.join.strokeLight, // 초기값 (연한색)
|
stroke: RELATION_COLORS.join.strokeLight, // 초기값 (연한색)
|
||||||
|
|
@ -809,6 +831,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||||
sourceScreenId,
|
sourceScreenId,
|
||||||
isFilterJoin: true,
|
isFilterJoin: true,
|
||||||
visualRelationType: 'join',
|
visualRelationType: 'join',
|
||||||
|
edgeCategory: 'join' as EdgeCategory,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -901,7 +924,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||||
target: `table-${referencedTable}`, // 참조당하는 테이블
|
target: `table-${referencedTable}`, // 참조당하는 테이블
|
||||||
sourceHandle: "bottom", // 하단에서 나감 (서브테이블 구간으로)
|
sourceHandle: "bottom", // 하단에서 나감 (서브테이블 구간으로)
|
||||||
targetHandle: "bottom_target", // 하단으로 들어감
|
targetHandle: "bottom_target", // 하단으로 들어감
|
||||||
type: "smoothstep",
|
type: "animatedFlow",
|
||||||
animated: false,
|
animated: false,
|
||||||
style: {
|
style: {
|
||||||
stroke: relationColor.strokeLight, // 관계 유형별 연한 색상
|
stroke: relationColor.strokeLight, // 관계 유형별 연한 색상
|
||||||
|
|
@ -919,6 +942,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||||
referrerTable,
|
referrerTable,
|
||||||
referencedTable,
|
referencedTable,
|
||||||
visualRelationType, // 관계 유형 저장
|
visualRelationType, // 관계 유형 저장
|
||||||
|
edgeCategory: (visualRelationType === 'lookup' ? 'lookup' : 'join') as EdgeCategory,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -944,7 +968,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||||
target: `subtable-${subTable.tableName}`,
|
target: `subtable-${subTable.tableName}`,
|
||||||
sourceHandle: "bottom",
|
sourceHandle: "bottom",
|
||||||
targetHandle: "top",
|
targetHandle: "top",
|
||||||
type: "smoothstep",
|
type: "animatedFlow",
|
||||||
markerEnd: {
|
markerEnd: {
|
||||||
type: MarkerType.ArrowClosed,
|
type: MarkerType.ArrowClosed,
|
||||||
color: relationColor.strokeLight
|
color: relationColor.strokeLight
|
||||||
|
|
@ -959,6 +983,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||||
data: {
|
data: {
|
||||||
sourceScreenId,
|
sourceScreenId,
|
||||||
visualRelationType,
|
visualRelationType,
|
||||||
|
edgeCategory: (visualRelationType === 'lookup' ? 'lookup' : 'join') as EdgeCategory,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -973,7 +998,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||||
target: `table-${join.join_table}`,
|
target: `table-${join.join_table}`,
|
||||||
sourceHandle: "bottom",
|
sourceHandle: "bottom",
|
||||||
targetHandle: "bottom_target",
|
targetHandle: "bottom_target",
|
||||||
type: "smoothstep",
|
type: "animatedFlow",
|
||||||
markerEnd: {
|
markerEnd: {
|
||||||
type: MarkerType.ArrowClosed,
|
type: MarkerType.ArrowClosed,
|
||||||
color: RELATION_COLORS.join.strokeLight
|
color: RELATION_COLORS.join.strokeLight
|
||||||
|
|
@ -985,31 +1010,33 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||||
strokeDasharray: "8,4",
|
strokeDasharray: "8,4",
|
||||||
opacity: 0.5,
|
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) => {
|
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(
|
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) {
|
if (!edgeExists) {
|
||||||
newEdges.push({
|
newEdges.push({
|
||||||
id: `edge-rel-${idx}`,
|
id: `edge-rel-${idx}`,
|
||||||
source: `screen-${screen.screenId}`,
|
source: `screen-${refScreen.screenId}`,
|
||||||
target: `table-${rel.table_name}`,
|
target: `table-${rel.table_name}`,
|
||||||
sourceHandle: "bottom",
|
sourceHandle: "bottom",
|
||||||
targetHandle: "top",
|
targetHandle: "top",
|
||||||
type: "smoothstep",
|
type: "animatedFlow",
|
||||||
label: rel.relation_type === "join" ? "조인" : rel.crud_operations || "",
|
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 },
|
labelBgStyle: { fill: "hsl(var(--card))", stroke: "hsl(var(--border))", strokeWidth: 1 },
|
||||||
labelBgPadding: [3, 2] as [number, number],
|
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
|
flows
|
||||||
.filter((flow: any) => flow.source_screen_id === screen.screenId)
|
.filter((flow: any) => flow.source_screen_id === refScreen.screenId)
|
||||||
.forEach((flow: any, idx: number) => {
|
.forEach((flow: any, idx: number) => {
|
||||||
if (flow.target_screen_id) {
|
if (flow.target_screen_id) {
|
||||||
newEdges.push({
|
newEdges.push({
|
||||||
id: `edge-flow-${idx}`,
|
id: `edge-flow-${idx}`,
|
||||||
source: `screen-${screen.screenId}`,
|
source: `screen-${refScreen.screenId}`,
|
||||||
target: `screen-${flow.target_screen_id}`,
|
target: `screen-${flow.target_screen_id}`,
|
||||||
sourceHandle: "right",
|
sourceHandle: "right",
|
||||||
targetHandle: "left",
|
targetHandle: "left",
|
||||||
type: "smoothstep",
|
type: "animatedFlow",
|
||||||
animated: true,
|
animated: true,
|
||||||
label: flow.flow_label || flow.flow_type || "이동",
|
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 },
|
labelBgStyle: { fill: "hsl(var(--card))", stroke: "hsl(var(--border))", strokeWidth: 1 },
|
||||||
labelBgPadding: [4, 2] as [number, number],
|
labelBgPadding: [4, 2] as [number, number],
|
||||||
markerEnd: { type: MarkerType.ArrowClosed, color: "#8b5cf6" },
|
markerEnd: { type: MarkerType.ArrowClosed, color: "hsl(var(--primary))" },
|
||||||
style: { stroke: "#8b5cf6", strokeWidth: 2 },
|
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-")) {
|
if (node.id.startsWith("screen-")) {
|
||||||
const screenId = parseInt(node.id.replace("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];
|
const mainTable = screenTableMap[screenId];
|
||||||
|
|
||||||
// 해당 화면의 서브 테이블 (필터 테이블) 정보
|
// 해당 화면의 서브 테이블 (필터 테이블) 정보
|
||||||
|
|
@ -1248,7 +1276,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||||
// 메인 테이블 노드 더블클릭
|
// 메인 테이블 노드 더블클릭
|
||||||
if (node.id.startsWith("table-") && !node.id.startsWith("table-sub-")) {
|
if (node.id.startsWith("table-") && !node.id.startsWith("table-sub-")) {
|
||||||
const tableName = node.id.replace("table-", "");
|
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(
|
const screenId = Object.entries(screenTableMap).find(
|
||||||
|
|
@ -1293,7 +1321,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||||
// 서브 테이블 노드 더블클릭
|
// 서브 테이블 노드 더블클릭
|
||||||
if (node.id.startsWith("subtable-")) {
|
if (node.id.startsWith("subtable-")) {
|
||||||
const tableName = node.id.replace("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(
|
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) => {
|
return nodes.map((node) => {
|
||||||
// 화면 노드 스타일링 (포커스가 있을 때만)
|
// 화면 노드 스타일링 (포커스가 있을 때만)
|
||||||
if (node.id.startsWith("screen-")) {
|
if (node.id.startsWith("screen-")) {
|
||||||
|
|
@ -1755,7 +1809,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||||
...node.data,
|
...node.data,
|
||||||
isFocused: isFocusedTable,
|
isFocused: isFocusedTable,
|
||||||
isRelated: isRelatedTable,
|
isRelated: isRelatedTable,
|
||||||
isFaded: focusedScreenId !== null && !isActiveTable,
|
isFaded: (focusedScreenId !== null && !isActiveTable) || lookupOnlyNodes.has(node.id),
|
||||||
highlightedColumns: isActiveTable ? highlightedColumns : [],
|
highlightedColumns: isActiveTable ? highlightedColumns : [],
|
||||||
joinColumns: isActiveTable ? joinColumns : [],
|
joinColumns: isActiveTable ? joinColumns : [],
|
||||||
joinColumnRefs: focusedJoinColumnRefs.length > 0 ? focusedJoinColumnRefs : undefined, // 조인 컬럼 참조 정보
|
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도 추가 (화면에서 서브테이블 컬럼을 직접 사용하는 경우)
|
// 서브 테이블의 highlightedColumns도 추가 (화면에서 서브테이블 컬럼을 직접 사용하는 경우)
|
||||||
|
|
@ -1872,7 +1920,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||||
data: {
|
data: {
|
||||||
...node.data,
|
...node.data,
|
||||||
isFocused: isActiveSubTable,
|
isFocused: isActiveSubTable,
|
||||||
isFaded: !isActiveSubTable,
|
isFaded: !isActiveSubTable || lookupOnlyNodes.has(node.id),
|
||||||
highlightedColumns: isActiveSubTable ? subTableHighlightedColumns : [],
|
highlightedColumns: isActiveSubTable ? subTableHighlightedColumns : [],
|
||||||
joinColumns: isActiveSubTable ? subTableJoinColumns : [],
|
joinColumns: isActiveSubTable ? subTableJoinColumns : [],
|
||||||
fieldMappings: isActiveSubTable ? displayFieldMappings : [],
|
fieldMappings: isActiveSubTable ? displayFieldMappings : [],
|
||||||
|
|
@ -1883,7 +1931,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||||
|
|
||||||
return node;
|
return node;
|
||||||
});
|
});
|
||||||
}, [nodes, selectedGroup, focusedScreenId, screenSubTableMap, subTablesDataMap, screenUsedColumnsMap, screenTableMap, tableColumns]);
|
}, [nodes, selectedGroup, focusedScreenId, screenSubTableMap, subTablesDataMap, screenUsedColumnsMap, screenTableMap, tableColumns, edgeFilterState, edges]);
|
||||||
|
|
||||||
// 포커스에 따른 엣지 스타일링 (그룹 모드 & 개별 화면 모드)
|
// 포커스에 따른 엣지 스타일링 (그룹 모드 & 개별 화면 모드)
|
||||||
const styledEdges = React.useMemo(() => {
|
const styledEdges = React.useMemo(() => {
|
||||||
|
|
@ -1903,9 +1951,9 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||||
animated: isConnected,
|
animated: isConnected,
|
||||||
style: {
|
style: {
|
||||||
...edge.style,
|
...edge.style,
|
||||||
stroke: isConnected ? "#8b5cf6" : "#d1d5db",
|
stroke: isConnected ? "hsl(var(--primary))" : "hsl(var(--border))",
|
||||||
strokeWidth: isConnected ? 2 : 1,
|
strokeWidth: isConnected ? 2.5 : 1,
|
||||||
opacity: isConnected ? 1 : 0.3,
|
opacity: isConnected ? 1 : 0.2,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -1920,10 +1968,10 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||||
animated: isMyConnection,
|
animated: isMyConnection,
|
||||||
style: {
|
style: {
|
||||||
...edge.style,
|
...edge.style,
|
||||||
stroke: isMyConnection ? "#3b82f6" : "#d1d5db",
|
stroke: isMyConnection ? "hsl(var(--primary))" : "hsl(var(--border))",
|
||||||
strokeWidth: isMyConnection ? 2 : 1,
|
strokeWidth: isMyConnection ? 2.5 : 1,
|
||||||
strokeDasharray: isMyConnection ? undefined : "5,5",
|
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,
|
target: targetNodeId,
|
||||||
sourceHandle: 'bottom', // 고정: 서브테이블 구간 통과
|
sourceHandle: 'bottom', // 고정: 서브테이블 구간 통과
|
||||||
targetHandle: 'bottom_target', // 고정: 서브테이블 구간 통과
|
targetHandle: 'bottom_target', // 고정: 서브테이블 구간 통과
|
||||||
type: 'smoothstep',
|
type: "animatedFlow",
|
||||||
animated: true,
|
animated: true,
|
||||||
style: {
|
style: {
|
||||||
stroke: relationColor.stroke, // 관계 유형별 색상
|
stroke: relationColor.stroke, // 관계 유형별 색상
|
||||||
strokeWidth: 2,
|
strokeWidth: 2.5,
|
||||||
strokeDasharray: '8,4',
|
strokeDasharray: '8,4',
|
||||||
},
|
},
|
||||||
markerEnd: {
|
markerEnd: {
|
||||||
|
|
@ -2040,9 +2088,9 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||||
animated: isConnected,
|
animated: isConnected,
|
||||||
style: {
|
style: {
|
||||||
...edge.style,
|
...edge.style,
|
||||||
stroke: isConnected ? "#8b5cf6" : "#d1d5db",
|
stroke: isConnected ? "hsl(var(--primary))" : "hsl(var(--border))",
|
||||||
strokeWidth: isConnected ? 2 : 1,
|
strokeWidth: isConnected ? 2.5 : 1,
|
||||||
opacity: isConnected ? 1 : 0.3,
|
opacity: isConnected ? 1 : 0.2,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -2076,8 +2124,8 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||||
animated: true,
|
animated: true,
|
||||||
style: {
|
style: {
|
||||||
...edge.style,
|
...edge.style,
|
||||||
stroke: "#3b82f6",
|
stroke: "hsl(var(--primary))",
|
||||||
strokeWidth: 2,
|
strokeWidth: 2.5,
|
||||||
strokeDasharray: "5,5",
|
strokeDasharray: "5,5",
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
},
|
},
|
||||||
|
|
@ -2095,10 +2143,10 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||||
animated: isMyConnection,
|
animated: isMyConnection,
|
||||||
style: {
|
style: {
|
||||||
...edge.style,
|
...edge.style,
|
||||||
stroke: isMyConnection ? "#3b82f6" : "#d1d5db",
|
stroke: isMyConnection ? "hsl(var(--primary))" : "hsl(var(--border))",
|
||||||
strokeWidth: isMyConnection ? 2 : 1,
|
strokeWidth: isMyConnection ? 2.5 : 1,
|
||||||
strokeDasharray: isMyConnection ? undefined : "5,5",
|
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,
|
stroke: isActive ? relationColor.stroke : relationColor.strokeLight,
|
||||||
strokeWidth: isActive ? 2.5 : 1.5,
|
strokeWidth: isActive ? 2.5 : 1.5,
|
||||||
strokeDasharray: "8,4",
|
strokeDasharray: "8,4",
|
||||||
opacity: isActive ? 1 : 0.3,
|
opacity: isActive ? 1 : 0.2,
|
||||||
},
|
},
|
||||||
markerEnd: {
|
markerEnd: {
|
||||||
type: MarkerType.ArrowClosed,
|
type: MarkerType.ArrowClosed,
|
||||||
|
|
@ -2179,7 +2227,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||||
stroke: RELATION_COLORS.join.strokeLight,
|
stroke: RELATION_COLORS.join.strokeLight,
|
||||||
strokeWidth: 1.5,
|
strokeWidth: 1.5,
|
||||||
strokeDasharray: "6,4",
|
strokeDasharray: "6,4",
|
||||||
opacity: 0.3,
|
opacity: 0.2,
|
||||||
},
|
},
|
||||||
markerEnd: {
|
markerEnd: {
|
||||||
type: MarkerType.ArrowClosed,
|
type: MarkerType.ArrowClosed,
|
||||||
|
|
@ -2206,7 +2254,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||||
style: {
|
style: {
|
||||||
...edge.style,
|
...edge.style,
|
||||||
stroke: RELATION_COLORS.join.stroke,
|
stroke: RELATION_COLORS.join.stroke,
|
||||||
strokeWidth: 2,
|
strokeWidth: 2.5,
|
||||||
strokeDasharray: "6,4",
|
strokeDasharray: "6,4",
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
},
|
},
|
||||||
|
|
@ -2282,8 +2330,19 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||||
});
|
});
|
||||||
|
|
||||||
// 기존 엣지 + 조인 관계 엣지 합치기
|
// 기존 엣지 + 조인 관계 엣지 합치기
|
||||||
return [...styledOriginalEdges, ...joinEdges];
|
const allEdges = [...styledOriginalEdges, ...joinEdges];
|
||||||
}, [edges, nodes, selectedGroup, focusedScreenId, screen, screenSubTableMap, subTablesDataMap, screenTableMap]);
|
// 엣지 필터 적용 (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 전에 선언해야 함
|
// 그룹의 화면 목록 (데이터 흐름 설정용) - 모든 조건부 return 전에 선언해야 함
|
||||||
const groupScreensList = React.useMemo(() => {
|
const groupScreensList = React.useMemo(() => {
|
||||||
|
|
@ -2300,10 +2359,38 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||||
// 조건부 렌더링 (모든 훅 선언 후에 위치해야 함)
|
// 조건부 렌더링 (모든 훅 선언 후에 위치해야 함)
|
||||||
if (!screen && !selectedGroup) {
|
if (!screen && !selectedGroup) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
<div className="flex h-full flex-col items-center justify-center gap-6 p-8">
|
||||||
<div className="text-center">
|
<div className="relative">
|
||||||
<p className="text-sm">그룹 또는 화면을 선택하면</p>
|
<div className="flex items-center gap-4 opacity-30">
|
||||||
<p className="text-sm">데이터 관계가 시각화됩니다</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -2318,10 +2405,60 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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면 숨김 처리하여 깜빡임 방지 */}
|
{/* isViewReady가 false면 숨김 처리하여 깜빡임 방지 */}
|
||||||
<div className={`h-full w-full transition-opacity duration-0 ${isViewReady ? "opacity-100" : "opacity-0"}`}>
|
<div className={`h-full w-full transition-opacity duration-0 ${isViewReady ? "opacity-100" : "opacity-0"}`}>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
|
className="[&_.react-flow__node]:transition-all [&_.react-flow__node]:duration-300"
|
||||||
nodes={styledNodes}
|
nodes={styledNodes}
|
||||||
edges={styledEdges}
|
edges={styledEdges}
|
||||||
onNodesChange={onNodesChange}
|
onNodesChange={onNodesChange}
|
||||||
|
|
@ -2329,12 +2466,42 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||||
onNodeClick={handleNodeClick}
|
onNodeClick={handleNodeClick}
|
||||||
onNodeContextMenu={handleNodeContextMenu}
|
onNodeContextMenu={handleNodeContextMenu}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
|
edgeTypes={edgeTypes}
|
||||||
minZoom={0.3}
|
minZoom={0.3}
|
||||||
maxZoom={1.5}
|
maxZoom={1.5}
|
||||||
proOptions={{ hideAttribution: true }}
|
proOptions={{ hideAttribution: true }}
|
||||||
>
|
>
|
||||||
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="hsl(var(--border))" />
|
<svg style={{ position: "absolute", width: 0, height: 0 }}>
|
||||||
<Controls position="bottom-right" />
|
<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>
|
</ReactFlow>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -2353,7 +2520,6 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||||
fieldMappings={settingModalNode.existingConfig?.fieldMappings}
|
fieldMappings={settingModalNode.existingConfig?.fieldMappings}
|
||||||
componentCount={0}
|
componentCount={0}
|
||||||
onSaveSuccess={handleRefreshVisualization}
|
onSaveSuccess={handleRefreshVisualization}
|
||||||
isPop={isPop}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -2367,7 +2533,13 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
||||||
screenId={settingModalNode.screenId}
|
screenId={settingModalNode.screenId}
|
||||||
joinColumnRefs={settingModalNode.existingConfig?.joinColumnRefs}
|
joinColumnRefs={settingModalNode.existingConfig?.joinColumnRefs}
|
||||||
referencedBy={settingModalNode.existingConfig?.referencedBy}
|
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}
|
filterColumns={settingModalNode.existingConfig?.filterColumns}
|
||||||
onSaveSuccess={handleRefreshVisualization}
|
onSaveSuccess={handleRefreshVisualization}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent
|
||||||
|
|
||||||
// ComponentRegistry import (동적 ConfigPanel 가져오기용)
|
// ComponentRegistry import (동적 ConfigPanel 가져오기용)
|
||||||
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
|
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
|
||||||
|
import { columnMetaCache } from "@/lib/registry/DynamicComponentRenderer";
|
||||||
import { DynamicComponentConfigPanel, hasComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel";
|
import { DynamicComponentConfigPanel, hasComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel";
|
||||||
import StyleEditor from "../StyleEditor";
|
import StyleEditor from "../StyleEditor";
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
|
@ -207,28 +208,36 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
onUpdateProperty(selectedComponent.id, "componentConfig", { ...currentConfig, ...newConfig });
|
onUpdateProperty(selectedComponent.id, "componentConfig", { ...currentConfig, ...newConfig });
|
||||||
};
|
};
|
||||||
|
|
||||||
// 컬럼의 inputType 가져오기 (entity 타입인지 확인용)
|
|
||||||
const inputType = currentConfig.inputType || currentConfig.webType || (selectedComponent as any).inputType;
|
|
||||||
|
|
||||||
// 현재 화면의 테이블명 가져오기
|
// 현재 화면의 테이블명 가져오기
|
||||||
const currentTableName = tables?.[0]?.tableName;
|
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
|
// 컴포넌트별 추가 props
|
||||||
const extraProps: Record<string, any> = {};
|
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.inputType = inputType;
|
||||||
extraProps.tableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
|
extraProps.tableName = resolvedTableName;
|
||||||
extraProps.columnName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName;
|
extraProps.columnName = resolvedColumnName;
|
||||||
|
extraProps.screenTableName = resolvedTableName;
|
||||||
|
}
|
||||||
|
if (componentId === "v2-input") {
|
||||||
|
extraProps.allComponents = allComponents;
|
||||||
}
|
}
|
||||||
if (componentId === "v2-list") {
|
if (componentId === "v2-list") {
|
||||||
extraProps.currentTableName = currentTableName;
|
extraProps.currentTableName = currentTableName;
|
||||||
}
|
}
|
||||||
if (componentId === "v2-bom-item-editor" || componentId === "v2-bom-tree") {
|
if (componentId === "v2-bom-item-editor" || componentId === "v2-bom-tree") {
|
||||||
extraProps.currentTableName = currentTableName;
|
extraProps.currentTableName = currentTableName;
|
||||||
extraProps.screenTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
|
extraProps.screenTableName = resolvedTableName;
|
||||||
}
|
|
||||||
if (componentId === "v2-input") {
|
|
||||||
extraProps.allComponents = allComponents;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,15 @@ interface CategoryValueOption {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 하위 호환: 기존 config에서 fieldType 추론 ───
|
// ─── 하위 호환: 기존 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;
|
if (config.fieldType) return config.fieldType as FieldType;
|
||||||
|
|
||||||
// v2-select 계열
|
// v2-select 계열
|
||||||
|
|
@ -207,7 +215,7 @@ export const V2FieldConfigPanel: React.FC<V2FieldConfigPanelProps> = ({
|
||||||
inputType: metaInputType,
|
inputType: metaInputType,
|
||||||
componentType,
|
componentType,
|
||||||
}) => {
|
}) => {
|
||||||
const fieldType = resolveFieldType(config, componentType);
|
const fieldType = resolveFieldType(config, componentType, metaInputType);
|
||||||
const isSelectGroup = ["select", "category", "entity"].includes(fieldType);
|
const isSelectGroup = ["select", "category", "entity"].includes(fieldType);
|
||||||
|
|
||||||
// ─── 채번 관련 상태 (테이블 기반) ───
|
// ─── 채번 관련 상태 (테이블 기반) ───
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,34 @@ import { apiClient } from "@/lib/api/client";
|
||||||
import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor";
|
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 columnMetaLoading: Record<string, Promise<void>> = {};
|
||||||
|
const columnMetaTimestamp: Record<string, number> = {};
|
||||||
|
const CACHE_TTL_MS = 5000;
|
||||||
|
|
||||||
async function loadColumnMeta(tableName: string): Promise<void> {
|
export function invalidateColumnMetaCache(tableName?: string): void {
|
||||||
if (columnMetaCache[tableName]) return;
|
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]) {
|
if (columnMetaLoading[tableName]) {
|
||||||
await columnMetaLoading[tableName];
|
await columnMetaLoading[tableName];
|
||||||
return;
|
return;
|
||||||
|
|
@ -36,6 +57,7 @@ async function loadColumnMeta(tableName: string): Promise<void> {
|
||||||
if (name) map[name] = col;
|
if (name) map[name] = col;
|
||||||
}
|
}
|
||||||
columnMetaCache[tableName] = map;
|
columnMetaCache[tableName] = map;
|
||||||
|
columnMetaTimestamp[tableName] = Date.now();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[columnMeta] ${tableName} 로드 실패:`, e);
|
console.error(`[columnMeta] ${tableName} 로드 실패:`, e);
|
||||||
columnMetaCache[tableName] = {};
|
columnMetaCache[tableName] = {};
|
||||||
|
|
@ -56,43 +78,59 @@ export function isColumnRequiredByMeta(tableName?: string, columnName?: string):
|
||||||
return nullable === "NO" || nullable === "N";
|
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 {
|
function mergeColumnMeta(tableName: string | undefined, columnName: string | undefined, componentConfig: any): any {
|
||||||
if (!tableName || !columnName) return componentConfig;
|
if (!tableName || !columnName) return componentConfig;
|
||||||
|
|
||||||
const meta = columnMetaCache[tableName]?.[columnName];
|
const meta = columnMetaCache[tableName]?.[columnName];
|
||||||
if (!meta) return componentConfig;
|
if (!meta) return componentConfig;
|
||||||
|
|
||||||
const inputType = meta.input_type || meta.inputType;
|
const rawType = meta.input_type || meta.inputType;
|
||||||
if (!inputType) return componentConfig;
|
const dbInputType = rawType === "direct" || rawType === "auto" ? undefined : rawType;
|
||||||
|
if (!dbInputType) return componentConfig;
|
||||||
// 이미 source가 올바르게 설정된 경우 건드리지 않음
|
|
||||||
const existingSource = componentConfig?.source;
|
|
||||||
if (existingSource && existingSource !== "static" && existingSource !== "distinct" && existingSource !== "select") {
|
|
||||||
return componentConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged = { ...componentConfig };
|
const merged = { ...componentConfig };
|
||||||
|
const savedFieldType = merged.fieldType;
|
||||||
|
|
||||||
// source가 미설정/기본값일 때만 DB 메타데이터로 보완
|
// savedFieldType이 있고 DB와 같으면 변경 불필요
|
||||||
if (inputType === "entity") {
|
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 refTable = meta.reference_table || meta.referenceTable;
|
||||||
const refColumn = meta.reference_column || meta.referenceColumn;
|
const refColumn = meta.reference_column || meta.referenceColumn;
|
||||||
const displayCol = meta.display_column || meta.displayColumn;
|
const displayCol = meta.display_column || meta.displayColumn;
|
||||||
if (refTable && !merged.entityTable) {
|
if (refTable) {
|
||||||
merged.source = "entity";
|
merged.source = "entity";
|
||||||
merged.entityTable = refTable;
|
merged.entityTable = refTable;
|
||||||
merged.entityValueColumn = refColumn || "id";
|
merged.entityValueColumn = refColumn || "id";
|
||||||
merged.entityLabelColumn = displayCol || "name";
|
merged.entityLabelColumn = displayCol || "name";
|
||||||
|
merged.fieldType = "entity";
|
||||||
|
merged.inputType = "entity";
|
||||||
}
|
}
|
||||||
} else if (inputType === "category" && !existingSource) {
|
} else if (dbInputType === "category") {
|
||||||
merged.source = "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 =
|
const detail =
|
||||||
typeof meta.detail_settings === "string" ? JSON.parse(meta.detail_settings || "{}") : meta.detail_settings || {};
|
typeof meta.detail_settings === "string" ? JSON.parse(meta.detail_settings || "{}") : meta.detail_settings || {};
|
||||||
if (detail.options && !merged.options?.length) {
|
if (detail.options && !merged.options?.length) {
|
||||||
merged.options = detail.options;
|
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;
|
return merged;
|
||||||
|
|
@ -266,15 +304,27 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
// 컬럼 메타데이터 로드 트리거 (테이블명이 있으면 비동기 로드)
|
// 컬럼 메타데이터 로드 트리거 (TTL 기반 자동 갱신)
|
||||||
const screenTableName = props.tableName || (component as any).tableName;
|
const screenTableName = props.tableName || (component as any).tableName;
|
||||||
const [, forceUpdate] = React.useState(0);
|
const [metaVersion, forceUpdate] = React.useState(0);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (screenTableName) {
|
if (screenTableName) {
|
||||||
loadColumnMeta(screenTableName).then(() => forceUpdate((v) => v + 1));
|
loadColumnMeta(screenTableName).then(() => forceUpdate((v) => v + 1));
|
||||||
}
|
}
|
||||||
}, [screenTableName]);
|
}, [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 사용
|
// 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 type 사용
|
||||||
// 🆕 V2 레이아웃의 경우 url에서 컴포넌트 타입 추출 (예: "@/lib/registry/components/v2-input" → "v2-input")
|
// 🆕 V2 레이아웃의 경우 url에서 컴포넌트 타입 추출 (예: "@/lib/registry/components/v2-input" → "v2-input")
|
||||||
const extractTypeFromUrl = (url: string | undefined): string | undefined => {
|
const extractTypeFromUrl = (url: string | undefined): string | undefined => {
|
||||||
|
|
@ -306,12 +356,40 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
|
|
||||||
const mappedComponentType = mapToV2ComponentType(rawComponentType);
|
const mappedComponentType = mapToV2ComponentType(rawComponentType);
|
||||||
|
|
||||||
// fieldType 기반 동적 컴포넌트 전환 (통합 필드 설정 패널에서 설정된 값)
|
// fieldType 기반 동적 컴포넌트 전환 (사용자 설정 > DB input_type > 기본값)
|
||||||
const componentType = (() => {
|
const componentType = (() => {
|
||||||
const ft = (component as any).componentConfig?.fieldType;
|
const configFieldType = (component as any).componentConfig?.fieldType;
|
||||||
if (!ft) return mappedComponentType;
|
const fieldName = (component as any).columnName || (component as any).componentConfig?.fieldKey || (component as any).componentConfig?.columnName;
|
||||||
if (["text", "number", "password", "textarea", "slider", "color", "numbering"].includes(ft)) return "v2-input";
|
const isEntityJoin = fieldName?.includes(".");
|
||||||
if (["select", "category", "entity"].includes(ft)) return "v2-select";
|
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;
|
return mappedComponentType;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
@ -376,15 +454,24 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
// (v2-input, v2-select, v2-repeat-container 등 모두 동일하게 처리)
|
// (v2-input, v2-select, v2-repeat-container 등 모두 동일하게 처리)
|
||||||
|
|
||||||
// 🎯 카테고리 타입 우선 처리 (inputType 또는 webType 확인)
|
// 🎯 카테고리 타입 우선 처리 (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 webType = (component as any).componentConfig?.webType;
|
||||||
const tableName = (component as any).tableName;
|
const tableName = (component as any).tableName;
|
||||||
const columnName = (component as any).columnName;
|
const columnName = (component as any).columnName;
|
||||||
|
|
||||||
// 카테고리 셀렉트: webType이 "category"이고 tableName과 columnName이 있는 경우만
|
// DB input_type 확인: 데이터타입관리에서 변경한 최신 값이 레이아웃 저장값보다 우선
|
||||||
// ⚠️ 단, 다음 경우는 V2SelectRenderer로 직접 처리 (고급 모드 지원):
|
const dbMetaForField = columnName && screenTableName && !columnName.includes(".")
|
||||||
// 1. componentType이 "select-basic" 또는 "v2-select"인 경우
|
? columnMetaCache[screenTableName]?.[columnName]
|
||||||
// 2. config.mode가 dropdown이 아닌 경우 (radio, check, tagbox 등)
|
: 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 componentMode = (component as any).componentConfig?.mode || (component as any).config?.mode;
|
||||||
const isMultipleSelect = (component as any).componentConfig?.multiple;
|
const isMultipleSelect = (component as any).componentConfig?.multiple;
|
||||||
const nonDropdownModes = ["radio", "check", "checkbox", "tag", "tagbox", "toggle", "swap", "combobox"];
|
const nonDropdownModes = ["radio", "check", "checkbox", "tag", "tagbox", "toggle", "swap", "combobox"];
|
||||||
|
|
@ -392,7 +479,11 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
const shouldUseV2Select =
|
const shouldUseV2Select =
|
||||||
componentType === "select-basic" || componentType === "v2-select" || isNonDropdownMode || isMultipleSelect;
|
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로 직접 렌더링 (카테고리 + 고급 모드)
|
// V2SelectRenderer로 직접 렌더링 (카테고리 + 고급 모드)
|
||||||
try {
|
try {
|
||||||
const { V2SelectRenderer } = require("@/lib/registry/components/v2-select/V2SelectRenderer");
|
const { V2SelectRenderer } = require("@/lib/registry/components/v2-select/V2SelectRenderer");
|
||||||
|
|
@ -491,7 +582,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ V2SelectRenderer 로드 실패:", error);
|
console.error("❌ V2SelectRenderer 로드 실패:", error);
|
||||||
}
|
}
|
||||||
} else if ((inputType === "category" || webType === "category") && tableName && columnName) {
|
} else if (!isDbConfirmedNonCategory && (inputType === "category" || effectiveWebType === "category") && tableName && columnName) {
|
||||||
try {
|
try {
|
||||||
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
|
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
|
||||||
const fieldName = columnName || component.id;
|
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