"use client"; import React, { useMemo, useState, useEffect } from "react"; import { Handle, Position } from "@xyflow/react"; import { Monitor, Database, FormInput, Table2, LayoutDashboard, MousePointer2, Key, Link2, Columns3, } from "lucide-react"; import { ScreenLayoutSummary } from "@/lib/api/screenGroup"; // ========== 타입 정의 ========== // 화면 노드 데이터 인터페이스 export interface ScreenNodeData { label: string; subLabel?: string; type: "screen" | "table" | "action"; tableName?: string; isMain?: boolean; // 레이아웃 요약 정보 (미리보기용) layoutSummary?: ScreenLayoutSummary; // 그룹 내 포커스 관련 속성 isInGroup?: boolean; // 그룹 모드인지 isFocused?: boolean; // 포커스된 화면인지 isFaded?: boolean; // 흑백 처리할지 screenRole?: string; // 화면 역할 (메인그리드, 등록폼 등) } // 필드 매핑 정보 (조인 관계 표시용) export interface FieldMappingDisplay { sourceField: string; // 메인 테이블 컬럼 (예: manager_id) targetField: string; // 서브 테이블 컬럼 (예: user_id) sourceDisplayName?: string; // 메인 테이블 한글 컬럼명 (예: 담당자) targetDisplayName?: string; // 서브 테이블 한글 컬럼명 (예: 사용자ID) sourceTable?: string; // 소스 테이블명 (필드 매핑에서 테이블 구분용) } // 참조 관계 정보 (다른 테이블에서 이 테이블을 참조하는 경우) export interface ReferenceInfo { fromTable: string; // 참조하는 테이블명 (영문) fromTableLabel?: string; // 참조하는 테이블 한글명 fromColumn: string; // 참조하는 컬럼명 (영문) fromColumnLabel?: string; // 참조하는 컬럼 한글명 toColumn: string; // 참조되는 컬럼명 (이 테이블의 컬럼) toColumnLabel?: string; // 참조되는 컬럼 한글명 relationType: 'lookup' | 'join' | 'filter'; // 참조 유형 } // 테이블 노드 데이터 인터페이스 export interface TableNodeData { label: string; subLabel?: string; isMain?: boolean; isFocused?: boolean; // 포커스된 테이블인지 isFaded?: boolean; // 흑백 처리할지 columns?: Array<{ name: string; // 표시용 이름 (한글명) originalName?: string; // 원본 컬럼명 (영문, 필터링용) type: string; isPrimaryKey?: boolean; isForeignKey?: boolean; }>; // 포커스 시 강조할 컬럼 정보 highlightedColumns?: string[]; // 화면에서 사용하는 컬럼 (영문명) joinColumns?: string[]; // 조인에 사용되는 컬럼 joinColumnRefs?: Array<{ // 조인 컬럼의 참조 정보 column: string; // FK 컬럼명 (예: 'customer_id') refTable: string; // 참조 테이블 (예: 'customer_mng') refTableLabel?: string; // 참조 테이블 한글명 (예: '거래처 관리') refColumn: string; // 참조 컬럼 (예: 'customer_code') }>; filterColumns?: string[]; // 필터링에 사용되는 FK 컬럼 (마스터-디테일 관계) // 필드 매핑 정보 (조인 관계 표시용) fieldMappings?: FieldMappingDisplay[]; // 서브 테이블일 때 조인 관계 표시 // 참조 관계 정보 (다른 테이블에서 이 테이블을 참조하는 경우) referencedBy?: ReferenceInfo[]; // 이 테이블을 참조하는 관계들 // 저장 관계 정보 saveInfos?: Array<{ saveType: string; // 'save' | 'edit' | 'delete' | 'transferData' componentType: string; // 버튼 컴포넌트 타입 isMainTable: boolean; // 메인 테이블 저장인지 sourceScreenId?: number; // 어떤 화면에서 저장하는지 }>; } // ========== 유틸리티 함수 ========== // 화면 타입별 아이콘 const getScreenTypeIcon = (screenType?: string) => { switch (screenType) { case "grid": return ; case "dashboard": return ; case "action": return ; default: return ; } }; // 화면 타입별 색상 (헤더) const getScreenTypeColor = (screenType?: string, isMain?: boolean) => { if (!isMain) return "bg-slate-400"; switch (screenType) { case "grid": return "bg-violet-500"; case "dashboard": return "bg-amber-500"; case "action": return "bg-rose-500"; default: return "bg-blue-500"; } }; // 화면 역할(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-blue-500"; // 파란색 - 등록 폼 } 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"; // 기본 회색 }; // 화면 타입별 라벨 const getScreenTypeLabel = (screenType?: string) => { switch (screenType) { case "grid": return "그리드"; case "dashboard": return "대시보드"; case "action": return "액션"; default: return "폼"; } }; // ========== 화면 노드 (상단) - 미리보기 표시 ========== export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => { const { label, subLabel, isMain, tableName, layoutSummary, isInGroup, isFocused, isFaded, screenRole } = data; const screenType = layoutSummary?.screenType || "form"; // 그룹 모드에서는 screenRole 기반 색상, 그렇지 않으면 screenType 기반 색상 // isFocused일 때 색상 활성화, isFaded일 때 회색 let headerColor: string; if (isInGroup) { if (isFaded) { headerColor = "bg-gray-300"; // 흑백 처리 - 더 확실한 회색 } else { // 포커스되었거나 아직 아무것도 선택 안됐을 때: 역할별 색상 headerColor = getScreenRoleColor(screenRole); } } else { headerColor = getScreenTypeColor(screenType, isMain); } return (
{/* Handles */} {/* 헤더 (컬러) */}
{label} {(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)}
); }; // ========== 컴포넌트 종류별 미니어처 색상 ========== // componentKind는 더 정확한 컴포넌트 타입 (table-list, button-primary 등) const getComponentColor = (componentKind: string) => { // 테이블/그리드 관련 if (componentKind === "table-list" || componentKind === "data-grid") { return "bg-violet-200 border-violet-400"; } // 검색 필터 if (componentKind === "table-search-widget" || componentKind === "search-filter") { return "bg-pink-200 border-pink-400"; } // 버튼 관련 if (componentKind?.includes("button")) { return "bg-blue-300 border-blue-500"; } // 입력 필드 if (componentKind?.includes("input") || componentKind?.includes("text")) { return "bg-slate-200 border-slate-400"; } // 셀렉트/드롭다운 if (componentKind?.includes("select") || componentKind?.includes("dropdown")) { return "bg-amber-200 border-amber-400"; } // 차트 if (componentKind?.includes("chart")) { return "bg-emerald-200 border-emerald-400"; } // 커스텀 위젯 if (componentKind === "custom") { return "bg-pink-200 border-pink-400"; } return "bg-slate-100 border-slate-300"; }; // ========== 화면 미리보기 컴포넌트 - 화면 타입별 간단한 일러스트 ========== const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType: string }> = ({ layoutSummary, screenType, }) => { const { totalComponents, widgetCounts } = layoutSummary; // 그리드 화면 일러스트 if (screenType === "grid") { return (
{/* 상단 툴바 */}
{/* 테이블 헤더 */}
{[...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}개
); } // 기본 (알 수 없는 타입) return (
{getScreenTypeIcon(screenType)}
{totalComponents}개 컴포넌트
); }; // ========== 테이블 노드 (하단) - 컬럼 목록 표시 (컴팩트) ========== export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { const { label, subLabel, isMain, isFocused, isFaded, columns, highlightedColumns, joinColumns, joinColumnRefs, filterColumns, fieldMappings, referencedBy, saveInfos } = data; // 강조할 컬럼 세트 (영문 컬럼명 기준) const highlightSet = new Set(highlightedColumns || []); const filterSet = new Set(filterColumns || []); // 필터링에 사용되는 FK 컬럼 const joinSet = new Set(joinColumns || []); // 조인 컬럼 참조 정보 맵 생성 (column → { refTable, refTableLabel, refColumn }) const joinRefMap = new Map(); if (joinColumnRefs) { joinColumnRefs.forEach((ref) => { joinRefMap.set(ref.column, { refTable: ref.refTable, refTableLabel: ref.refTableLabel || ref.refTable, // 한글명 (없으면 영문명) refColumn: ref.refColumn }); }); } // 필드 매핑 맵 생성 (targetField → { sourceField, sourceDisplayName }) // 서브 테이블에서 targetField가 어떤 메인 테이블 컬럼(sourceField)과 연결되는지 const fieldMappingMap = new Map(); if (fieldMappings) { fieldMappings.forEach(mapping => { fieldMappingMap.set(mapping.targetField, { sourceField: mapping.sourceField, // 한글명이 있으면 한글명, 없으면 영문명 사용 sourceDisplayName: mapping.sourceDisplayName || mapping.sourceField, }); }); } // 필터 소스 컬럼 세트 (메인 테이블에서 필터에 사용되는 컬럼) const filterSourceSet = new Set( referencedBy?.filter(r => r.relationType === 'filter').map(r => r.fromColumn) || [] ); // 포커스 모드: 사용 컬럼만 필터링하여 표시 // originalName (영문) 또는 name으로 매칭 시도 // 필터 컬럼(filterSet) 및 필터 소스 컬럼(filterSourceSet)도 포함하여 보라색으로 표시 const potentialFilteredColumns = columns?.filter(col => { const colOriginal = col.originalName || col.name; return highlightSet.has(colOriginal) || joinSet.has(colOriginal) || filterSet.has(colOriginal) || filterSourceSet.has(colOriginal); }) || []; // 정렬: 조인 컬럼 → 필터 컬럼/필터 소스 컬럼 → 사용 컬럼 순서 const sortedFilteredColumns = [...potentialFilteredColumns].sort((a, b) => { const aOriginal = a.originalName || a.name; const bOriginal = b.originalName || b.name; const aIsJoin = joinSet.has(aOriginal); const bIsJoin = joinSet.has(bOriginal); const aIsFilter = filterSet.has(aOriginal) || filterSourceSet.has(aOriginal); const bIsFilter = filterSet.has(bOriginal) || filterSourceSet.has(bOriginal); // 조인 컬럼 우선 if (aIsJoin && !bIsJoin) return -1; if (!aIsJoin && bIsJoin) return 1; // 필터 컬럼/필터 소스 다음 if (aIsFilter && !bIsFilter) return -1; if (!aIsFilter && bIsFilter) return 1; // 나머지는 원래 순서 유지 return 0; }); const hasActiveColumns = sortedFilteredColumns.length > 0; // 필터 관계가 있는 테이블인지 확인 (마스터-디테일 필터링) // - hasFilterRelation: 디테일 테이블 (WHERE 조건 대상) - filterColumns에 FK 컬럼이 있음 // - isFilterSource: 마스터 테이블 (필터 소스, WHERE 조건 제공) - 포커스된 화면의 메인 테이블이고 filterSourceSet에 컬럼이 있음 // 디테일 테이블: filterColumns(filterSet)에 FK 컬럼이 있고, 포커스된 화면의 메인이 아님 const hasFilterRelation = filterSet.size > 0 && !isFocused; // 마스터 테이블: 포커스된 화면의 메인 테이블(isFocused)이고 filterSourceSet에 컬럼이 있음 const isFilterSource = isFocused && filterSourceSet.size > 0; // 표시할 컬럼: // - 포커스 시 (활성 컬럼 있음): 정렬된 컬럼만 표시 // - 비포커스 시: 최대 8개만 표시 const MAX_DEFAULT_COLUMNS = 8; const allColumns = columns || []; const displayColumns = hasActiveColumns ? sortedFilteredColumns : allColumns.slice(0, MAX_DEFAULT_COLUMNS); const remainingCount = hasActiveColumns ? 0 : Math.max(0, allColumns.length - MAX_DEFAULT_COLUMNS); const totalCount = allColumns.length; // 컬럼 수 기반 높이 계산 (DOM 측정 없이) // - 각 컬럼 행 높이: 약 22px (py-0.5 + text + gap-px) // - 컨테이너 패딩: p-1.5 = 12px (상하 합계) // - 뱃지 높이: 약 26px (py-1 + text + gap) const COLUMN_ROW_HEIGHT = 22; const CONTAINER_PADDING = 12; const BADGE_HEIGHT = 26; const MAX_HEIGHT = 200; // 뱃지 포함 가능하도록 증가 // 뱃지가 표시될지 미리 계산 (필터/참조만, 저장은 헤더에 표시) const hasFilterOrLookupBadge = referencedBy && referencedBy.some(r => r.relationType === 'filter' || r.relationType === 'lookup'); const hasBadge = hasFilterOrLookupBadge; const calculatedHeight = useMemo(() => { const badgeHeight = hasBadge ? BADGE_HEIGHT : 0; const rawHeight = CONTAINER_PADDING + badgeHeight + (displayColumns.length * COLUMN_ROW_HEIGHT); return Math.min(rawHeight, MAX_HEIGHT); }, [displayColumns.length, hasBadge]); // Debounce된 높이: 중간 값(늘어났다가 줄어드는 현상)을 무시하고 최종 값만 사용 // 듀얼 그리드에서 filterColumns와 joinColumns가 2단계로 업데이트되는 문제 해결 const [debouncedHeight, setDebouncedHeight] = useState(calculatedHeight); useEffect(() => { // 50ms 내에 다시 변경되면 이전 값 무시 const timer = setTimeout(() => { setDebouncedHeight(calculatedHeight); }, 50); return () => clearTimeout(timer); }, [calculatedHeight]); // 저장 대상 여부 const hasSaveTarget = saveInfos && saveInfos.length > 0; return (
{/* 저장 대상: 테이블 바깥 왼쪽에 띄워진 막대기 (나타나기/사라지기 애니메이션) */}
{/* Handles */} {/* top target: 화면 → 메인테이블 연결용 */} {/* top source: 메인테이블 → 메인테이블 연결용 (위쪽으로 나가는 선) */} {/* bottom target: 메인테이블 ← 메인테이블 연결용 (아래에서 들어오는 선) */} {/* 헤더 (필터 관계: 보라색, 필터 소스: 보라색, 메인: 초록색, 기본: 슬레이트) */}
{label}
{/* 필터 관계에 따른 문구 변경 */}
{isFilterSource ? "마스터 테이블 (필터 소스)" : hasFilterRelation ? "디테일 테이블 (WHERE 조건)" : subLabel}
{hasActiveColumns && ( {displayColumns.length}개 활성 )}
{/* 컬럼 목록 - 컴팩트하게 (부드러운 높이 전환 + 스크롤) */} {/* 뱃지도 이 영역 안에 포함되어 높이 계산에 반영됨 */}
{/* 필터링/참조 관계 뱃지 (컬럼 목록 영역 안에 포함, 저장은 헤더에 표시) */} {hasBadge && (() => { const filterRefs = referencedBy?.filter(r => r.relationType === 'filter') || []; const lookupRefs = referencedBy?.filter(r => r.relationType === 'lookup') || []; if (filterRefs.length === 0 && lookupRefs.length === 0) return null; return (
{/* 필터 뱃지 */} {filterRefs.length > 0 && ( `${r.fromTable}.${r.fromColumn || 'id'} → ${r.toColumn}`).join('\n')}`} > 필터 )} {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}곳 참조 )}
); })()} {displayColumns.length > 0 ? (
{displayColumns.map((col, idx) => { const colOriginal = col.originalName || col.name; const isJoinColumn = joinSet.has(colOriginal); const isFilterColumn = filterSet.has(colOriginal); // 서브 테이블의 필터링 FK 컬럼 const isHighlighted = highlightSet.has(colOriginal); // 필터링 참조 정보 (어떤 테이블의 어떤 컬럼에서 필터링되는지) - 서브 테이블용 const filterRefInfo = referencedBy?.find( r => r.relationType === 'filter' && r.toColumn === colOriginal ); // 메인 테이블에서 필터 소스로 사용되는 컬럼인지 (fromColumn과 일치) const isFilterSourceColumn = filterSourceSet.has(colOriginal); return (
{/* 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 &&
} {/* 컬럼명 */} {col.name} {/* 역할 태그 + 참조 관계 표시 */} {isJoinColumn && ( <> {/* 조인 참조 테이블 표시 (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}
); })} {/* 더 많은 컬럼이 있을 경우 표시 */} {remainingCount > 0 && (
+ {remainingCount}개 더
)}
) : (
컬럼 정보 없음
)}
{/* 푸터 (컴팩트) */}
PostgreSQL {columns && ( {hasActiveColumns ? `${displayColumns.length}/${totalCount}` : totalCount}개 컬럼 )}
{/* CSS 애니메이션 정의 */}
); }; // ========== 기존 호환성 유지용 ========== export const LegacyScreenNode = ScreenNode; export const AggregateNode: React.FC<{ data: any }> = ({ data }) => { return (
{data.label || "Aggregate"}
); };