820 lines
35 KiB
TypeScript
820 lines
35 KiB
TypeScript
"use client";
|
|
|
|
import React, { useMemo } 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)
|
|
}
|
|
|
|
// 참조 관계 정보 (다른 테이블에서 이 테이블을 참조하는 경우)
|
|
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[]; // 이 테이블을 참조하는 관계들
|
|
}
|
|
|
|
// ========== 유틸리티 함수 ==========
|
|
|
|
// 화면 타입별 아이콘
|
|
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-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 (
|
|
<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-gray-200 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-blue-500 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-blue-500 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-blue-500 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-slate-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-slate-200 bg-white px-2 py-1.5">
|
|
<div className="mb-1 flex items-center gap-1 text-[9px] font-medium text-slate-500">
|
|
<Columns3 className="h-3 w-3" />
|
|
<span>필드 매핑</span>
|
|
<span className="ml-auto text-[8px] text-slate-400">
|
|
{layoutSummary?.layoutItems?.filter(i => i.label && !i.componentKind?.includes('button')).length || 0}개
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-col gap-0.5 overflow-y-auto" style={{ maxHeight: '80px' }}>
|
|
{layoutSummary?.layoutItems
|
|
?.filter(item => item.label && !item.componentKind?.includes('button'))
|
|
?.slice(0, 6)
|
|
?.map((item, idx) => (
|
|
<div key={idx} className="flex items-center gap-1 rounded bg-slate-50 px-1.5 py-0.5">
|
|
<div className={`h-1.5 w-1.5 rounded-full ${
|
|
item.componentKind === 'table-list' ? 'bg-violet-400' :
|
|
item.componentKind?.includes('select') ? 'bg-amber-400' :
|
|
'bg-slate-400'
|
|
}`} />
|
|
<span className="flex-1 truncate text-[9px] text-slate-600">{item.label}</span>
|
|
<span className="text-[8px] text-slate-400">{item.componentKind?.split('-')[0] || 'field'}</span>
|
|
</div>
|
|
)) || (
|
|
<div className="text-center text-[9px] text-slate-400 py-2">필드 정보 없음</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 푸터 (테이블 정보) */}
|
|
<div className="flex items-center justify-between border-t border-border bg-muted/30 px-3 py-1.5">
|
|
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
|
<Database className="h-3 w-3" />
|
|
<span className="max-w-[120px] truncate font-mono">{tableName || "No Table"}</span>
|
|
</div>
|
|
<span className="rounded bg-muted px-1.5 py-0.5 text-[9px] font-medium text-muted-foreground">
|
|
{getScreenTypeLabel(screenType)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ========== 컴포넌트 종류별 미니어처 색상 ==========
|
|
// 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 (
|
|
<div className="flex h-full flex-col gap-2 rounded-lg border border-slate-200 bg-gradient-to-b from-slate-50 to-white p-3">
|
|
{/* 상단 툴바 */}
|
|
<div className="flex items-center gap-2">
|
|
<div className="h-4 w-16 rounded bg-pink-400/80 shadow-sm" />
|
|
<div className="flex-1" />
|
|
<div className="h-4 w-8 rounded bg-blue-500 shadow-sm" />
|
|
<div className="h-4 w-8 rounded bg-blue-500 shadow-sm" />
|
|
<div className="h-4 w-8 rounded bg-rose-500 shadow-sm" />
|
|
</div>
|
|
{/* 테이블 헤더 */}
|
|
<div className="flex gap-1 rounded-t-md bg-violet-500 px-2 py-2 shadow-sm">
|
|
{[...Array(5)].map((_, i) => (
|
|
<div key={i} className="h-2.5 flex-1 rounded bg-white/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-slate-100" : "bg-white"}`}>
|
|
{[...Array(5)].map((_, j) => (
|
|
<div key={j} className="h-2 flex-1 rounded bg-slate-300/70" />
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
{/* 페이지네이션 */}
|
|
<div className="flex items-center justify-center gap-2 pt-1">
|
|
<div className="h-2.5 w-4 rounded bg-slate-300" />
|
|
<div className="h-2.5 w-4 rounded bg-blue-500" />
|
|
<div className="h-2.5 w-4 rounded bg-slate-300" />
|
|
<div className="h-2.5 w-4 rounded bg-slate-300" />
|
|
</div>
|
|
{/* 컴포넌트 수 */}
|
|
<div className="absolute bottom-2 right-2 rounded-md bg-slate-800/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
|
|
{totalComponents}개
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 폼 화면 일러스트
|
|
if (screenType === "form") {
|
|
return (
|
|
<div className="flex h-full flex-col gap-3 rounded-lg border border-slate-200 bg-gradient-to-b from-slate-50 to-white p-3">
|
|
{/* 폼 필드들 */}
|
|
{[...Array(6)].map((_, i) => (
|
|
<div key={i} className="flex items-center gap-3">
|
|
<div className="h-2.5 w-14 rounded bg-slate-400" />
|
|
<div className="h-5 flex-1 rounded-md border border-slate-300 bg-white shadow-sm" />
|
|
</div>
|
|
))}
|
|
{/* 버튼 영역 */}
|
|
<div className="mt-auto flex justify-end gap-2 border-t border-slate-100 pt-3">
|
|
<div className="h-5 w-14 rounded-md bg-slate-300 shadow-sm" />
|
|
<div className="h-5 w-14 rounded-md bg-blue-500 shadow-sm" />
|
|
</div>
|
|
{/* 컴포넌트 수 */}
|
|
<div className="absolute bottom-2 right-2 rounded-md bg-slate-800/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
|
|
{totalComponents}개
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 대시보드 화면 일러스트
|
|
if (screenType === "dashboard") {
|
|
return (
|
|
<div className="grid h-full grid-cols-2 gap-2 rounded-lg border border-slate-200 bg-gradient-to-b from-slate-50 to-white p-3">
|
|
{/* 카드/차트들 */}
|
|
<div className="rounded-lg bg-emerald-100 p-2 shadow-sm">
|
|
<div className="mb-2 h-2.5 w-10 rounded bg-emerald-400" />
|
|
<div className="h-10 rounded-md bg-emerald-300/80" />
|
|
</div>
|
|
<div className="rounded-lg bg-amber-100 p-2 shadow-sm">
|
|
<div className="mb-2 h-2.5 w-10 rounded bg-amber-400" />
|
|
<div className="h-10 rounded-md bg-amber-300/80" />
|
|
</div>
|
|
<div className="col-span-2 rounded-lg bg-blue-100 p-2 shadow-sm">
|
|
<div className="mb-2 h-2.5 w-12 rounded bg-blue-400" />
|
|
<div className="flex h-14 items-end gap-1">
|
|
{[...Array(10)].map((_, i) => (
|
|
<div
|
|
key={i}
|
|
className="flex-1 rounded-t bg-blue-400/80"
|
|
style={{ height: `${25 + Math.random() * 75}%` }}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{/* 컴포넌트 수 */}
|
|
<div className="absolute bottom-2 right-2 rounded-md bg-slate-800/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
|
|
{totalComponents}개
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 액션 화면 일러스트 (버튼 중심)
|
|
if (screenType === "action") {
|
|
return (
|
|
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-lg border border-slate-200 bg-gradient-to-b from-slate-50 to-white p-3">
|
|
<div className="rounded-full bg-slate-100 p-4 text-slate-400">
|
|
<MousePointer2 className="h-10 w-10" />
|
|
</div>
|
|
<div className="flex gap-3">
|
|
<div className="h-7 w-16 rounded-md bg-blue-500 shadow-sm" />
|
|
<div className="h-7 w-16 rounded-md bg-slate-300 shadow-sm" />
|
|
</div>
|
|
<div className="text-xs font-medium text-slate-400">액션 화면</div>
|
|
{/* 컴포넌트 수 */}
|
|
<div className="absolute bottom-2 right-2 rounded-md bg-slate-800/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
|
|
{totalComponents}개
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 기본 (알 수 없는 타입)
|
|
return (
|
|
<div className="flex h-full flex-col items-center justify-center gap-3 rounded-lg border border-slate-200 bg-gradient-to-b from-slate-50 to-white text-slate-400">
|
|
<div className="rounded-full bg-slate-100 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, isFocused, isFaded, columns, highlightedColumns, joinColumns, joinColumnRefs, filterColumns, fieldMappings, referencedBy } = 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 (상하 합계)
|
|
const COLUMN_ROW_HEIGHT = 22;
|
|
const CONTAINER_PADDING = 12;
|
|
const MAX_HEIGHT = 180;
|
|
|
|
const calculatedHeight = useMemo(() => {
|
|
const rawHeight = CONTAINER_PADDING + (displayColumns.length * COLUMN_ROW_HEIGHT);
|
|
return Math.min(rawHeight, MAX_HEIGHT);
|
|
}, [displayColumns.length]);
|
|
|
|
return (
|
|
<div
|
|
className={`group relative flex w-[260px] flex-col overflow-hidden rounded-lg border shadow-md ${
|
|
// 필터 관련 테이블 (마스터 또는 디테일): 보라색
|
|
(hasFilterRelation || isFilterSource)
|
|
? "border-2 border-violet-500 ring-4 ring-violet-500/30 shadow-xl bg-violet-50"
|
|
// 순수 포커스 (필터 관계 없음): 초록색
|
|
: isFocused
|
|
? "border-2 border-emerald-500 ring-4 ring-emerald-500/30 shadow-xl bg-card"
|
|
// 흐리게 처리
|
|
: isFaded
|
|
? "border-gray-200 opacity-60 bg-card"
|
|
// 기본
|
|
: "border-border hover:shadow-lg hover:ring-2 hover:ring-emerald-500/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",
|
|
}}
|
|
>
|
|
{/* Handles */}
|
|
{/* top target: 화면 → 메인테이블 연결용 */}
|
|
<Handle
|
|
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"
|
|
/>
|
|
{/* top source: 메인테이블 → 메인테이블 연결용 (위쪽으로 나가는 선) */}
|
|
<Handle
|
|
type="source"
|
|
position={Position.Top}
|
|
id="top_source"
|
|
style={{ top: -4 }}
|
|
className="!h-2 !w-2 !border-2 !border-background !bg-orange-500 opacity-0 transition-opacity group-hover:opacity-100"
|
|
/>
|
|
<Handle
|
|
type="target"
|
|
position={Position.Left}
|
|
id="left"
|
|
className="!h-2 !w-2 !border-2 !border-background !bg-emerald-500 opacity-0 transition-opacity group-hover:opacity-100"
|
|
/>
|
|
<Handle
|
|
type="source"
|
|
position={Position.Right}
|
|
id="right"
|
|
className="!h-2 !w-2 !border-2 !border-background !bg-emerald-500 opacity-0 transition-opacity group-hover:opacity-100"
|
|
/>
|
|
<Handle
|
|
type="source"
|
|
position={Position.Bottom}
|
|
id="bottom"
|
|
className="!h-2 !w-2 !border-2 !border-background !bg-orange-500 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-orange-500 opacity-0 transition-opacity group-hover:opacity-100"
|
|
/>
|
|
|
|
{/* 헤더 (필터 관계: 보라색, 필터 소스: 보라색, 메인: 초록색, 기본: 슬레이트) */}
|
|
<div className={`flex items-center gap-2 px-3 py-1.5 text-white transition-colors duration-700 ease-in-out ${
|
|
isFaded ? "bg-gray-400" : (hasFilterRelation || isFilterSource) ? "bg-violet-600" : isMain ? "bg-emerald-600" : "bg-slate-500"
|
|
}`}>
|
|
<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>
|
|
|
|
{/* 필터링/참조 관계 표시 (포커스 시에만 표시, 헤더 아래 별도 영역) */}
|
|
{referencedBy && referencedBy.length > 0 && (() => {
|
|
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 px-2 py-1 bg-violet-50 border-b border-violet-100 text-[9px]">
|
|
{filterRefs.length > 0 && (
|
|
<span
|
|
className="flex items-center gap-0.5 rounded bg-violet-500 px-1.5 py-0.5 text-white font-medium"
|
|
title={`마스터-디테일 필터링\n${filterRefs.map(r => `${r.fromTable}.${r.fromColumn || 'id'} → ${r.toColumn}`).join('\n')}`}
|
|
>
|
|
<Link2 className="h-2.5 w-2.5" />
|
|
<span>필터</span>
|
|
</span>
|
|
)}
|
|
{filterRefs.length > 0 && (
|
|
<span className="text-violet-600 truncate">
|
|
{filterRefs.map(r => `${r.fromTableLabel || r.fromTable}.${r.fromColumnLabel || r.fromColumn || 'id'}`).join(', ')}
|
|
</span>
|
|
)}
|
|
{lookupRefs.length > 0 && (
|
|
<span
|
|
className="flex items-center gap-0.5 rounded bg-amber-500 px-1.5 py-0.5 text-white font-medium"
|
|
title={`코드 참조 (lookup)\n${lookupRefs.map(r => `${r.fromTable} → ${r.toColumn}`).join('\n')}`}
|
|
>
|
|
{lookupRefs.length}곳 참조
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{/* 컬럼 목록 - 컴팩트하게 (부드러운 높이 전환) */}
|
|
<div
|
|
className="p-1.5 overflow-hidden"
|
|
style={{
|
|
height: `${calculatedHeight}px`,
|
|
transition: 'height 0.7s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
}}
|
|
>
|
|
{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-orange-100 border border-orange-300 shadow-sm"
|
|
: isFilterColumn || isFilterSourceColumn
|
|
? "bg-violet-100 border border-violet-300 shadow-sm" // 필터 컬럼/필터 소스: 보라색
|
|
: isHighlighted
|
|
? "bg-blue-100 border border-blue-300 shadow-sm"
|
|
: hasActiveColumns
|
|
? "bg-slate-100"
|
|
: "bg-slate-50 hover:bg-slate-100"
|
|
}`}
|
|
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-orange-500" />}
|
|
{(isFilterColumn || isFilterSourceColumn) && !isJoinColumn && <Link2 className="h-2.5 w-2.5 text-violet-500" />}
|
|
{!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && col.isPrimaryKey && <Key className="h-2.5 w-2.5 text-amber-500" />}
|
|
{!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && col.isForeignKey && !col.isPrimaryKey && <Link2 className="h-2.5 w-2.5 text-blue-500" />}
|
|
{!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-orange-700"
|
|
: (isFilterColumn || isFilterSourceColumn) ? "text-violet-700"
|
|
: isHighlighted ? "text-blue-700"
|
|
: "text-slate-700"
|
|
}`}>
|
|
{col.name}
|
|
</span>
|
|
|
|
{/* 역할 태그 + 참조 관계 표시 */}
|
|
{isJoinColumn && (
|
|
<>
|
|
{/* 조인 참조 테이블 표시 (joinColumnRefs에서) */}
|
|
{joinRefMap.has(colOriginal) && (
|
|
<span className="rounded bg-orange-100 px-1 text-[7px] text-orange-600">
|
|
← {joinRefMap.get(colOriginal)?.refTableLabel}
|
|
</span>
|
|
)}
|
|
{/* 필드 매핑 참조 표시 (fieldMappingMap에서, joinRefMap에 없는 경우) */}
|
|
{!joinRefMap.has(colOriginal) && fieldMappingMap.has(colOriginal) && (
|
|
<span className="rounded bg-orange-100 px-1 text-[7px] text-orange-600">
|
|
← {fieldMappingMap.get(colOriginal)?.sourceDisplayName}
|
|
</span>
|
|
)}
|
|
<span className="rounded bg-orange-200 px-1 text-[7px] text-orange-700">조인</span>
|
|
</>
|
|
)}
|
|
{isFilterColumn && !isJoinColumn && (
|
|
<span className="rounded bg-violet-200 px-1 text-[7px] text-violet-700">필터</span>
|
|
)}
|
|
{/* 메인 테이블에서 필터 소스로 사용되는 컬럼: "필터" + "사용" 둘 다 표시 */}
|
|
{isFilterSourceColumn && !isJoinColumn && !isFilterColumn && (
|
|
<>
|
|
<span className="rounded bg-violet-200 px-1 text-[7px] text-violet-700">필터</span>
|
|
{isHighlighted && (
|
|
<span className="rounded bg-blue-200 px-1 text-[7px] text-blue-700">사용</span>
|
|
)}
|
|
</>
|
|
)}
|
|
{isHighlighted && !isJoinColumn && !isFilterColumn && !isFilterSourceColumn && (
|
|
<span className="rounded bg-blue-200 px-1 text-[7px] text-blue-700">사용</span>
|
|
)}
|
|
|
|
{/* 타입 */}
|
|
<span className="text-[8px] text-slate-400">{col.type}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
{/* 더 많은 컬럼이 있을 경우 표시 */}
|
|
{remainingCount > 0 && (
|
|
<div className="text-center text-[8px] text-slate-400 py-0.5">
|
|
+ {remainingCount}개 더
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center py-2 text-muted-foreground">
|
|
<Database className="h-4 w-4 text-slate-300" />
|
|
<span className="mt-0.5 text-[8px] text-slate-400">컬럼 정보 없음</span>
|
|
</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-purple-300 bg-white p-3 shadow-lg">
|
|
<Handle type="target" position={Position.Left} id="left" className="!h-3 !w-3 !bg-purple-500" />
|
|
<Handle type="source" position={Position.Right} id="right" className="!h-3 !w-3 !bg-purple-500" />
|
|
<div className="flex items-center gap-2 text-purple-600">
|
|
<Table2 className="h-4 w-4" />
|
|
<span className="text-sm font-semibold">{data.label || "Aggregate"}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|