diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts index f14f6532..c7c6023c 100644 --- a/backend-node/src/controllers/screenGroupController.ts +++ b/backend-node/src/controllers/screenGroupController.ts @@ -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, + }); + // ============================================================ // 저장 테이블 정보 추출 // ============================================================ diff --git a/docker/dev/docker-compose.backend.mac.yml b/docker/dev/docker-compose.backend.mac.yml index ed4602dd..4d862d9e 100644 --- a/docker/dev/docker-compose.backend.mac.yml +++ b/docker/dev/docker-compose.backend.mac.yml @@ -12,7 +12,7 @@ services: environment: - NODE_ENV=development - PORT=8080 - - DATABASE_URL=postgresql://postgres:vexplor0909!!@211.115.91.141:11134/vexplor + - DATABASE_URL=postgresql://postgres:ph0909!!@39.117.244.52:11132/plm - JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024 - JWT_EXPIRES_IN=24h - CORS_ORIGIN=http://localhost:9771 diff --git a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx index 76d1b91f..2978e025 100644 --- a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx @@ -1,11 +1,10 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useMemo } from "react"; import { useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { ArrowLeft, Plus, RefreshCw, Search, LayoutGrid, LayoutList, TestTube2 } from "lucide-react"; -import ScreenList from "@/components/screen/ScreenList"; +import { Plus, RefreshCw, Search, X, LayoutGrid, LayoutList, TestTube2, Database, MoreHorizontal, PanelLeftClose, PanelLeftOpen } from "lucide-react"; import ScreenDesigner from "@/components/screen/ScreenDesigner"; import TemplateManager from "@/components/screen/TemplateManager"; import { ScreenGroupTreeView } from "@/components/screen/ScreenGroupTreeView"; @@ -15,11 +14,19 @@ import { ScrollToTop } from "@/components/common/ScrollToTop"; import { ScreenDefinition } from "@/types/screen"; import { screenApi } from "@/lib/api/screen"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Badge } from "@/components/ui/badge"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import CreateScreenModal from "@/components/screen/CreateScreenModal"; +import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@/components/ui/sheet"; // 단계별 진행을 위한 타입 정의 type Step = "list" | "design" | "template" | "v2-test"; -type ViewMode = "tree" | "table"; +type ViewMode = "flow" | "card"; export default function ScreenManagementPage() { const searchParams = useSearchParams(); @@ -28,11 +35,15 @@ export default function ScreenManagementPage() { const [selectedGroup, setSelectedGroup] = useState<{ id: number; name: string; company_code?: string } | null>(null); const [focusedScreenIdInGroup, setFocusedScreenIdInGroup] = useState(null); const [stepHistory, setStepHistory] = useState(["list"]); - const [viewMode, setViewMode] = useState("tree"); + const [viewMode, setViewMode] = useState("flow"); const [screens, setScreens] = useState([]); const [loading, setLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(""); const [isCreateOpen, setIsCreateOpen] = useState(false); + const [isDetailOpen, setIsDetailOpen] = useState(false); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + + const tableCount = useMemo(() => new Set(screens.map((s) => s.tableName).filter(Boolean)).size, [screens]); // 화면 목록 로드 const loadScreens = useCallback(async () => { @@ -102,6 +113,7 @@ export default function ScreenManagementPage() { // 화면 선택 핸들러 (개별 화면 선택 시 그룹 선택 해제) const handleScreenSelect = (screen: ScreenDefinition) => { setSelectedScreen(screen); + setIsDetailOpen(true); setSelectedGroup(null); // 그룹 선택 해제 }; @@ -159,96 +171,126 @@ export default function ScreenManagementPage() { return (
{/* 페이지 헤더 */} -
+
-
-

화면 관리

-

화면을 그룹별로 관리하고 데이터 관계를 확인합니다

+
+

화면 관리

