"use client"; import React from "react"; import { ArrowRight, Link2, Unlink2, Plus, Trash2, Pencil, X, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { PopComponentDefinitionV5, PopDataConnection, } from "../types/pop-layout"; import { PopComponentRegistry, type ComponentConnectionMeta, } from "@/lib/registry/PopComponentRegistry"; import { getTableColumns } from "@/lib/api/tableManagement"; // ======================================== // Props // ======================================== interface ConnectionEditorProps { component: PopComponentDefinitionV5; allComponents: PopComponentDefinitionV5[]; connections: PopDataConnection[]; onAddConnection?: (conn: Omit) => void; onUpdateConnection?: (connectionId: string, conn: Omit) => void; onRemoveConnection?: (connectionId: string) => void; } // ======================================== // 소스 컴포넌트에 filter 타입 sendable이 있는지 판단 // ======================================== function hasFilterSendable(meta: ComponentConnectionMeta | undefined): boolean { if (!meta?.sendable) return false; return meta.sendable.some((s) => s.category === "filter" || s.type === "filter_value"); } // ======================================== // ConnectionEditor // ======================================== export default function ConnectionEditor({ component, allComponents, connections, onAddConnection, onUpdateConnection, onRemoveConnection, }: ConnectionEditorProps) { const registeredComp = PopComponentRegistry.getComponent(component.type); const meta = registeredComp?.connectionMeta; const outgoing = connections.filter( (c) => c.sourceComponent === component.id ); const incoming = connections.filter( (c) => c.targetComponent === component.id ); const hasSendable = meta?.sendable && meta.sendable.length > 0; const hasReceivable = meta?.receivable && meta.receivable.length > 0; if (!hasSendable && !hasReceivable) { return (

연결 없음

이 컴포넌트는 다른 컴포넌트와 연결할 수 없습니다

); } const isFilterSource = hasFilterSendable(meta); return (
{hasSendable && ( )} {hasReceivable && ( )}
); } // ======================================== // 대상 컴포넌트에서 정보 추출 // ======================================== function extractDisplayColumns(comp: PopComponentDefinitionV5 | undefined): string[] { if (!comp?.config) return []; const cfg = comp.config as Record; const cols: string[] = []; if (Array.isArray(cfg.listColumns)) { (cfg.listColumns as Array<{ columnName?: string }>).forEach((c) => { if (c.columnName && !cols.includes(c.columnName)) cols.push(c.columnName); }); } if (Array.isArray(cfg.selectedColumns)) { (cfg.selectedColumns as string[]).forEach((c) => { if (!cols.includes(c)) cols.push(c); }); } return cols; } function extractTableName(comp: PopComponentDefinitionV5 | undefined): string { if (!comp?.config) return ""; const cfg = comp.config as Record; const ds = cfg.dataSource as { tableName?: string } | undefined; return ds?.tableName || ""; } // ======================================== // 보내기 섹션 // ======================================== interface SendSectionProps { component: PopComponentDefinitionV5; meta: ComponentConnectionMeta; allComponents: PopComponentDefinitionV5[]; outgoing: PopDataConnection[]; isFilterSource: boolean; onAddConnection?: (conn: Omit) => void; onUpdateConnection?: (connectionId: string, conn: Omit) => void; onRemoveConnection?: (connectionId: string) => void; } function SendSection({ component, meta, allComponents, outgoing, isFilterSource, onAddConnection, onUpdateConnection, onRemoveConnection, }: SendSectionProps) { const [editingId, setEditingId] = React.useState(null); return (
{outgoing.map((conn) => (
{editingId === conn.id ? ( isFilterSource ? ( { onUpdateConnection?.(conn.id, data); setEditingId(null); }} onCancel={() => setEditingId(null)} submitLabel="수정" /> ) : ( { onUpdateConnection?.(conn.id, data); setEditingId(null); }} onCancel={() => setEditingId(null)} submitLabel="수정" /> ) ) : (
{conn.label || `→ ${allComponents.find((c) => c.id === conn.targetComponent)?.label || conn.targetComponent}`} {onRemoveConnection && ( )}
)}
))} {isFilterSource ? ( onAddConnection?.(data)} submitLabel="연결 추가" /> ) : ( onAddConnection?.(data)} submitLabel="연결 추가" /> )}
); } // ======================================== // 단순 연결 폼 (이벤트 타입: "어디로" 1개만) // ======================================== interface SimpleConnectionFormProps { component: PopComponentDefinitionV5; allComponents: PopComponentDefinitionV5[]; initial?: PopDataConnection; onSubmit: (data: Omit) => void; onCancel?: () => void; submitLabel: string; } function SimpleConnectionForm({ component, allComponents, initial, onSubmit, onCancel, submitLabel, }: SimpleConnectionFormProps) { const [selectedTargetId, setSelectedTargetId] = React.useState( initial?.targetComponent || "" ); const targetCandidates = allComponents.filter((c) => { if (c.id === component.id) return false; const reg = PopComponentRegistry.getComponent(c.type); return reg?.connectionMeta?.receivable && reg.connectionMeta.receivable.length > 0; }); const handleSubmit = () => { if (!selectedTargetId) return; const targetComp = allComponents.find((c) => c.id === selectedTargetId); const srcLabel = component.label || component.id; const tgtLabel = targetComp?.label || targetComp?.id || "?"; onSubmit({ sourceComponent: component.id, sourceField: "", sourceOutput: "_auto", targetComponent: selectedTargetId, targetField: "", targetInput: "_auto", label: `${srcLabel} → ${tgtLabel}`, }); if (!initial) { setSelectedTargetId(""); } }; return (
{onCancel && (

연결 수정

)} {!onCancel && (

새 연결 추가

)}
어디로?
); } // ======================================== // 필터 연결 폼 (검색 컴포넌트용: 기존 UI 유지) // ======================================== interface FilterConnectionFormProps { component: PopComponentDefinitionV5; meta: ComponentConnectionMeta; allComponents: PopComponentDefinitionV5[]; initial?: PopDataConnection; onSubmit: (data: Omit) => void; onCancel?: () => void; submitLabel: string; } function FilterConnectionForm({ component, meta, allComponents, initial, onSubmit, onCancel, submitLabel, }: FilterConnectionFormProps) { const [selectedOutput, setSelectedOutput] = React.useState( initial?.sourceOutput || meta.sendable[0]?.key || "" ); const [selectedTargetId, setSelectedTargetId] = React.useState( initial?.targetComponent || "" ); const [selectedTargetInput, setSelectedTargetInput] = React.useState( initial?.targetInput || "" ); const [filterColumns, setFilterColumns] = React.useState( initial?.filterConfig?.targetColumns || (initial?.filterConfig?.targetColumn ? [initial.filterConfig.targetColumn] : []) ); const [filterMode, setFilterMode] = React.useState< "equals" | "contains" | "starts_with" | "range" >(initial?.filterConfig?.filterMode || "contains"); const targetCandidates = allComponents.filter((c) => { if (c.id === component.id) return false; const reg = PopComponentRegistry.getComponent(c.type); return reg?.connectionMeta?.receivable && reg.connectionMeta.receivable.length > 0; }); const targetComp = selectedTargetId ? allComponents.find((c) => c.id === selectedTargetId) : null; const targetMeta = targetComp ? PopComponentRegistry.getComponent(targetComp.type)?.connectionMeta : null; React.useEffect(() => { if (!selectedOutput || !targetMeta?.receivable?.length) return; if (selectedTargetInput) return; const receivables = targetMeta.receivable; const exactMatch = receivables.find((r) => r.key === selectedOutput); if (exactMatch) { setSelectedTargetInput(exactMatch.key); return; } if (receivables.length === 1) { setSelectedTargetInput(receivables[0].key); } }, [selectedOutput, targetMeta, selectedTargetInput]); const displayColumns = React.useMemo( () => extractDisplayColumns(targetComp || undefined), [targetComp] ); const tableName = React.useMemo( () => extractTableName(targetComp || undefined), [targetComp] ); const [allDbColumns, setAllDbColumns] = React.useState([]); const [dbColumnsLoading, setDbColumnsLoading] = React.useState(false); React.useEffect(() => { if (!tableName) { setAllDbColumns([]); return; } let cancelled = false; setDbColumnsLoading(true); getTableColumns(tableName).then((res) => { if (cancelled) return; if (res.success && res.data?.columns) { setAllDbColumns(res.data.columns.map((c) => c.columnName)); } else { setAllDbColumns([]); } setDbColumnsLoading(false); }); return () => { cancelled = true; }; }, [tableName]); const displaySet = React.useMemo(() => new Set(displayColumns), [displayColumns]); const dataOnlyColumns = React.useMemo( () => allDbColumns.filter((c) => !displaySet.has(c)), [allDbColumns, displaySet] ); const hasAnyColumns = displayColumns.length > 0 || dataOnlyColumns.length > 0; const toggleColumn = (col: string) => { setFilterColumns((prev) => prev.includes(col) ? prev.filter((c) => c !== col) : [...prev, col] ); }; const handleSubmit = () => { if (!selectedOutput || !selectedTargetId || !selectedTargetInput) return; const isEvent = isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput); onSubmit({ sourceComponent: component.id, sourceField: "", sourceOutput: selectedOutput, targetComponent: selectedTargetId, targetField: "", targetInput: selectedTargetInput, filterConfig: !isEvent && filterColumns.length > 0 ? { targetColumn: filterColumns[0], targetColumns: filterColumns, filterMode, } : undefined, label: buildConnectionLabel( component, selectedOutput, allComponents.find((c) => c.id === selectedTargetId), selectedTargetInput, filterColumns ), }); if (!initial) { setSelectedTargetId(""); setSelectedTargetInput(""); setFilterColumns([]); } }; return (
{onCancel && (

연결 수정

)} {!onCancel && (

새 연결 추가

)}
보내는 값
받는 컴포넌트
{targetMeta && (
받는 방식
)} {selectedTargetInput && !isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput) && (

필터할 컬럼

{dbColumnsLoading ? (
컬럼 조회 중...
) : hasAnyColumns ? (
{displayColumns.length > 0 && (

화면 표시 컬럼

{displayColumns.map((col) => (
toggleColumn(col)} />
))}
)} {dataOnlyColumns.length > 0 && (
{displayColumns.length > 0 && (
)}

데이터 전용 컬럼

{dataOnlyColumns.map((col) => (
toggleColumn(col)} />
))}
)}
) : ( setFilterColumns(e.target.value ? [e.target.value] : [])} placeholder="컬럼명 입력" className="h-7 text-xs" /> )} {filterColumns.length > 0 && (

{filterColumns.length}개 컬럼 중 하나라도 일치하면 표시

)}

