- Changed color schemes for various screen types and roles to align with the new design guidelines, enhancing visual consistency across the application. - Updated background colors for components based on their types, such as changing 'bg-slate-400' to 'bg-muted-foreground' and adjusting other color mappings for better clarity. - Improved the styling of the ScreenNode and V2PropertiesPanel components to ensure a more cohesive user experience. - Enhanced the DynamicComponentRenderer to support dynamic loading of column metadata with cache invalidation for better performance. These changes aim to refine the UI and improve the overall aesthetic of the application, ensuring a more modern and user-friendly interface.
874 lines
38 KiB
TypeScript
874 lines
38 KiB
TypeScript
"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;
|
|
isFilterTable?: 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 <Table2 className="h-4 w-4" />;
|
|
case "dashboard":
|
|
return <LayoutDashboard className="h-4 w-4" />;
|
|
case "action":
|
|
return <MousePointer2 className="h-4 w-4" />;
|
|
default:
|
|
return <FormInput className="h-4 w-4" />;
|
|
}
|
|
};
|
|
|
|
// 화면 타입별 색상 (헤더)
|
|
const getScreenTypeColor = (screenType?: string, isMain?: boolean) => {
|
|
if (!isMain) return "bg-muted-foreground";
|
|
switch (screenType) {
|
|
case "grid":
|
|
return "bg-primary";
|
|
case "dashboard":
|
|
return "bg-warning";
|
|
case "action":
|
|
return "bg-destructive";
|
|
default:
|
|
return "bg-primary";
|
|
}
|
|
};
|
|
|
|
// 화면 역할(screenRole)에 따른 색상
|
|
const getScreenRoleColor = (screenRole?: string) => {
|
|
if (!screenRole) return "bg-muted-foreground";
|
|
|
|
// 역할명에 포함된 키워드로 색상 결정
|
|
const role = screenRole.toLowerCase();
|
|
|
|
if (role.includes("그리드") || role.includes("grid") || role.includes("메인") || role.includes("main") || role.includes("list")) {
|
|
return "bg-primary"; // 메인 그리드
|
|
}
|
|
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-destructive"; // 액션/이벤트
|
|
}
|
|
if (role.includes("상세") || role.includes("detail") || role.includes("popup") || role.includes("팝업")) {
|
|
return "bg-warning"; // 상세/팝업
|
|
}
|
|
|
|
return "bg-muted-foreground"; // 기본 회색
|
|
};
|
|
|
|
// 화면 타입별 라벨
|
|
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-muted/60"; // 흑백 처리 - 더 확실한 회색
|
|
} else {
|
|
// 포커스되었거나 아직 아무것도 선택 안됐을 때: 역할별 색상
|
|
headerColor = getScreenRoleColor(screenRole);
|
|
}
|
|
} else {
|
|
headerColor = getScreenTypeColor(screenType, isMain);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={`group relative flex h-[320px] w-[260px] flex-col overflow-hidden rounded-lg border bg-card shadow-md transition-all cursor-pointer ${
|
|
isFocused
|
|
? "border-2 border-primary ring-4 ring-primary/50 shadow-xl scale-105"
|
|
: isFaded
|
|
? "border-border opacity-50"
|
|
: "border-border hover:shadow-lg hover:ring-2 hover:ring-primary/20"
|
|
}`}
|
|
style={{
|
|
filter: isFaded ? "grayscale(100%)" : "none",
|
|
transition: "all 0.3s ease",
|
|
transform: isFocused ? "scale(1.02)" : "scale(1)",
|
|
}}
|
|
>
|
|
{/* Handles */}
|
|
<Handle
|
|
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"
|
|
/>
|
|
<Handle
|
|
type="source"
|
|
position={Position.Right}
|
|
id="right"
|
|
className="!h-2 !w-2 !border-2 !border-background !bg-primary opacity-0 transition-opacity group-hover:opacity-100"
|
|
/>
|
|
<Handle
|
|
type="source"
|
|
position={Position.Bottom}
|
|
id="bottom"
|
|
className="!h-2 !w-2 !border-2 !border-background !bg-primary opacity-0 transition-opacity group-hover:opacity-100"
|
|
/>
|
|
|
|
{/* 헤더 (컬러) */}
|
|
<div className={`flex items-center gap-2 px-3 py-2 text-white ${headerColor} transition-colors duration-300`}>
|
|
<Monitor className="h-4 w-4" />
|
|
<span className="flex-1 truncate text-xs font-semibold">{label}</span>
|
|
{(isMain || isFocused) && <span className="flex h-2 w-2 rounded-full bg-white/80 animate-pulse" />}
|
|
</div>
|
|
|
|
{/* 화면 미리보기 영역 (컴팩트) */}
|
|
<div className="h-[140px] overflow-hidden bg-muted/50 p-2">
|
|
{layoutSummary ? (
|
|
<ScreenPreview layoutSummary={layoutSummary} screenType={screenType} />
|
|
) : (
|
|
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
|
{getScreenTypeIcon(screenType)}
|
|
<span className="mt-1 text-[10px]">화면: {label}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 필드 매핑 영역 */}
|
|
<div className="flex-1 overflow-hidden border-t border-border bg-card px-2 py-1.5">
|
|
<div className="mb-1 flex items-center gap-1 text-[9px] font-medium text-muted-foreground">
|
|
<Columns3 className="h-3 w-3" />
|
|
<span>필드 매핑</span>
|
|
<span className="ml-auto text-[8px] text-muted-foreground/70">
|
|
{layoutSummary?.layoutItems?.filter(i => i.label && !i.componentKind?.includes('button')).length || 0}개
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-col gap-0.5 overflow-y-auto" style={{ maxHeight: '80px' }}>
|
|
{layoutSummary?.layoutItems
|
|
?.filter(item => item.label && !item.componentKind?.includes('button'))
|
|
?.slice(0, 6)
|
|
?.map((item, idx) => (
|
|
<div key={idx} className="flex items-center gap-1 rounded bg-muted/50 px-1.5 py-0.5">
|
|
<div className={`h-1.5 w-1.5 rounded-full ${
|
|
item.componentKind === 'table-list' ? 'bg-primary' :
|
|
item.componentKind?.includes('select') ? 'bg-warning' :
|
|
'bg-muted-foreground'
|
|
}`} />
|
|
<span className="flex-1 truncate text-[9px] text-muted-foreground">{item.label}</span>
|
|
<span className="text-[8px] text-muted-foreground/70">{item.componentKind?.split('-')[0] || 'field'}</span>
|
|
</div>
|
|
)) || (
|
|
<div className="text-center text-[9px] text-muted-foreground py-2">필드 정보 없음</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 푸터 (테이블 정보) */}
|
|
<div className="flex items-center justify-between border-t border-border bg-muted/30 px-3 py-1.5">
|
|
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
|
<Database className="h-3 w-3" />
|
|
<span className="max-w-[120px] truncate font-mono">{tableName || "No Table"}</span>
|
|
</div>
|
|
<span className="rounded bg-muted px-1.5 py-0.5 text-[9px] font-medium text-muted-foreground">
|
|
{getScreenTypeLabel(screenType)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ========== 컴포넌트 종류별 미니어처 색상 ==========
|
|
// componentKind는 더 정확한 컴포넌트 타입 (table-list, button-primary 등)
|
|
const getComponentColor = (componentKind: string) => {
|
|
// 테이블/그리드 관련
|
|
if (componentKind === "table-list" || componentKind === "data-grid") {
|
|
return "bg-primary/20 border-primary/40";
|
|
}
|
|
// 검색 필터
|
|
if (componentKind === "table-search-widget" || componentKind === "search-filter") {
|
|
return "bg-destructive/20 border-destructive/40";
|
|
}
|
|
// 버튼 관련
|
|
if (componentKind?.includes("button")) {
|
|
return "bg-primary/30 border-primary";
|
|
}
|
|
// 입력 필드
|
|
if (componentKind?.includes("input") || componentKind?.includes("text")) {
|
|
return "bg-muted border-border";
|
|
}
|
|
// 셀렉트/드롭다운
|
|
if (componentKind?.includes("select") || componentKind?.includes("dropdown")) {
|
|
return "bg-warning/20 border-warning/40";
|
|
}
|
|
// 차트
|
|
if (componentKind?.includes("chart")) {
|
|
return "bg-success/20 border-success/40";
|
|
}
|
|
// 커스텀 위젯
|
|
if (componentKind === "custom") {
|
|
return "bg-destructive/20 border-destructive/40";
|
|
}
|
|
return "bg-muted/50 border-border";
|
|
};
|
|
|
|
// ========== 화면 미리보기 컴포넌트 - 화면 타입별 간단한 일러스트 ==========
|
|
const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType: string }> = ({
|
|
layoutSummary,
|
|
screenType,
|
|
}) => {
|
|
const { totalComponents, widgetCounts } = layoutSummary;
|
|
|
|
// 그리드 화면 일러스트
|
|
if (screenType === "grid") {
|
|
return (
|
|
<div className="flex h-full flex-col gap-2 rounded-lg border border-border bg-muted/30 p-3">
|
|
{/* 상단 툴바 */}
|
|
<div className="flex items-center gap-2">
|
|
<div className="h-4 w-16 rounded bg-destructive/80 shadow-sm" />
|
|
<div className="flex-1" />
|
|
<div className="h-4 w-8 rounded bg-primary shadow-sm" />
|
|
<div className="h-4 w-8 rounded bg-primary shadow-sm" />
|
|
<div className="h-4 w-8 rounded bg-destructive shadow-sm" />
|
|
</div>
|
|
{/* 테이블 헤더 */}
|
|
<div className="flex gap-1 rounded-t-md bg-primary px-2 py-2 shadow-sm">
|
|
{[...Array(5)].map((_, i) => (
|
|
<div key={i} className="h-2.5 flex-1 rounded bg-primary-foreground/40" />
|
|
))}
|
|
</div>
|
|
{/* 테이블 행들 */}
|
|
<div className="flex flex-1 flex-col gap-1 overflow-hidden">
|
|
{[...Array(7)].map((_, i) => (
|
|
<div key={i} className={`flex gap-1 rounded px-2 py-1.5 ${i % 2 === 0 ? "bg-muted" : "bg-card"}`}>
|
|
{[...Array(5)].map((_, j) => (
|
|
<div key={j} className="h-2 flex-1 rounded bg-muted-foreground/30" />
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
{/* 페이지네이션 */}
|
|
<div className="flex items-center justify-center gap-2 pt-1">
|
|
<div className="h-2.5 w-4 rounded bg-muted-foreground/40" />
|
|
<div className="h-2.5 w-4 rounded bg-primary" />
|
|
<div className="h-2.5 w-4 rounded bg-muted-foreground/40" />
|
|
<div className="h-2.5 w-4 rounded bg-muted-foreground/40" />
|
|
</div>
|
|
{/* 컴포넌트 수 */}
|
|
<div className="absolute bottom-2 right-2 rounded-md bg-foreground/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
|
|
{totalComponents}개
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 폼 화면 일러스트
|
|
if (screenType === "form") {
|
|
return (
|
|
<div className="flex h-full flex-col gap-3 rounded-lg border border-border bg-muted/30 p-3">
|
|
{/* 폼 필드들 */}
|
|
{[...Array(6)].map((_, i) => (
|
|
<div key={i} className="flex items-center gap-3">
|
|
<div className="h-2.5 w-14 rounded bg-muted-foreground/50" />
|
|
<div className="h-5 flex-1 rounded-md border border-border bg-card shadow-sm" />
|
|
</div>
|
|
))}
|
|
{/* 버튼 영역 */}
|
|
<div className="mt-auto flex justify-end gap-2 border-t border-border pt-3">
|
|
<div className="h-5 w-14 rounded-md bg-muted-foreground/40 shadow-sm" />
|
|
<div className="h-5 w-14 rounded-md bg-primary shadow-sm" />
|
|
</div>
|
|
{/* 컴포넌트 수 */}
|
|
<div className="absolute bottom-2 right-2 rounded-md bg-foreground/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
|
|
{totalComponents}개
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 대시보드 화면 일러스트
|
|
if (screenType === "dashboard") {
|
|
return (
|
|
<div className="grid h-full grid-cols-2 gap-2 rounded-lg border border-border bg-muted/30 p-3">
|
|
{/* 카드/차트들 */}
|
|
<div className="rounded-lg bg-success/20 p-2 shadow-sm">
|
|
<div className="mb-2 h-2.5 w-10 rounded bg-success" />
|
|
<div className="h-10 rounded-md bg-success/60" />
|
|
</div>
|
|
<div className="rounded-lg bg-warning/20 p-2 shadow-sm">
|
|
<div className="mb-2 h-2.5 w-10 rounded bg-warning" />
|
|
<div className="h-10 rounded-md bg-warning/60" />
|
|
</div>
|
|
<div className="col-span-2 rounded-lg bg-primary/10 p-2 shadow-sm">
|
|
<div className="mb-2 h-2.5 w-12 rounded bg-primary/70" />
|
|
<div className="flex h-14 items-end gap-1">
|
|
{[...Array(10)].map((_, i) => (
|
|
<div
|
|
key={i}
|
|
className="flex-1 rounded-t bg-primary/70"
|
|
style={{ height: `${25 + Math.random() * 75}%` }}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{/* 컴포넌트 수 */}
|
|
<div className="absolute bottom-2 right-2 rounded-md bg-foreground/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
|
|
{totalComponents}개
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 액션 화면 일러스트 (버튼 중심)
|
|
if (screenType === "action") {
|
|
return (
|
|
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-lg border border-border bg-muted/30 p-3">
|
|
<div className="rounded-full bg-muted p-4 text-muted-foreground">
|
|
<MousePointer2 className="h-10 w-10" />
|
|
</div>
|
|
<div className="flex gap-3">
|
|
<div className="h-7 w-16 rounded-md bg-primary shadow-sm" />
|
|
<div className="h-7 w-16 rounded-md bg-muted-foreground/40 shadow-sm" />
|
|
</div>
|
|
<div className="text-xs font-medium text-muted-foreground">액션 화면</div>
|
|
{/* 컴포넌트 수 */}
|
|
<div className="absolute bottom-2 right-2 rounded-md bg-foreground/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
|
|
{totalComponents}개
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 기본 (알 수 없는 타입)
|
|
return (
|
|
<div className="flex h-full flex-col items-center justify-center gap-3 rounded-lg border border-border bg-muted/30 text-muted-foreground">
|
|
<div className="rounded-full bg-muted p-4">
|
|
{getScreenTypeIcon(screenType)}
|
|
</div>
|
|
<span className="text-sm font-medium">{totalComponents}개 컴포넌트</span>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ========== 테이블 노드 (하단) - 컬럼 목록 표시 (컴팩트) ==========
|
|
export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
|
const { label, subLabel, isMain, isFilterTable, 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<string, { refTable: string; refTableLabel: string; refColumn: string }>();
|
|
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<string, { sourceField: string; sourceDisplayName: string }>();
|
|
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 (
|
|
<div
|
|
className={`group relative flex w-[260px] flex-col overflow-visible rounded-xl border shadow-md ${
|
|
// 1. 필터 테이블 (마스터-디테일의 디테일 테이블): 항상 primary 테두리
|
|
isFilterTable
|
|
? "border-2 border-primary ring-2 ring-primary/20 shadow-lg bg-primary/5"
|
|
// 2. 필터 관련 테이블 (마스터 또는 디테일) 포커스 시: primary 강조
|
|
: (hasFilterRelation || isFilterSource)
|
|
? "border-2 border-primary ring-4 ring-primary/30 shadow-xl bg-primary/5"
|
|
// 3. 순수 포커스 (필터 관계 없음): primary
|
|
: isFocused
|
|
? "border-2 border-primary ring-4 ring-primary/30 shadow-xl bg-card"
|
|
// 4. 흐리게 처리
|
|
: isFaded
|
|
? "border-border opacity-60 bg-card"
|
|
// 5. 기본
|
|
: "border-border hover:shadow-lg hover:ring-2 hover:ring-primary/20 bg-card"
|
|
}`}
|
|
style={{
|
|
filter: isFaded ? "grayscale(80%)" : "none",
|
|
// 색상/테두리/그림자만 transition (높이 제외)
|
|
transition: "background-color 0.7s ease, border-color 0.7s ease, box-shadow 0.7s ease, filter 0.3s ease, opacity 0.3s ease",
|
|
}}
|
|
title={hasSaveTarget ? "저장 대상 테이블" : undefined}
|
|
>
|
|
{/* 저장 대상: 테이블 바깥 왼쪽에 띄워진 막대기 (나타나기/사라지기 애니메이션) */}
|
|
<div
|
|
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%, hsl(var(--destructive)) 15%, hsl(var(--destructive)) 85%, transparent 100%)`,
|
|
opacity: hasSaveTarget ? 1 : 0,
|
|
transform: hasSaveTarget ? 'scaleY(1)' : 'scaleY(0)',
|
|
transformOrigin: 'top',
|
|
pointerEvents: hasSaveTarget ? 'auto' : 'none',
|
|
}}
|
|
/>
|
|
|
|
{/* Handles */}
|
|
{/* top target: 화면 → 메인테이블 연결용 */}
|
|
<Handle
|
|
type="target"
|
|
position={Position.Top}
|
|
id="top"
|
|
className="!h-2 !w-2 !border-2 !border-background !bg-primary opacity-0 transition-opacity group-hover:opacity-100"
|
|
/>
|
|
{/* top source: 메인테이블 → 메인테이블 연결용 (위쪽으로 나가는 선) */}
|
|
<Handle
|
|
type="source"
|
|
position={Position.Top}
|
|
id="top_source"
|
|
style={{ top: -4 }}
|
|
className="!h-2 !w-2 !border-2 !border-background !bg-warning opacity-0 transition-opacity group-hover:opacity-100"
|
|
/>
|
|
<Handle
|
|
type="target"
|
|
position={Position.Left}
|
|
id="left"
|
|
className="!h-2 !w-2 !border-2 !border-background !bg-primary opacity-0 transition-opacity group-hover:opacity-100"
|
|
/>
|
|
<Handle
|
|
type="source"
|
|
position={Position.Right}
|
|
id="right"
|
|
className="!h-2 !w-2 !border-2 !border-background !bg-primary opacity-0 transition-opacity group-hover:opacity-100"
|
|
/>
|
|
<Handle
|
|
type="source"
|
|
position={Position.Bottom}
|
|
id="bottom"
|
|
className="!h-2 !w-2 !border-2 !border-background !bg-warning opacity-0 transition-opacity group-hover:opacity-100"
|
|
/>
|
|
{/* bottom target: 메인테이블 ← 메인테이블 연결용 (아래에서 들어오는 선) */}
|
|
<Handle
|
|
type="target"
|
|
position={Position.Bottom}
|
|
id="bottom_target"
|
|
style={{ bottom: -4 }}
|
|
className="!h-2 !w-2 !border-2 !border-background !bg-warning opacity-0 transition-opacity group-hover:opacity-100"
|
|
/>
|
|
|
|
{/* 헤더 (필터 관계: primary, 필터 소스: primary, 메인: primary, 기본: muted-foreground) */}
|
|
<div className={`flex items-center gap-2 px-3 py-1.5 text-white rounded-t-xl transition-colors duration-700 ease-in-out ${
|
|
isFaded ? "bg-muted-foreground" : (hasFilterRelation || isFilterSource) ? "bg-primary" : isMain ? "bg-primary" : "bg-muted-foreground"
|
|
}`}>
|
|
<Database className="h-3.5 w-3.5 shrink-0" />
|
|
<div className="flex-1 min-w-0">
|
|
<div className="truncate text-[11px] font-semibold">{label}</div>
|
|
{/* 필터 관계에 따른 문구 변경 */}
|
|
<div className="truncate text-[9px] opacity-80">
|
|
{isFilterSource
|
|
? "마스터 테이블 (필터 소스)"
|
|
: hasFilterRelation
|
|
? "디테일 테이블 (WHERE 조건)"
|
|
: subLabel}
|
|
</div>
|
|
</div>
|
|
{hasActiveColumns && (
|
|
<span className="rounded-full bg-white/20 px-1.5 py-0.5 text-[8px] shrink-0">
|
|
{displayColumns.length}개 활성
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* 컬럼 목록 - 컴팩트하게 (부드러운 높이 전환 + 스크롤) */}
|
|
{/* 뱃지도 이 영역 안에 포함되어 높이 계산에 반영됨 */}
|
|
<div
|
|
className="p-1.5 overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent"
|
|
style={{
|
|
height: `${debouncedHeight}px`,
|
|
maxHeight: `${MAX_HEIGHT}px`,
|
|
// Debounce로 중간 값이 무시되므로 항상 부드러운 transition 적용 가능
|
|
transition: 'height 0.5s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
}}
|
|
>
|
|
{/* 필터링/참조 관계 뱃지 (컬럼 목록 영역 안에 포함, 저장은 헤더에 표시) */}
|
|
{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 (
|
|
<div className="flex items-center gap-1.5 px-2 py-1 mb-1.5 rounded border border-border bg-muted text-[9px]">
|
|
{/* 필터 뱃지 */}
|
|
{filterRefs.length > 0 && (
|
|
<span
|
|
className="flex items-center gap-1 rounded-full bg-primary px-2 py-px text-white font-semibold shadow-sm"
|
|
title={`마스터-디테일 필터링\n${filterRefs.map(r => `${r.fromTable}.${r.fromColumn || 'id'} → ${r.toColumn}`).join('\n')}`}
|
|
>
|
|
<Link2 className="h-3 w-3" />
|
|
<span>필터</span>
|
|
</span>
|
|
)}
|
|
{filterRefs.length > 0 && (
|
|
<span className="text-primary font-medium truncate">
|
|
{filterRefs.map(r => `${r.fromTableLabel || r.fromTable}.${r.fromColumnLabel || r.fromColumn || 'id'}`).join(', ')}
|
|
</span>
|
|
)}
|
|
{/* 참조 뱃지 */}
|
|
{lookupRefs.length > 0 && (
|
|
<span
|
|
className="flex items-center gap-1 rounded-full bg-warning px-2 py-px text-white font-semibold shadow-sm"
|
|
title={`코드 참조 (lookup)\n${lookupRefs.map(r => `${r.fromTable} → ${r.toColumn}`).join('\n')}`}
|
|
>
|
|
{lookupRefs.length}곳 참조
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{displayColumns.length > 0 ? (
|
|
<div className="flex flex-col gap-px transition-all duration-700 ease-in-out">
|
|
{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 (
|
|
<div
|
|
key={col.name}
|
|
className={`flex items-center gap-1 rounded px-1.5 py-0.5 transition-all duration-300 ${
|
|
isJoinColumn
|
|
? "bg-warning/10 border border-warning/30 shadow-sm"
|
|
: isFilterColumn || isFilterSourceColumn
|
|
? "bg-primary/10 border border-primary/30 shadow-sm" // 필터 컬럼/필터 소스
|
|
: isHighlighted
|
|
? "bg-primary/10 border border-primary/40 shadow-sm"
|
|
: hasActiveColumns
|
|
? "bg-muted"
|
|
: "bg-muted/50 hover:bg-muted"
|
|
}`}
|
|
style={{
|
|
animation: hasActiveColumns ? `fadeIn 0.5s ease-out ${idx * 80}ms forwards` : undefined,
|
|
opacity: hasActiveColumns ? 0 : 1,
|
|
}}
|
|
>
|
|
{/* PK/FK/조인/필터 아이콘 */}
|
|
{isJoinColumn && <Link2 className="h-2.5 w-2.5 text-warning" />}
|
|
{(isFilterColumn || isFilterSourceColumn) && !isJoinColumn && <Link2 className="h-2.5 w-2.5 text-primary" />}
|
|
{!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && col.isPrimaryKey && <Key className="h-2.5 w-2.5 text-warning" />}
|
|
{!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && col.isForeignKey && !col.isPrimaryKey && <Link2 className="h-2.5 w-2.5 text-primary" />}
|
|
{!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && !col.isPrimaryKey && !col.isForeignKey && <div className="w-2.5" />}
|
|
|
|
{/* 컬럼명 */}
|
|
<span className={`flex-1 truncate font-mono text-[9px] font-medium ${
|
|
isJoinColumn ? "text-warning"
|
|
: (isFilterColumn || isFilterSourceColumn) ? "text-primary"
|
|
: isHighlighted ? "text-primary"
|
|
: "text-foreground"
|
|
}`}>
|
|
{col.name}
|
|
</span>
|
|
|
|
{/* 역할 태그 + 참조 관계 표시 */}
|
|
{isJoinColumn && (
|
|
<>
|
|
{/* 조인 참조 테이블 표시 (joinColumnRefs에서) */}
|
|
{joinRefMap.has(colOriginal) && (
|
|
<span className="rounded bg-warning/20 px-1 text-[7px] text-warning">
|
|
← {joinRefMap.get(colOriginal)?.refTableLabel}
|
|
</span>
|
|
)}
|
|
{/* 필드 매핑 참조 표시 (fieldMappingMap에서, joinRefMap에 없는 경우) */}
|
|
{!joinRefMap.has(colOriginal) && fieldMappingMap.has(colOriginal) && (
|
|
<span className="rounded bg-warning/20 px-1 text-[7px] text-warning">
|
|
← {fieldMappingMap.get(colOriginal)?.sourceDisplayName}
|
|
</span>
|
|
)}
|
|
<span className="rounded bg-warning/20 px-1 text-[7px] text-warning">조인</span>
|
|
</>
|
|
)}
|
|
{isFilterColumn && !isJoinColumn && (
|
|
<span className="rounded bg-primary/20 px-1 text-[7px] text-primary">필터</span>
|
|
)}
|
|
{/* 메인 테이블에서 필터 소스로 사용되는 컬럼: "필터" + "사용" 둘 다 표시 */}
|
|
{isFilterSourceColumn && !isJoinColumn && !isFilterColumn && (
|
|
<>
|
|
<span className="rounded bg-primary/20 px-1 text-[7px] text-primary">필터</span>
|
|
{isHighlighted && (
|
|
<span className="rounded bg-primary/20 px-1 text-[7px] text-primary">사용</span>
|
|
)}
|
|
</>
|
|
)}
|
|
{isHighlighted && !isJoinColumn && !isFilterColumn && !isFilterSourceColumn && (
|
|
<span className="rounded bg-primary/20 px-1 text-[7px] text-primary">사용</span>
|
|
)}
|
|
|
|
{/* 타입 */}
|
|
<span className="text-[8px] text-muted-foreground">{col.type}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
{/* 더 많은 컬럼이 있을 경우 표시 */}
|
|
{remainingCount > 0 && (
|
|
<div className="text-center text-[8px] text-muted-foreground py-0.5">
|
|
+ {remainingCount}개 더
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center py-2 text-muted-foreground">
|
|
<Database className="h-4 w-4 text-muted-foreground" />
|
|
<span className="mt-0.5 text-[8px] text-muted-foreground">컬럼 정보 없음</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 푸터 (컴팩트) */}
|
|
<div className="flex items-center justify-between border-t border-border bg-muted/30 px-2 py-1">
|
|
<span className="text-[9px] text-muted-foreground">PostgreSQL</span>
|
|
{columns && (
|
|
<span className="text-[9px] text-muted-foreground">
|
|
{hasActiveColumns ? `${displayColumns.length}/${totalCount}` : totalCount}개 컬럼
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* CSS 애니메이션 정의 */}
|
|
<style jsx>{`
|
|
@keyframes fadeIn {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(-5px);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
`}</style>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ========== 기존 호환성 유지용 ==========
|
|
export const LegacyScreenNode = ScreenNode;
|
|
export const AggregateNode: React.FC<{ data: any }> = ({ data }) => {
|
|
return (
|
|
<div className="rounded-lg border-2 border-primary/40 bg-card p-3 shadow-lg">
|
|
<Handle type="target" position={Position.Left} id="left" className="!h-3 !w-3 !bg-primary" />
|
|
<Handle type="source" position={Position.Right} id="right" className="!h-3 !w-3 !bg-primary" />
|
|
<div className="flex items-center gap-2 text-primary">
|
|
<Table2 className="h-4 w-4" />
|
|
<span className="text-sm font-semibold">{data.label || "Aggregate"}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|