+ {screens.length}개 화면
- {/* V2 컴포넌트 테스트 버튼 */} - {/* 뷰 모드 전환 */} setViewMode(v as ViewMode)}> - - + + - 트리 + 관계도 - + - 테이블 + 카드 - + + + + + + goToNextStep("v2-test")}> + + V2 테스트 + + +
{/* 메인 콘텐츠 */} - {viewMode === "tree" ? ( + {viewMode === "flow" ? (
- {/* 왼쪽: 트리 구조 */} -
- {/* 검색 */} -
-
- - setSearchTerm(e.target.value)} - className="pl-9 h-9" - /> + {/* 왼쪽: 트리 구조 (접기/펼기 지원) */} +
+ {/* 사이드바 헤더 */} +
+ {!sidebarCollapsed && 탐색} + +
+ {/* 사이드바 접힘 시 아이콘 컬럼 */} + {sidebarCollapsed && ( +
+ +
+ {screens.length} +
-
- {/* 트리 뷰 */} -
- { - setSelectedGroup(group); - setSelectedScreen(null); // 화면 선택 해제 - setFocusedScreenIdInGroup(null); // 포커스 초기화 - }} - onScreenSelectInGroup={(group, screenId) => { - // 그룹 내 화면 클릭 시 - const isNewGroup = selectedGroup?.id !== group.id; - - if (isNewGroup) { - // 새 그룹 진입: 포커싱 없이 시작 (첫 진입 시 망가지는 문제 방지) - setSelectedGroup(group); - setFocusedScreenIdInGroup(null); - } else { - // 같은 그룹 내에서 다른 화면 클릭: 포커싱 유지 - setFocusedScreenIdInGroup(screenId); - } - setSelectedScreen(null); - }} - /> -
+ )} + {/* 사이드바 펼침 시 전체 UI */} + {!sidebarCollapsed && ( + <> + {/* 검색 */} +
+
+ + setSearchTerm(e.target.value)} + className="pl-9 h-9 rounded-xl bg-muted/30 border-border/50 focus:bg-background focus:ring-2 focus:ring-primary/30 transition-colors" + /> +
+
+ {/* 트리 뷰 */} +
+ { + setSelectedGroup(group); + setSelectedScreen(null); + setFocusedScreenIdInGroup(null); + }} + onScreenSelectInGroup={(group, screenId) => { + const isNewGroup = selectedGroup?.id !== group.id; + if (isNewGroup) { + setSelectedGroup(group); + setFocusedScreenIdInGroup(null); + } else { + setFocusedScreenIdInGroup(screenId); + } + setSelectedScreen(null); + }} + /> +
+ + )}
{/* 오른쪽: 관계 시각화 (React Flow) */} -
+
) : ( - // 테이블 뷰 (기존 ScreenList 사용) -
- +
+ {/* 카드 뷰 상단: 검색 + 카운트 */} +
+
+ + 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 && ( + + )} +
+ {filteredScreens.length}개 화면 +
+
+ {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 ( +
handleScreenSelect(screen)} + onDoubleClick={() => handleDesignScreen(screen)} + > + {/* 좌측 그라데이션 액센트 바 */} +
+ {isSelected && ( +
+ )} +
+ {/* Row 1: 이름 + 타입 뱃지 */} +
+
{screen.screenName}
+ + {screenType === "grid" ? "그리드" : screenType === "dashboard" ? "대시보드" : "폼"} + +
+ {/* Row 2: 스크린 코드 */} +
{screen.screenCode}
+ {/* Row 3: 테이블 칩 + 메타 */} +
+ + + {screen.tableLabel || screen.tableName || "—"} + +
+ {/* Row 4: 날짜 + 수정 상태 */} +
+ + {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" }) : ""} + + {isRecentlyModified && ( + + + 수정됨 + + )} +
+
+
+ ); + })} +
+ {filteredScreens.length === 0 && ( +
+ +

검색 결과가 없습니다

+
+ )}
)} + {/* 화면 디테일 Sheet */} + + + + {selectedScreen?.screenName || "화면 상세"} + {selectedScreen?.screenCode} + + {selectedScreen && ( +
+
+
+ 테이블 + {selectedScreen.tableName || "없음"} +
+
+ 화면 ID + {selectedScreen.screenId} +
+
+
+ + +
+
+ )} +
+
+ {/* 화면 생성 모달 */} setIsCreateOpen(false)} - onSuccess={() => { + open={isCreateOpen} + onOpenChange={setIsCreateOpen} + onCreated={() => { setIsCreateOpen(false); loadScreens(); }} diff --git a/frontend/app/globals.css b/frontend/app/globals.css index b9173da6..19097f43 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -418,6 +418,21 @@ select { border-spacing: 0 !important; } +/* ===== 카드 펄스 도트 애니메이션 ===== */ +@keyframes screen-card-pulse { + 0%, 100% { opacity: 0; transform: scale(1); } + 50% { opacity: 0.35; transform: scale(2); } +} +.screen-card-pulse-dot::after { + content: ''; + position: absolute; + inset: -3px; + border-radius: 50%; + background: hsl(var(--success)); + opacity: 0; + animation: screen-card-pulse 2.5s ease-in-out infinite; +} + /* ===== 저장 테이블 막대기 애니메이션 ===== */ @keyframes saveBarDrop { 0% { diff --git a/frontend/components/screen/AnimatedFlowEdge.tsx b/frontend/components/screen/AnimatedFlowEdge.tsx new file mode 100644 index 00000000..dc33dcfa --- /dev/null +++ b/frontend/components/screen/AnimatedFlowEdge.tsx @@ -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) */} + + + + + + + + + + {/* 글로우 레이어 */} + + {/* 메인 엣지 */} + + {/* 흐르는 파티클 */} + {isActive && ( + <> + + + + + + + + )} + + ); +} diff --git a/frontend/components/screen/ScreenGroupTreeView.tsx b/frontend/components/screen/ScreenGroupTreeView.tsx index ead6ddd3..46a96847 100644 --- a/frontend/components/screen/ScreenGroupTreeView.tsx +++ b/frontend/components/screen/ScreenGroupTreeView.tsx @@ -37,7 +37,8 @@ import { import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { useAuth } from "@/hooks/useAuth"; -import { getCompanyList, Company } from "@/lib/api/company"; +import { getCompanyList } from "@/lib/api/company"; +import type { Company } from "@/types/company"; import { DropdownMenu, DropdownMenuContent, @@ -1106,7 +1107,7 @@ export function ScreenGroupTreeView({ {/* 그룹 헤더 */}
)} {isExpanded ? ( - + ) : ( - + )} {group.group_name} - + {groupScreens.length} {/* 그룹 메뉴 버튼 */} @@ -1157,7 +1158,8 @@ export function ScreenGroupTreeView({ {/* 그룹 내 하위 그룹들 */} {isExpanded && childGroups.length > 0 && ( -
+
+
{childGroups.map((childGroup) => { const childGroupId = String(childGroup.id); const isChildExpanded = expandedGroups.has(childGroupId) || shouldAutoExpandForSearch.has(childGroup.id); // 검색 시 상위 그룹만 자동 확장 @@ -1172,7 +1174,7 @@ export function ScreenGroupTreeView({ {/* 중분류 헤더 */}
)} {isChildExpanded ? ( - + ) : ( - + )} {childGroup.group_name} - + {childScreens.length} @@ -1222,7 +1224,8 @@ export function ScreenGroupTreeView({ {/* 중분류 내 손자 그룹들 (소분류) */} {isChildExpanded && grandChildGroups.length > 0 && ( -
+
+
{grandChildGroups.map((grandChild) => { const grandChildId = String(grandChild.id); const isGrandExpanded = expandedGroups.has(grandChildId) || shouldAutoExpandForSearch.has(grandChild.id); // 검색 시 상위 그룹만 자동 확장 @@ -1234,7 +1237,7 @@ export function ScreenGroupTreeView({ {/* 소분류 헤더 */}
)} {isGrandExpanded ? ( - + ) : ( - + )} {grandChild.group_name} - + {grandScreens.length} @@ -1294,9 +1297,9 @@ export function ScreenGroupTreeView({
handleScreenClickInGroup(screen, grandChild)} onDoubleClick={() => handleScreenDoubleClick(screen)} @@ -1330,9 +1333,9 @@ export function ScreenGroupTreeView({
handleScreenClickInGroup(screen, childGroup)} onDoubleClick={() => handleScreenDoubleClick(screen)} @@ -1366,9 +1369,9 @@ export function ScreenGroupTreeView({
handleScreenClickInGroup(screen, group)} onDoubleClick={() => handleScreenDoubleClick(screen)} @@ -1393,7 +1396,7 @@ export function ScreenGroupTreeView({
toggleGroup("ungrouped")} @@ -1405,7 +1408,7 @@ export function ScreenGroupTreeView({ )} 미분류 - + {ungroupedScreens.length}
@@ -1416,9 +1419,9 @@ export function ScreenGroupTreeView({
handleScreenClick(screen)} onDoubleClick={() => handleScreenDoubleClick(screen)} @@ -2096,15 +2099,15 @@ export function ScreenGroupTreeView({ onClick={() => handleSync("menu-to-screen")} disabled={isSyncing} variant="outline" - className="w-full justify-start gap-2 border-emerald-200 bg-emerald-50/50 hover:bg-emerald-100/70 hover:border-emerald-300" + className="w-full justify-start gap-2 border-success/20 bg-success/5 hover:bg-success/10 hover:border-success/30" > {isSyncing && syncDirection === "menu-to-screen" ? ( - + ) : ( - + )} - 메뉴 → 화면관리 동기화 - + 메뉴 → 화면관리 동기화 + 메뉴 구조를 폴더에 반영 diff --git a/frontend/components/screen/ScreenNode.tsx b/frontend/components/screen/ScreenNode.tsx index ff5ade46..1e763735 100644 --- a/frontend/components/screen/ScreenNode.tsx +++ b/frontend/components/screen/ScreenNode.tsx @@ -11,10 +11,25 @@ import { MousePointer2, Key, Link2, - Columns3, } from "lucide-react"; import { ScreenLayoutSummary } from "@/lib/api/screenGroup"; +// 글로우 펄스 애니메이션 CSS 주입 +if (typeof document !== "undefined") { + const styleId = "glow-pulse-animation"; + if (!document.getElementById(styleId)) { + const style = document.createElement("style"); + style.id = styleId; + style.textContent = ` + @keyframes glow-pulse { + from { filter: drop-shadow(0 0 4px hsl(var(--primary) / 0.25)) drop-shadow(0 0 10px hsl(var(--primary) / 0.12)); } + to { filter: drop-shadow(0 0 6px hsl(var(--primary) / 0.35)) drop-shadow(0 0 16px hsl(var(--primary) / 0.18)); } + } + `; + document.head.appendChild(style); + } +} + // ========== 타입 정의 ========== // 화면 노드 데이터 인터페이스 @@ -107,42 +122,14 @@ const getScreenTypeIcon = (screenType?: string) => { } }; -// 화면 타입별 색상 (헤더) -const getScreenTypeColor = (screenType?: string, isMain?: boolean) => { - if (!isMain) return "bg-slate-400"; - switch (screenType) { - case "grid": - return "bg-violet-500"; - case "dashboard": - return "bg-amber-500"; - case "action": - return "bg-rose-500"; - default: - return "bg-primary"; - } +// 화면 타입별 색상 (헤더) - 더 이상 그라데이션 미사용 +const getScreenTypeColor = (_screenType?: string, _isMain?: boolean) => { + return ""; }; -// 화면 역할(screenRole)에 따른 색상 -const getScreenRoleColor = (screenRole?: string) => { - if (!screenRole) return "bg-slate-400"; - - // 역할명에 포함된 키워드로 색상 결정 - const role = screenRole.toLowerCase(); - - if (role.includes("그리드") || role.includes("grid") || role.includes("메인") || role.includes("main") || role.includes("list")) { - return "bg-violet-500"; // 보라색 - 메인 그리드 - } - if (role.includes("등록") || role.includes("폼") || role.includes("form") || role.includes("register") || role.includes("input")) { - return "bg-primary"; // 파란색 - 등록 폼 - } - if (role.includes("액션") || role.includes("action") || role.includes("이벤트") || role.includes("event") || role.includes("클릭")) { - return "bg-rose-500"; // 빨간색 - 액션/이벤트 - } - if (role.includes("상세") || role.includes("detail") || role.includes("popup") || role.includes("팝업")) { - return "bg-amber-500"; // 주황색 - 상세/팝업 - } - - return "bg-slate-400"; // 기본 회색 +// 화면 역할(screenRole)에 따른 색상 - 더 이상 그라데이션 미사용 +const getScreenRoleColor = (_screenRole?: string) => { + return ""; }; // 화면 타입별 라벨 @@ -161,36 +148,26 @@ const getScreenTypeLabel = (screenType?: string) => { // ========== 화면 노드 (상단) - 미리보기 표시 ========== export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => { - const { label, subLabel, isMain, tableName, layoutSummary, isInGroup, isFocused, isFaded, screenRole } = data; + const { label, isMain, tableName, layoutSummary, isFocused, isFaded } = data; const screenType = layoutSummary?.screenType || "form"; - - // 그룹 모드에서는 screenRole 기반 색상, 그렇지 않으면 screenType 기반 색상 - // isFocused일 때 색상 활성화, isFaded일 때 회색 - let headerColor: string; - if (isInGroup) { - if (isFaded) { - headerColor = "bg-muted/60"; // 흑백 처리 - 더 확실한 회색 - } else { - // 포커스되었거나 아직 아무것도 선택 안됐을 때: 역할별 색상 - headerColor = getScreenRoleColor(screenRole); - } - } else { - headerColor = getScreenTypeColor(screenType, isMain); - } return (
{/* Handles */} @@ -198,78 +175,49 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => { type="target" position={Position.Left} id="left" - className="!h-2 !w-2 !border-2 !border-background !bg-primary opacity-0 transition-opacity group-hover:opacity-100" + className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-all duration-300 group-hover:opacity-100 group-hover:shadow-[0_0_6px_hsl(var(--primary)/0.5)]" /> - {/* 헤더 (컬러) */} -
- - {label} - {(isMain || isFocused) && } + {/* 헤더: 그라디언트 제거, 모노크롬 */} +
+
+ +
+
+
{label}
+ {tableName &&
{tableName}
} +
+ {(isMain || isFocused) && }
{/* 화면 미리보기 영역 (컴팩트) */} -
+
{layoutSummary ? ( ) : ( -
+
{getScreenTypeIcon(screenType)} 화면: {label}
)}
- {/* 필드 매핑 영역 */} -
-
- - 필드 매핑 - - {layoutSummary?.layoutItems?.filter(i => i.label && !i.componentKind?.includes('button')).length || 0}개 - -
-
- {layoutSummary?.layoutItems - ?.filter(item => item.label && !item.componentKind?.includes('button')) - ?.slice(0, 6) - ?.map((item, idx) => ( -
-
- {item.label} - {item.componentKind?.split('-')[0] || 'field'} -
- )) || ( -
필드 정보 없음
- )} -
-
- - {/* 푸터 (테이블 정보) */} -
-
- - {tableName || "No Table"} -
- - {getScreenTypeLabel(screenType)} - + {/* 푸터 (타입 칩 + 컴포넌트 수) */} +
+ {getScreenTypeLabel(screenType)} + {layoutSummary?.totalComponents ?? 0}개 컴포넌트
); @@ -280,33 +228,33 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => { const getComponentColor = (componentKind: string) => { // 테이블/그리드 관련 if (componentKind === "table-list" || componentKind === "data-grid") { - return "bg-violet-200 border-violet-400"; + return "bg-primary/20 border-primary/40"; } // 검색 필터 if (componentKind === "table-search-widget" || componentKind === "search-filter") { - return "bg-pink-200 border-pink-400"; + return "bg-destructive/20 border-destructive/40"; } // 버튼 관련 if (componentKind?.includes("button")) { - return "bg-blue-300 border-primary"; + return "bg-primary/30 border-primary"; } // 입력 필드 if (componentKind?.includes("input") || componentKind?.includes("text")) { - return "bg-slate-200 border-slate-400"; + return "bg-muted border-border"; } // 셀렉트/드롭다운 if (componentKind?.includes("select") || componentKind?.includes("dropdown")) { - return "bg-amber-200 border-amber-400"; + return "bg-warning/20 border-warning/40"; } // 차트 if (componentKind?.includes("chart")) { - return "bg-emerald-200 border-emerald-400"; + return "bg-success/20 border-success/40"; } // 커스텀 위젯 if (componentKind === "custom") { - return "bg-pink-200 border-pink-400"; + return "bg-destructive/20 border-destructive/40"; } - return "bg-slate-100 border-slate-300"; + return "bg-muted/50 border-border"; }; // ========== 화면 미리보기 컴포넌트 - 화면 타입별 간단한 일러스트 ========== @@ -316,130 +264,114 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType: }) => { const { totalComponents, widgetCounts } = layoutSummary; - // 그리드 화면 일러스트 + // 그리드 화면 일러스트 (모노크롬) if (screenType === "grid") { - return ( -
+ return ( +
{/* 상단 툴바 */}
-
+
-
-
-
+
+
+
{/* 테이블 헤더 */} -
+
{[...Array(5)].map((_, i) => ( -
+
))}
{/* 테이블 행들 */}
{[...Array(7)].map((_, i) => ( -
+
{[...Array(5)].map((_, j) => ( -
+
))}
))}
{/* 페이지네이션 */}
-
-
-
-
-
- {/* 컴포넌트 수 */} -
- {totalComponents}개 +
+
+
+
); } - // 폼 화면 일러스트 + // 폼 화면 일러스트 (모노크롬) if (screenType === "form") { return ( -
+
{/* 폼 필드들 */} {[...Array(6)].map((_, i) => (
-
-
+
+
))} {/* 버튼 영역 */} -
-
-
-
- {/* 컴포넌트 수 */} -
- {totalComponents}개 +
+
+
); } - // 대시보드 화면 일러스트 + // 대시보드 화면 일러스트 (모노크롬) if (screenType === "dashboard") { return ( -
+
{/* 카드/차트들 */} -
-
-
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
{[...Array(10)].map((_, i) => (
))}
- {/* 컴포넌트 수 */} -
- {totalComponents}개 -
-
- ); - } - - // 액션 화면 일러스트 (버튼 중심) - if (screenType === "action") { - return ( -
-
- -
-
-
-
-
-
액션 화면
- {/* 컴포넌트 수 */} -
- {totalComponents}개 -
); } - // 기본 (알 수 없는 타입) + // 액션 화면 일러스트 (모노크롬) + if (screenType === "action") { + return ( +
+
+ +
+
+
+
+
+
액션 화면
+
+ ); + } + + // 기본 (알 수 없는 타입, 모노크롬) return ( -
-
+
+
{getScreenTypeIcon(screenType)}
{totalComponents}개 컴포넌트 @@ -574,21 +506,21 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { return (
= ({ data }) => { className="absolute -left-1.5 top-1 bottom-1 w-0.5 z-20 rounded-full transition-all duration-500 ease-out" title={hasSaveTarget ? "저장 대상 테이블" : undefined} style={{ - background: 'linear-gradient(to bottom, transparent 0%, #f472b6 15%, #f472b6 85%, transparent 100%)', + background: `linear-gradient(to bottom, transparent 0%, hsl(var(--destructive)) 15%, hsl(var(--destructive)) 85%, transparent 100%)`, opacity: hasSaveTarget ? 1 : 0, transform: hasSaveTarget ? 'scaleY(1)' : 'scaleY(0)', transformOrigin: 'top', @@ -616,7 +548,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { type="target" position={Position.Top} id="top" - className="!h-2 !w-2 !border-2 !border-background !bg-emerald-500 opacity-0 transition-opacity group-hover:opacity-100" + className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100" /> {/* top source: 메인테이블 → 메인테이블 연결용 (위쪽으로 나가는 선) */} = ({ data }) => { position={Position.Top} id="top_source" style={{ top: -4 }} - className="!h-2 !w-2 !border-2 !border-background !bg-amber-500 opacity-0 transition-opacity group-hover:opacity-100" + className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100" /> {/* bottom target: 메인테이블 ← 메인테이블 연결용 (아래에서 들어오는 선) */} = ({ data }) => { position={Position.Bottom} id="bottom_target" style={{ bottom: -4 }} - className="!h-2 !w-2 !border-2 !border-background !bg-amber-500 opacity-0 transition-opacity group-hover:opacity-100" + className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100" /> - {/* 헤더 (필터 관계: 보라색, 필터 소스: 보라색, 메인: 초록색, 기본: 슬레이트) */} -
- + {/* 헤더: 그라디언트 제거, bg-muted/30 + 아이콘 박스 */} +
+
+ +
-
{label}
+
{label}
{/* 필터 관계에 따른 문구 변경 */} -
+
{isFilterSource ? "마스터 테이블 (필터 소스)" : hasFilterRelation @@ -670,8 +602,8 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
{hasActiveColumns && ( - - {displayColumns.length}개 활성 + + {displayColumns.length} ref )}
@@ -679,7 +611,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { {/* 컬럼 목록 - 컴팩트하게 (부드러운 높이 전환 + 스크롤) */} {/* 뱃지도 이 영역 안에 포함되어 높이 계산에 반영됨 */}
= ({ data }) => { {/* 필터 뱃지 */} {filterRefs.length > 0 && ( `${r.fromTable}.${r.fromColumn || 'id'} → ${r.toColumn}`).join('\n')}`} > @@ -707,14 +639,14 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { )} {filterRefs.length > 0 && ( - + {filterRefs.map(r => `${r.fromTableLabel || r.fromTable}.${r.fromColumnLabel || r.fromColumn || 'id'}`).join(', ')} )} {/* 참조 뱃지 */} {lookupRefs.length > 0 && ( `${r.fromTable} → ${r.toColumn}`).join('\n')}`} > {lookupRefs.length}곳 참조 @@ -745,33 +677,37 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { key={col.name} className={`flex items-center gap-1 rounded px-1.5 py-0.5 transition-all duration-300 ${ isJoinColumn - ? "bg-amber-100 border border-orange-300 shadow-sm" + ? "bg-warning/10 border border-warning/20 shadow-sm" : isFilterColumn || isFilterSourceColumn - ? "bg-violet-100 border border-violet-300 shadow-sm" // 필터 컬럼/필터 소스: 보라색 + ? "bg-primary/10 border border-primary/20 shadow-sm" // 필터 컬럼/필터 소스 : isHighlighted ? "bg-primary/10 border border-primary/40 shadow-sm" : hasActiveColumns - ? "bg-slate-100" - : "bg-slate-50 hover:bg-slate-100" + ? "bg-muted" + : "bg-muted/50 hover:bg-muted/80 transition-colors" }`} style={{ animation: hasActiveColumns ? `fadeIn 0.5s ease-out ${idx * 80}ms forwards` : undefined, opacity: hasActiveColumns ? 0 : 1, }} > - {/* PK/FK/조인/필터 아이콘 */} - {isJoinColumn && } - {(isFilterColumn || isFilterSourceColumn) && !isJoinColumn && } - {!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && col.isPrimaryKey && } - {!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && col.isForeignKey && !col.isPrimaryKey && } - {!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && !col.isPrimaryKey && !col.isForeignKey &&
} + {/* 3px 세로 마커 (PK/FK/조인/필터) */} +
{/* 컬럼명 */} {col.name} @@ -781,63 +717,74 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { <> {/* 조인 참조 테이블 표시 (joinColumnRefs에서) */} {joinRefMap.has(colOriginal) && ( - + ← {joinRefMap.get(colOriginal)?.refTableLabel} )} {/* 필드 매핑 참조 표시 (fieldMappingMap에서, joinRefMap에 없는 경우) */} {!joinRefMap.has(colOriginal) && fieldMappingMap.has(colOriginal) && ( - + ← {fieldMappingMap.get(colOriginal)?.sourceDisplayName} )} - 조인 + 조인 )} {isFilterColumn && !isJoinColumn && ( - 필터 + 필터 )} {/* 메인 테이블에서 필터 소스로 사용되는 컬럼: "필터" + "사용" 둘 다 표시 */} {isFilterSourceColumn && !isJoinColumn && !isFilterColumn && ( <> - 필터 + 필터 {isHighlighted && ( - 사용 + 사용 )} )} {isHighlighted && !isJoinColumn && !isFilterColumn && !isFilterSourceColumn && ( - 사용 + 사용 )} {/* 타입 */} - {col.type} + {col.type}
); })} {/* 더 많은 컬럼이 있을 경우 표시 */} {remainingCount > 0 && ( -
+
+ {remainingCount}개 더
)}
) : (
- - 컬럼 정보 없음 + + 컬럼 정보 없음
)}
- {/* 푸터 (컴팩트) */} -
- PostgreSQL - {columns && ( - - {hasActiveColumns ? `${displayColumns.length}/${totalCount}` : totalCount}개 컬럼 - - )} + {/* 푸터: cols + PK/FK 카운트 */} +
+ + {hasActiveColumns ? `${displayColumns.length}/${totalCount}` : totalCount} cols + +
+ {columns?.some(c => c.isPrimaryKey) && ( + + + PK {columns.filter(c => c.isPrimaryKey).length} + + )} + {columns?.some(c => c.isForeignKey) && ( + + + FK {columns.filter(c => c.isForeignKey).length} + + )} +
{/* CSS 애니메이션 정의 */} @@ -861,10 +808,10 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { export const LegacyScreenNode = ScreenNode; export const AggregateNode: React.FC<{ data: any }> = ({ data }) => { return ( -
- - -
+
+ + +
{data.label || "Aggregate"}
diff --git a/frontend/components/screen/ScreenRelationFlow.tsx b/frontend/components/screen/ScreenRelationFlow.tsx index cad4fc1f..87484840 100644 --- a/frontend/components/screen/ScreenRelationFlow.tsx +++ b/frontend/components/screen/ScreenRelationFlow.tsx @@ -4,6 +4,7 @@ import React, { useState, useEffect, useCallback } from "react"; import { ReactFlow, Controls, + MiniMap, Background, BackgroundVariant, Node, @@ -34,22 +35,31 @@ import { import { getTableColumns, ColumnTypeInfo } from "@/lib/api/tableManagement"; import { ScreenSettingModal } from "./ScreenSettingModal"; import { TableSettingModal } from "./TableSettingModal"; +import { AnimatedFlowEdge } from "./AnimatedFlowEdge"; +import { Monitor, Database, FolderOpen } from "lucide-react"; -// 관계 유형별 색상 정의 +// 관계 유형별 색상 정의 (CSS 변수 기반 - 다크모드 자동 대응) const RELATION_COLORS: Record = { - filter: { stroke: '#8b5cf6', strokeLight: '#c4b5fd', label: '마스터-디테일' }, // 보라색 - hierarchy: { stroke: '#06b6d4', strokeLight: '#a5f3fc', label: '계층 구조' }, // 시안색 - lookup: { stroke: '#f59e0b', strokeLight: '#fcd34d', label: '코드 참조' }, // 주황색 (기존) - mapping: { stroke: '#10b981', strokeLight: '#6ee7b7', label: '데이터 매핑' }, // 녹색 - join: { stroke: '#f97316', strokeLight: '#fdba74', label: '엔티티 조인' }, // orange-500 (기존 주황색) + filter: { stroke: 'hsl(var(--primary))', strokeLight: 'hsl(var(--primary) / 0.4)', label: '마스터-디테일' }, + hierarchy: { stroke: 'hsl(var(--info))', strokeLight: 'hsl(var(--info) / 0.4)', label: '계층 구조' }, + lookup: { stroke: 'hsl(var(--warning))', strokeLight: 'hsl(var(--warning) / 0.4)', label: '코드 참조' }, + mapping: { stroke: 'hsl(var(--success))', strokeLight: 'hsl(var(--success) / 0.4)', label: '데이터 매핑' }, + join: { stroke: 'hsl(var(--warning))', strokeLight: 'hsl(var(--warning) / 0.4)', label: '엔티티 조인' }, }; +// 엣지 필터 카테고리 (UI 토글용) +type EdgeCategory = 'main' | 'filter' | 'join' | 'lookup' | 'flow'; + // 노드 타입 등록 const nodeTypes = { screenNode: ScreenNode, tableNode: TableNode, }; +const edgeTypes = { + animatedFlow: AnimatedFlowEdge, +}; + // 레이아웃 상수 const SCREEN_Y = 50; // 화면 노드 Y 위치 (상단) const TABLE_Y = 420; // 메인 테이블 노드 Y 위치 (중단) @@ -89,6 +99,15 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId // 그룹 내 포커스된 화면 ID (그룹 모드에서만 사용) const [focusedScreenId, setFocusedScreenId] = useState(null); + // 엣지 필터 상태 (유형별 표시/숨김) + const [edgeFilterState, setEdgeFilterState] = useState>({ + main: true, + filter: true, + join: true, + lookup: false, + flow: true, + }); + // 노드 설정 모달 상태 const [isSettingModalOpen, setIsSettingModalOpen] = useState(false); const [settingModalNode, setSettingModalNode] = useState<{ @@ -414,7 +433,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId isFaded = focusedScreenId !== null && !isFocused; } else { // 개별 화면 모드: 메인 화면(선택된 화면)만 포커스, 연결 화면은 흐리게 - isFocused = isMain; + isFocused = !!isMain; isFaded = !isMain && screenList.length > 1; } @@ -426,7 +445,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId label: scr.screenName, subLabel: selectedGroup ? `${roleLabel} (#${scr.displayOrder || idx + 1})` : (isMain ? "메인 화면" : "연결 화면"), type: "screen", - isMain: selectedGroup ? idx === 0 : isMain, + isMain: selectedGroup ? idx === 0 : !!isMain, tableName: scr.tableName, layoutSummary: summary, // 화면 포커스 관련 속성 (그룹 모드 & 개별 모드 공통) @@ -687,14 +706,15 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId target: `screen-${nextScreen.screenId}`, sourceHandle: "right", targetHandle: "left", - type: "smoothstep", + type: "animatedFlow", label: `${i + 1}`, - labelStyle: { fontSize: 11, fill: "#0ea5e9", fontWeight: 600 }, + labelStyle: { fontSize: 11, fill: "hsl(var(--info))", fontWeight: 600 }, labelBgStyle: { fill: "hsl(var(--card))", stroke: "hsl(var(--border))", strokeWidth: 1 }, labelBgPadding: [4, 2] as [number, number], - markerEnd: { type: MarkerType.ArrowClosed, color: "#0ea5e9" }, + markerEnd: { type: MarkerType.ArrowClosed, color: "hsl(var(--info))" }, animated: true, - style: { stroke: "#0ea5e9", strokeWidth: 2 }, + style: { stroke: "hsl(var(--info))", strokeWidth: 2 }, + data: { edgeCategory: 'flow' as EdgeCategory }, }); } } @@ -709,12 +729,13 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId target: `table-${scr.tableName}`, sourceHandle: "bottom", targetHandle: "top", - type: "smoothstep", + type: "animatedFlow", animated: true, // 모든 메인 테이블 연결은 애니메이션 style: { - stroke: "#3b82f6", + stroke: "hsl(var(--primary))", strokeWidth: 2, }, + data: { edgeCategory: 'main' as EdgeCategory }, }); } }); @@ -748,15 +769,16 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId target: targetNodeId, sourceHandle: "bottom", targetHandle: "top", - type: "smoothstep", + type: "animatedFlow", animated: true, style: { - stroke: "#3b82f6", + stroke: "hsl(var(--primary))", strokeWidth: 2, strokeDasharray: "5,5", // 점선으로 필터 관계 표시 }, data: { sourceScreenId, + edgeCategory: 'filter' as EdgeCategory, }, }); @@ -793,7 +815,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId target: refTargetNodeId, sourceHandle: "bottom", targetHandle: "bottom_target", - type: "smoothstep", + type: "animatedFlow", animated: false, style: { stroke: RELATION_COLORS.join.strokeLight, // 초기값 (연한색) @@ -809,6 +831,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId sourceScreenId, isFilterJoin: true, visualRelationType: 'join', + edgeCategory: 'join' as EdgeCategory, }, }); }); @@ -901,7 +924,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId target: `table-${referencedTable}`, // 참조당하는 테이블 sourceHandle: "bottom", // 하단에서 나감 (서브테이블 구간으로) targetHandle: "bottom_target", // 하단으로 들어감 - type: "smoothstep", + type: "animatedFlow", animated: false, style: { stroke: relationColor.strokeLight, // 관계 유형별 연한 색상 @@ -919,6 +942,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId referrerTable, referencedTable, visualRelationType, // 관계 유형 저장 + edgeCategory: (visualRelationType === 'lookup' ? 'lookup' : 'join') as EdgeCategory, }, }); } @@ -944,7 +968,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId target: `subtable-${subTable.tableName}`, sourceHandle: "bottom", targetHandle: "top", - type: "smoothstep", + type: "animatedFlow", markerEnd: { type: MarkerType.ArrowClosed, color: relationColor.strokeLight @@ -959,6 +983,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId data: { sourceScreenId, visualRelationType, + edgeCategory: (visualRelationType === 'lookup' ? 'lookup' : 'join') as EdgeCategory, }, }); }); @@ -973,7 +998,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId target: `table-${join.join_table}`, sourceHandle: "bottom", targetHandle: "bottom_target", - type: "smoothstep", + type: "animatedFlow", markerEnd: { type: MarkerType.ArrowClosed, color: RELATION_COLORS.join.strokeLight @@ -985,31 +1010,33 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId strokeDasharray: "8,4", opacity: 0.5, }, - data: { visualRelationType: 'join' }, + data: { visualRelationType: 'join', edgeCategory: 'join' as EdgeCategory }, }); } }); - // 테이블 관계 엣지 (추가 관계) + // 테이블 관계 엣지 (추가 관계) - 참조용 화면(개별 모드: screen, 그룹 모드: screenList[0]) + const refScreen = screen ?? screenList[0]; relations.forEach((rel: any, idx: number) => { - if (rel.table_name && rel.table_name !== screen.tableName) { + if (rel.table_name && rel.table_name !== refScreen.tableName) { // 화면 → 연결 테이블 const edgeExists = newEdges.some( - (e) => e.source === `screen-${screen.screenId}` && e.target === `table-${rel.table_name}` + (e) => e.source === `screen-${refScreen.screenId}` && e.target === `table-${rel.table_name}` ); if (!edgeExists) { newEdges.push({ id: `edge-rel-${idx}`, - source: `screen-${screen.screenId}`, + source: `screen-${refScreen.screenId}`, target: `table-${rel.table_name}`, sourceHandle: "bottom", targetHandle: "top", - type: "smoothstep", + type: "animatedFlow", label: rel.relation_type === "join" ? "조인" : rel.crud_operations || "", - labelStyle: { fontSize: 9, fill: "#10b981" }, + labelStyle: { fontSize: 9, fill: "hsl(var(--success))" }, labelBgStyle: { fill: "hsl(var(--card))", stroke: "hsl(var(--border))", strokeWidth: 1 }, labelBgPadding: [3, 2] as [number, number], - style: { stroke: "#10b981", strokeWidth: 1.5 }, + style: { stroke: "hsl(var(--success))", strokeWidth: 1.5 }, + data: { edgeCategory: (rel.relation_type === 'lookup' ? 'lookup' : 'join') as EdgeCategory }, }); } } @@ -1017,23 +1044,24 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId // 데이터 흐름 엣지 (화면 간) flows - .filter((flow: any) => flow.source_screen_id === screen.screenId) + .filter((flow: any) => flow.source_screen_id === refScreen.screenId) .forEach((flow: any, idx: number) => { if (flow.target_screen_id) { newEdges.push({ id: `edge-flow-${idx}`, - source: `screen-${screen.screenId}`, + source: `screen-${refScreen.screenId}`, target: `screen-${flow.target_screen_id}`, sourceHandle: "right", targetHandle: "left", - type: "smoothstep", + type: "animatedFlow", animated: true, label: flow.flow_label || flow.flow_type || "이동", - labelStyle: { fontSize: 10, fill: "#8b5cf6", fontWeight: 500 }, + labelStyle: { fontSize: 10, fill: "hsl(var(--primary))", fontWeight: 500 }, labelBgStyle: { fill: "hsl(var(--card))", stroke: "hsl(var(--border))", strokeWidth: 1 }, labelBgPadding: [4, 2] as [number, number], - markerEnd: { type: MarkerType.ArrowClosed, color: "#8b5cf6" }, - style: { stroke: "#8b5cf6", strokeWidth: 2 }, + markerEnd: { type: MarkerType.ArrowClosed, color: "hsl(var(--primary))" }, + style: { stroke: "hsl(var(--primary))", strokeWidth: 2 }, + data: { edgeCategory: 'flow' as EdgeCategory }, }); } }); @@ -1134,7 +1162,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId // 화면 노드 우클릭 if (node.id.startsWith("screen-")) { const screenId = parseInt(node.id.replace("screen-", "")); - const nodeData = node.data as ScreenNodeData; + const nodeData = node.data as unknown as ScreenNodeData; const mainTable = screenTableMap[screenId]; // 해당 화면의 서브 테이블 (필터 테이블) 정보 @@ -1248,7 +1276,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId // 메인 테이블 노드 더블클릭 if (node.id.startsWith("table-") && !node.id.startsWith("table-sub-")) { const tableName = node.id.replace("table-", ""); - const nodeData = node.data as TableNodeData; + const nodeData = node.data as unknown as TableNodeData; // 이 테이블을 사용하는 화면 찾기 const screenId = Object.entries(screenTableMap).find( @@ -1293,7 +1321,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId // 서브 테이블 노드 더블클릭 if (node.id.startsWith("subtable-")) { const tableName = node.id.replace("subtable-", ""); - const nodeData = node.data as TableNodeData; + const nodeData = node.data as unknown as TableNodeData; // 이 서브 테이블을 사용하는 화면 찾기 const screenId = Object.entries(screenSubTableMap).find( @@ -1460,6 +1488,32 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId }); } + // lookup 필터 OFF일 때: lookup 연결만 있는 테이블 노드를 dim 처리 + const lookupOnlyNodes = new Set(); + if (!edgeFilterState.lookup) { + const nodeEdgeCategories = new Map>(); + edges.forEach((edge) => { + const category = (edge.data as any)?.edgeCategory as EdgeCategory | undefined; + if (!category) return; + [edge.source, edge.target].forEach((nodeId) => { + if (!nodeEdgeCategories.has(nodeId)) { + nodeEdgeCategories.set(nodeId, new Set()); + } + nodeEdgeCategories.get(nodeId)!.add(category); + }); + }); + nodeEdgeCategories.forEach((categories, nodeId) => { + if (nodeId.startsWith("table-") || nodeId.startsWith("subtable-")) { + const hasVisibleCategory = Array.from(categories).some( + (cat) => cat !== "lookup" && edgeFilterState[cat] + ); + if (!hasVisibleCategory) { + lookupOnlyNodes.add(nodeId); + } + } + }); + } + return nodes.map((node) => { // 화면 노드 스타일링 (포커스가 있을 때만) if (node.id.startsWith("screen-")) { @@ -1755,7 +1809,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId ...node.data, isFocused: isFocusedTable, isRelated: isRelatedTable, - isFaded: focusedScreenId !== null && !isActiveTable, + isFaded: (focusedScreenId !== null && !isActiveTable) || lookupOnlyNodes.has(node.id), highlightedColumns: isActiveTable ? highlightedColumns : [], joinColumns: isActiveTable ? joinColumns : [], joinColumnRefs: focusedJoinColumnRefs.length > 0 ? focusedJoinColumnRefs : undefined, // 조인 컬럼 참조 정보 @@ -1798,12 +1852,6 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId } }); } - - // 디버깅 로그 - console.log(`서브테이블 ${subTableName} (${subTableInfo?.relationType}):`, { - fieldMappings: subTableInfo?.fieldMappings, - extractedJoinColumns: subTableJoinColumns - }); } // 서브 테이블의 highlightedColumns도 추가 (화면에서 서브테이블 컬럼을 직접 사용하는 경우) @@ -1872,7 +1920,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId data: { ...node.data, isFocused: isActiveSubTable, - isFaded: !isActiveSubTable, + isFaded: !isActiveSubTable || lookupOnlyNodes.has(node.id), highlightedColumns: isActiveSubTable ? subTableHighlightedColumns : [], joinColumns: isActiveSubTable ? subTableJoinColumns : [], fieldMappings: isActiveSubTable ? displayFieldMappings : [], @@ -1883,7 +1931,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId return node; }); - }, [nodes, selectedGroup, focusedScreenId, screenSubTableMap, subTablesDataMap, screenUsedColumnsMap, screenTableMap, tableColumns]); + }, [nodes, selectedGroup, focusedScreenId, screenSubTableMap, subTablesDataMap, screenUsedColumnsMap, screenTableMap, tableColumns, edgeFilterState, edges]); // 포커스에 따른 엣지 스타일링 (그룹 모드 & 개별 화면 모드) const styledEdges = React.useMemo(() => { @@ -1903,9 +1951,9 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId animated: isConnected, style: { ...edge.style, - stroke: isConnected ? "#8b5cf6" : "#d1d5db", - strokeWidth: isConnected ? 2 : 1, - opacity: isConnected ? 1 : 0.3, + stroke: isConnected ? "hsl(var(--primary))" : "hsl(var(--border))", + strokeWidth: isConnected ? 2.5 : 1, + opacity: isConnected ? 1 : 0.2, }, }; } @@ -1920,10 +1968,10 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId animated: isMyConnection, style: { ...edge.style, - stroke: isMyConnection ? "#3b82f6" : "#d1d5db", - strokeWidth: isMyConnection ? 2 : 1, + stroke: isMyConnection ? "hsl(var(--primary))" : "hsl(var(--border))", + strokeWidth: isMyConnection ? 2.5 : 1, strokeDasharray: isMyConnection ? undefined : "5,5", - opacity: isMyConnection ? 1 : 0.3, + opacity: isMyConnection ? 1 : 0.2, }, }; } @@ -1998,11 +2046,11 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId target: targetNodeId, sourceHandle: 'bottom', // 고정: 서브테이블 구간 통과 targetHandle: 'bottom_target', // 고정: 서브테이블 구간 통과 - type: 'smoothstep', + type: "animatedFlow", animated: true, style: { stroke: relationColor.stroke, // 관계 유형별 색상 - strokeWidth: 2, + strokeWidth: 2.5, strokeDasharray: '8,4', }, markerEnd: { @@ -2040,9 +2088,9 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId animated: isConnected, style: { ...edge.style, - stroke: isConnected ? "#8b5cf6" : "#d1d5db", - strokeWidth: isConnected ? 2 : 1, - opacity: isConnected ? 1 : 0.3, + stroke: isConnected ? "hsl(var(--primary))" : "hsl(var(--border))", + strokeWidth: isConnected ? 2.5 : 1, + opacity: isConnected ? 1 : 0.2, }, }; } @@ -2076,8 +2124,8 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId animated: true, style: { ...edge.style, - stroke: "#3b82f6", - strokeWidth: 2, + stroke: "hsl(var(--primary))", + strokeWidth: 2.5, strokeDasharray: "5,5", opacity: 1, }, @@ -2095,10 +2143,10 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId animated: isMyConnection, style: { ...edge.style, - stroke: isMyConnection ? "#3b82f6" : "#d1d5db", - strokeWidth: isMyConnection ? 2 : 1, + stroke: isMyConnection ? "hsl(var(--primary))" : "hsl(var(--border))", + strokeWidth: isMyConnection ? 2.5 : 1, strokeDasharray: isMyConnection ? undefined : "5,5", - opacity: isMyConnection ? 1 : 0.3, + opacity: isMyConnection ? 1 : 0.2, }, }; } @@ -2155,7 +2203,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId stroke: isActive ? relationColor.stroke : relationColor.strokeLight, strokeWidth: isActive ? 2.5 : 1.5, strokeDasharray: "8,4", - opacity: isActive ? 1 : 0.3, + opacity: isActive ? 1 : 0.2, }, markerEnd: { type: MarkerType.ArrowClosed, @@ -2179,7 +2227,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId stroke: RELATION_COLORS.join.strokeLight, strokeWidth: 1.5, strokeDasharray: "6,4", - opacity: 0.3, + opacity: 0.2, }, markerEnd: { type: MarkerType.ArrowClosed, @@ -2206,7 +2254,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId style: { ...edge.style, stroke: RELATION_COLORS.join.stroke, - strokeWidth: 2, + strokeWidth: 2.5, strokeDasharray: "6,4", opacity: 1, }, @@ -2282,8 +2330,19 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId }); // 기존 엣지 + 조인 관계 엣지 합치기 - return [...styledOriginalEdges, ...joinEdges]; - }, [edges, nodes, selectedGroup, focusedScreenId, screen, screenSubTableMap, subTablesDataMap, screenTableMap]); + const allEdges = [...styledOriginalEdges, ...joinEdges]; + // 엣지 필터 적용 (edgeFilterState에 따라 숨김) + return allEdges.map((edge) => { + const category = (edge.data as any)?.edgeCategory as EdgeCategory | undefined; + if (category && !edgeFilterState[category]) { + return { + ...edge, + hidden: true, + }; + } + return edge; + }); + }, [edges, nodes, selectedGroup, focusedScreenId, screen, screenSubTableMap, subTablesDataMap, screenTableMap, edgeFilterState]); // 그룹의 화면 목록 (데이터 흐름 설정용) - 모든 조건부 return 전에 선언해야 함 const groupScreensList = React.useMemo(() => { @@ -2300,10 +2359,38 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId // 조건부 렌더링 (모든 훅 선언 후에 위치해야 함) if (!screen && !selectedGroup) { return ( -
-
-

그룹 또는 화면을 선택하면

-

데이터 관계가 시각화됩니다

+
+
+
+
+ +
+
+
+ +
+
+
+
+

화면 관계 시각화

+

+ 좌측에서 그룹 또는 화면을 선택하면
+ 테이블 관계가 자동으로 시각화됩니다. +

+
+
+
+ 1 + 그룹 선택 +
+
+ 2 + 관계 확인 +
+
+ 3 + 화면 편집 +
); @@ -2318,10 +2405,60 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId } return ( -
+
+ {/* 선택 정보 바 (캔버스 상단) */} + {(screen || selectedGroup) && ( +
+ {selectedGroup && ( + <> + + {selectedGroup.name} + + )} + {screen && !selectedGroup && ( + <> + + {screen.screenName} + {screen.screenCode} + + )} + +
+ 연결 + + {( + [ + { 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 ( + + ); + })} +
+ )} {/* isViewReady가 false면 숨김 처리하여 깜빡임 방지 */}
- - + + + + + + + + + + + + + + + { + 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", + }} + />
@@ -2353,7 +2520,6 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId fieldMappings={settingModalNode.existingConfig?.fieldMappings} componentCount={0} onSaveSuccess={handleRefreshVisualization} - isPop={isPop} /> )} @@ -2367,7 +2533,13 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId screenId={settingModalNode.screenId} joinColumnRefs={settingModalNode.existingConfig?.joinColumnRefs} referencedBy={settingModalNode.existingConfig?.referencedBy} - columns={settingModalNode.existingConfig?.columns} + columns={settingModalNode.existingConfig?.columns?.map((col) => ({ + column: col.originalName ?? col.name, + label: col.name, + type: col.type, + isPK: col.isPrimaryKey, + isFK: col.isForeignKey, + }))} filterColumns={settingModalNode.existingConfig?.filterColumns} onSaveSuccess={handleRefreshVisualization} /> diff --git a/frontend/components/screen/panels/V2PropertiesPanel.tsx b/frontend/components/screen/panels/V2PropertiesPanel.tsx index cf148e6e..ef739b27 100644 --- a/frontend/components/screen/panels/V2PropertiesPanel.tsx +++ b/frontend/components/screen/panels/V2PropertiesPanel.tsx @@ -47,6 +47,7 @@ import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent // ComponentRegistry import (동적 ConfigPanel 가져오기용) import { ComponentRegistry } from "@/lib/registry/ComponentRegistry"; +import { columnMetaCache } from "@/lib/registry/DynamicComponentRenderer"; import { DynamicComponentConfigPanel, hasComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel"; import StyleEditor from "../StyleEditor"; import { Slider } from "@/components/ui/slider"; @@ -207,28 +208,36 @@ export const V2PropertiesPanel: React.FC = ({ onUpdateProperty(selectedComponent.id, "componentConfig", { ...currentConfig, ...newConfig }); }; - // 컬럼의 inputType 가져오기 (entity 타입인지 확인용) - const inputType = currentConfig.inputType || currentConfig.webType || (selectedComponent as any).inputType; - // 현재 화면의 테이블명 가져오기 const currentTableName = tables?.[0]?.tableName; + // DB input_type 가져오기 (columnMetaCache에서 최신값 조회) + const colName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName; + const tblName = selectedComponent.tableName || currentTable?.tableName || currentTableName; + const dbMeta = colName && tblName && !colName.includes(".") ? columnMetaCache[tblName]?.[colName] : undefined; + const dbInputType = dbMeta ? (() => { const raw = dbMeta.input_type || dbMeta.inputType; return raw === "direct" || raw === "auto" ? undefined : raw; })() : undefined; + const inputType = dbInputType || currentConfig.inputType || currentConfig.webType || (selectedComponent as any).inputType; + // 컴포넌트별 추가 props const extraProps: Record = {}; - if (componentId === "v2-select") { + const resolvedTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName; + const resolvedColumnName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName; + + if (componentId === "v2-input" || componentId === "v2-select") { extraProps.inputType = inputType; - extraProps.tableName = selectedComponent.tableName || currentTable?.tableName || currentTableName; - extraProps.columnName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName; + extraProps.tableName = resolvedTableName; + extraProps.columnName = resolvedColumnName; + extraProps.screenTableName = resolvedTableName; + } + if (componentId === "v2-input") { + extraProps.allComponents = allComponents; } if (componentId === "v2-list") { extraProps.currentTableName = currentTableName; } if (componentId === "v2-bom-item-editor" || componentId === "v2-bom-tree") { extraProps.currentTableName = currentTableName; - extraProps.screenTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName; - } - if (componentId === "v2-input") { - extraProps.allComponents = allComponents; + extraProps.screenTableName = resolvedTableName; } return ( diff --git a/frontend/components/v2/config-panels/V2FieldConfigPanel.tsx b/frontend/components/v2/config-panels/V2FieldConfigPanel.tsx index 7dfe8834..2f2b8011 100644 --- a/frontend/components/v2/config-panels/V2FieldConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2FieldConfigPanel.tsx @@ -78,7 +78,15 @@ interface CategoryValueOption { } // ─── 하위 호환: 기존 config에서 fieldType 추론 ─── -function resolveFieldType(config: Record, componentType?: string): FieldType { +function resolveFieldType(config: Record, componentType?: string, metaInputType?: string): FieldType { + // DB input_type이 전달된 경우 (데이터타입관리에서 변경 시) 우선 적용 + if (metaInputType && metaInputType !== "direct" && metaInputType !== "auto") { + const dbType = metaInputType as FieldType; + if (["text", "number", "textarea", "numbering", "select", "category", "entity"].includes(dbType)) { + return dbType; + } + } + if (config.fieldType) return config.fieldType as FieldType; // v2-select 계열 @@ -207,7 +215,7 @@ export const V2FieldConfigPanel: React.FC = ({ inputType: metaInputType, componentType, }) => { - const fieldType = resolveFieldType(config, componentType); + const fieldType = resolveFieldType(config, componentType, metaInputType); const isSelectGroup = ["select", "category", "entity"].includes(fieldType); // ─── 채번 관련 상태 (테이블 기반) ─── diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 873b7408..859d136f 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -13,13 +13,34 @@ import { apiClient } from "@/lib/api/client"; import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor"; // 컬럼 메타데이터 캐시 (테이블명 → 컬럼 설정 맵) -const columnMetaCache: Record> = {}; +export const columnMetaCache: Record> = {}; const columnMetaLoading: Record> = {}; +const columnMetaTimestamp: Record = {}; +const CACHE_TTL_MS = 5000; -async function loadColumnMeta(tableName: string): Promise { - if (columnMetaCache[tableName]) return; +export function invalidateColumnMetaCache(tableName?: string): void { + if (tableName) { + delete columnMetaCache[tableName]; + delete columnMetaLoading[tableName]; + delete columnMetaTimestamp[tableName]; + } else { + for (const key of Object.keys(columnMetaCache)) delete columnMetaCache[key]; + for (const key of Object.keys(columnMetaLoading)) delete columnMetaLoading[key]; + for (const key of Object.keys(columnMetaTimestamp)) delete columnMetaTimestamp[key]; + } +} + +async function loadColumnMeta(tableName: string, forceReload = false): Promise { + const now = Date.now(); + const isStale = columnMetaTimestamp[tableName] && (now - columnMetaTimestamp[tableName] > CACHE_TTL_MS); + + if (!forceReload && !isStale && columnMetaCache[tableName]) return; + + if (forceReload || isStale) { + delete columnMetaCache[tableName]; + delete columnMetaLoading[tableName]; + } - // 이미 로딩 중이면 해당 Promise를 대기 (race condition 방지) if (columnMetaLoading[tableName]) { await columnMetaLoading[tableName]; return; @@ -36,6 +57,7 @@ async function loadColumnMeta(tableName: string): Promise { if (name) map[name] = col; } columnMetaCache[tableName] = map; + columnMetaTimestamp[tableName] = Date.now(); } catch (e) { console.error(`[columnMeta] ${tableName} 로드 실패:`, e); columnMetaCache[tableName] = {}; @@ -56,43 +78,59 @@ export function isColumnRequiredByMeta(tableName?: string, columnName?: string): return nullable === "NO" || nullable === "N"; } -// table_type_columns 기반 componentConfig 병합 (기존 설정이 없을 때만 DB 메타데이터로 보완) +// table_type_columns 기반 componentConfig 병합 (DB input_type 우선 적용) function mergeColumnMeta(tableName: string | undefined, columnName: string | undefined, componentConfig: any): any { if (!tableName || !columnName) return componentConfig; const meta = columnMetaCache[tableName]?.[columnName]; if (!meta) return componentConfig; - const inputType = meta.input_type || meta.inputType; - if (!inputType) return componentConfig; - - // 이미 source가 올바르게 설정된 경우 건드리지 않음 - const existingSource = componentConfig?.source; - if (existingSource && existingSource !== "static" && existingSource !== "distinct" && existingSource !== "select") { - return componentConfig; - } + const rawType = meta.input_type || meta.inputType; + const dbInputType = rawType === "direct" || rawType === "auto" ? undefined : rawType; + if (!dbInputType) return componentConfig; const merged = { ...componentConfig }; + const savedFieldType = merged.fieldType; - // source가 미설정/기본값일 때만 DB 메타데이터로 보완 - if (inputType === "entity") { + // savedFieldType이 있고 DB와 같으면 변경 불필요 + if (savedFieldType && savedFieldType === dbInputType) return merged; + // savedFieldType이 있고 DB와 다르면 — 사용자가 V2FieldConfigPanel에서 설정한 값 존중 + if (savedFieldType) return merged; + + // savedFieldType이 없으면: DB input_type 기준으로 동기화 + // 기존 overrides의 source/inputType이 DB와 불일치하면 덮어씀 + if (dbInputType === "entity") { const refTable = meta.reference_table || meta.referenceTable; const refColumn = meta.reference_column || meta.referenceColumn; const displayCol = meta.display_column || meta.displayColumn; - if (refTable && !merged.entityTable) { + if (refTable) { merged.source = "entity"; merged.entityTable = refTable; merged.entityValueColumn = refColumn || "id"; merged.entityLabelColumn = displayCol || "name"; + merged.fieldType = "entity"; + merged.inputType = "entity"; } - } else if (inputType === "category" && !existingSource) { + } else if (dbInputType === "category") { merged.source = "category"; - } else if (inputType === "select" && !existingSource) { + merged.fieldType = "category"; + merged.inputType = "category"; + } else if (dbInputType === "select") { + if (!merged.source || merged.source === "category" || merged.source === "entity") { + merged.source = "static"; + } const detail = typeof meta.detail_settings === "string" ? JSON.parse(meta.detail_settings || "{}") : meta.detail_settings || {}; if (detail.options && !merged.options?.length) { merged.options = detail.options; } + merged.fieldType = "select"; + merged.inputType = "select"; + } else { + // text, number, textarea 등 input 계열 — 카테고리 잔류 속성 제거 + merged.fieldType = dbInputType; + merged.inputType = dbInputType; + delete merged.source; } return merged; @@ -266,15 +304,27 @@ export const DynamicComponentRenderer: React.FC = children, ...props }) => { - // 컬럼 메타데이터 로드 트리거 (테이블명이 있으면 비동기 로드) + // 컬럼 메타데이터 로드 트리거 (TTL 기반 자동 갱신) const screenTableName = props.tableName || (component as any).tableName; - const [, forceUpdate] = React.useState(0); + const [metaVersion, forceUpdate] = React.useState(0); React.useEffect(() => { if (screenTableName) { loadColumnMeta(screenTableName).then(() => forceUpdate((v) => v + 1)); } }, [screenTableName]); + // table-columns-refresh 이벤트 수신 시 캐시 무효화 후 최신 메타 다시 로드 + React.useEffect(() => { + const handler = () => { + if (screenTableName) { + invalidateColumnMetaCache(screenTableName); + loadColumnMeta(screenTableName, true).then(() => forceUpdate((v) => v + 1)); + } + }; + window.addEventListener("table-columns-refresh", handler); + return () => window.removeEventListener("table-columns-refresh", handler); + }, [screenTableName]); + // 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 type 사용 // 🆕 V2 레이아웃의 경우 url에서 컴포넌트 타입 추출 (예: "@/lib/registry/components/v2-input" → "v2-input") const extractTypeFromUrl = (url: string | undefined): string | undefined => { @@ -306,12 +356,40 @@ export const DynamicComponentRenderer: React.FC = const mappedComponentType = mapToV2ComponentType(rawComponentType); - // fieldType 기반 동적 컴포넌트 전환 (통합 필드 설정 패널에서 설정된 값) + // fieldType 기반 동적 컴포넌트 전환 (사용자 설정 > DB input_type > 기본값) const componentType = (() => { - const ft = (component as any).componentConfig?.fieldType; - if (!ft) return mappedComponentType; - if (["text", "number", "password", "textarea", "slider", "color", "numbering"].includes(ft)) return "v2-input"; - if (["select", "category", "entity"].includes(ft)) return "v2-select"; + const configFieldType = (component as any).componentConfig?.fieldType; + const fieldName = (component as any).columnName || (component as any).componentConfig?.fieldKey || (component as any).componentConfig?.columnName; + const isEntityJoin = fieldName?.includes("."); + const baseCol = isEntityJoin ? undefined : fieldName; + const rawDbType = baseCol && screenTableName + ? (columnMetaCache[screenTableName]?.[baseCol]?.input_type || columnMetaCache[screenTableName]?.[baseCol]?.inputType) + : undefined; + const dbInputType = rawDbType === "direct" || rawDbType === "auto" ? undefined : rawDbType; + + // 디버그 (division, unit 필드만) - 문제 확인 후 제거 + if (baseCol && (baseCol === "division" || baseCol === "unit")) { + const result = configFieldType + ? (["text","number","password","textarea","slider","color","numbering"].includes(configFieldType) ? "v2-input" : "v2-select") + : dbInputType + ? (["text","number","password","textarea","slider","color","numbering"].includes(dbInputType) ? "v2-input" : "v2-select") + : mappedComponentType; + const skipCat = dbInputType && !["category", "entity", "select"].includes(dbInputType); + console.log(`[DCR] ${baseCol}: dbInputType=${dbInputType}, RESULT=${result}, skipCat=${skipCat}`); + } + + // 사용자가 V2FieldConfigPanel에서 명시적으로 설정한 fieldType 최우선 + if (configFieldType) { + if (["text", "number", "password", "textarea", "slider", "color", "numbering"].includes(configFieldType)) return "v2-input"; + if (["select", "category", "entity"].includes(configFieldType)) return "v2-select"; + } + + // componentConfig.fieldType 없으면 DB input_type 참조 (초기 로드 시) + if (dbInputType) { + if (["text", "number", "password", "textarea", "slider", "color", "numbering"].includes(dbInputType)) return "v2-input"; + if (["select", "category", "entity"].includes(dbInputType)) return "v2-select"; + } + return mappedComponentType; })(); @@ -376,15 +454,24 @@ export const DynamicComponentRenderer: React.FC = // (v2-input, v2-select, v2-repeat-container 등 모두 동일하게 처리) // 🎯 카테고리 타입 우선 처리 (inputType 또는 webType 확인) - const inputType = (component as any).componentConfig?.inputType || (component as any).inputType; + // DB input_type이 "text" 등 비-카테고리로 변경된 경우 이 분기를 건너뜀 + const savedInputType = (component as any).componentConfig?.inputType || (component as any).inputType; const webType = (component as any).componentConfig?.webType; const tableName = (component as any).tableName; const columnName = (component as any).columnName; - // 카테고리 셀렉트: webType이 "category"이고 tableName과 columnName이 있는 경우만 - // ⚠️ 단, 다음 경우는 V2SelectRenderer로 직접 처리 (고급 모드 지원): - // 1. componentType이 "select-basic" 또는 "v2-select"인 경우 - // 2. config.mode가 dropdown이 아닌 경우 (radio, check, tagbox 등) + // DB input_type 확인: 데이터타입관리에서 변경한 최신 값이 레이아웃 저장값보다 우선 + const dbMetaForField = columnName && screenTableName && !columnName.includes(".") + ? columnMetaCache[screenTableName]?.[columnName] + : undefined; + const dbFieldInputType = dbMetaForField + ? (() => { const raw = dbMetaForField.input_type || dbMetaForField.inputType; return raw === "direct" || raw === "auto" ? undefined : raw; })() + : undefined; + // DB에서 확인된 타입이 있으면 그걸 사용, 없으면 저장된 값 사용 + const inputType = dbFieldInputType || savedInputType; + // webType도 DB 값으로 대체 (레이아웃에 webType: "category" 하드코딩되어 있을 수 있음) + const effectiveWebType = dbFieldInputType || webType; + const componentMode = (component as any).componentConfig?.mode || (component as any).config?.mode; const isMultipleSelect = (component as any).componentConfig?.multiple; const nonDropdownModes = ["radio", "check", "checkbox", "tag", "tagbox", "toggle", "swap", "combobox"]; @@ -392,7 +479,11 @@ export const DynamicComponentRenderer: React.FC = const shouldUseV2Select = componentType === "select-basic" || componentType === "v2-select" || isNonDropdownMode || isMultipleSelect; - if ((inputType === "category" || webType === "category") && tableName && columnName && shouldUseV2Select) { + // DB input_type이 비-카테고리(text 등)로 확인된 경우, 레이아웃에 category가 남아있어도 카테고리 분기 강제 스킵 + // dbFieldInputType이 있으면(캐시 로드됨) 그 값으로 판단, 없으면 기존 로직 유지 + const isDbConfirmedNonCategory = dbFieldInputType && !["category", "entity", "select"].includes(dbFieldInputType); + + if (!isDbConfirmedNonCategory && (inputType === "category" || effectiveWebType === "category") && tableName && columnName && shouldUseV2Select) { // V2SelectRenderer로 직접 렌더링 (카테고리 + 고급 모드) try { const { V2SelectRenderer } = require("@/lib/registry/components/v2-select/V2SelectRenderer"); @@ -491,7 +582,7 @@ export const DynamicComponentRenderer: React.FC = } catch (error) { console.error("❌ V2SelectRenderer 로드 실패:", error); } - } else if ((inputType === "category" || webType === "category") && tableName && columnName) { + } else if (!isDbConfirmedNonCategory && (inputType === "category" || effectiveWebType === "category") && tableName && columnName) { try { const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent"); const fieldName = columnName || component.id; diff --git a/test-output/screen-149-field-type-verification-guide.md b/test-output/screen-149-field-type-verification-guide.md new file mode 100644 index 00000000..a18bfb11 --- /dev/null +++ b/test-output/screen-149-field-type-verification-guide.md @@ -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"))` 호출 후 재검증