필터 방식

)}
); } // ======================================== // 받기 섹션 (읽기 전용: 연결된 소스만 표시) // ======================================== interface ReceiveSectionProps { component: PopComponentDefinitionV5; allComponents: PopComponentDefinitionV5[]; incoming: PopDataConnection[]; } function ReceiveSection({ component, allComponents, incoming, }: ReceiveSectionProps) { return (
{incoming.length > 0 ? (
{incoming.map((conn) => { const sourceComp = allComponents.find( (c) => c.id === conn.sourceComponent ); return (
{sourceComp?.label || conn.sourceComponent}
); })}
) : (

연결된 소스가 없습니다

)}
); } // ======================================== // 유틸 // ======================================== function isEventTypeConnection( sourceMeta: ComponentConnectionMeta | undefined, outputKey: string, targetMeta: ComponentConnectionMeta | null | undefined, inputKey: string, ): boolean { const sourceItem = sourceMeta?.sendable?.find((s) => s.key === outputKey); const targetItem = targetMeta?.receivable?.find((r) => r.key === inputKey); return sourceItem?.type === "event" || targetItem?.type === "event"; } function buildConnectionLabel( source: PopComponentDefinitionV5, _outputKey: string, target: PopComponentDefinitionV5 | undefined, _inputKey: string, columns?: string[] ): string { const srcLabel = source.label || source.id; const tgtLabel = target?.label || target?.id || "?"; const colInfo = columns && columns.length > 0 ? ` [${columns.join(", ")}]` : ""; return `${srcLabel} → ${tgtLabel}${colInfo}`; }