"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; } // ======================================== // 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 (

연결 없음

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

); } 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[]; onAddConnection?: (conn: Omit) => void; onUpdateConnection?: (connectionId: string, conn: Omit) => void; onRemoveConnection?: (connectionId: string) => void; } function SendSection({ component, meta, allComponents, outgoing, onAddConnection, onUpdateConnection, onRemoveConnection, }: SendSectionProps) { const [editingId, setEditingId] = React.useState(null); return (
{/* 기존 연결 목록 */} {outgoing.map((conn) => (
{editingId === conn.id ? ( { onUpdateConnection?.(conn.id, data); setEditingId(null); }} onCancel={() => setEditingId(null)} submitLabel="수정" /> ) : (
{conn.label || `${conn.sourceOutput} -> ${conn.targetInput}`} {onRemoveConnection && ( )}
)}
))} {/* 새 연결 추가 */} onAddConnection?.(data)} submitLabel="연결 추가" />
); } // ======================================== // 연결 폼 (추가/수정 공용) // ======================================== interface ConnectionFormProps { component: PopComponentDefinitionV5; meta: ComponentConnectionMeta; allComponents: PopComponentDefinitionV5[]; initial?: PopDataConnection; onSubmit: (data: Omit) => void; onCancel?: () => void; submitLabel: string; } function ConnectionForm({ component, meta, allComponents, initial, onSubmit, onCancel, submitLabel, }: ConnectionFormProps) { 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; // 1) 같은 key가 있으면 자동 매칭 const exactMatch = receivables.find((r) => r.key === selectedOutput); if (exactMatch) { setSelectedTargetInput(exactMatch.key); return; } // 2) receivable이 1개뿐이면 자동 선택 if (receivables.length === 1) { setSelectedTargetInput(receivables[0].key); } }, [selectedOutput, targetMeta, selectedTargetInput]); // 화면에 표시 중인 컬럼 const displayColumns = React.useMemo( () => extractDisplayColumns(targetComp || undefined), [targetComp] ); // DB 테이블 전체 컬럼 (비동기 조회) 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 && (
받는 방식
)} {/* 필터 설정: event 타입 연결이면 숨김 */} {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; meta: ComponentConnectionMeta; allComponents: PopComponentDefinitionV5[]; incoming: PopDataConnection[]; } function ReceiveSection({ component, meta, allComponents, incoming, }: ReceiveSectionProps) { return (
{meta.receivable.map((r) => (
{r.label} {r.description && (

{r.description}

)}
))}
{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}`; }