ERP-node/frontend/components/pop/designer/panels/ConnectionEditor.tsx

656 lines
21 KiB
TypeScript

"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<PopDataConnection, "id">) => void;
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => 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 (
<div className="space-y-4">
<div className="rounded-lg bg-gray-50 p-4 text-center">
<Link2 className="mx-auto mb-2 h-8 w-8 text-muted-foreground" />
<p className="text-sm font-medium text-gray-700"> </p>
<p className="mt-1 text-xs text-muted-foreground">
</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
{hasSendable && (
<SendSection
component={component}
meta={meta!}
allComponents={allComponents}
outgoing={outgoing}
onAddConnection={onAddConnection}
onUpdateConnection={onUpdateConnection}
onRemoveConnection={onRemoveConnection}
/>
)}
{hasReceivable && (
<ReceiveSection
component={component}
meta={meta!}
allComponents={allComponents}
incoming={incoming}
/>
)}
</div>
);
}
// ========================================
// 대상 컴포넌트에서 정보 추출
// ========================================
/** 화면에 표시 중인 컬럼만 추출 */
function extractDisplayColumns(comp: PopComponentDefinitionV5 | undefined): string[] {
if (!comp?.config) return [];
const cfg = comp.config as Record<string, unknown>;
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<string, unknown>;
const ds = cfg.dataSource as { tableName?: string } | undefined;
return ds?.tableName || "";
}
// ========================================
// 보내기 섹션
// ========================================
interface SendSectionProps {
component: PopComponentDefinitionV5;
meta: ComponentConnectionMeta;
allComponents: PopComponentDefinitionV5[];
outgoing: PopDataConnection[];
onAddConnection?: (conn: Omit<PopDataConnection, "id">) => void;
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void;
onRemoveConnection?: (connectionId: string) => void;
}
function SendSection({
component,
meta,
allComponents,
outgoing,
onAddConnection,
onUpdateConnection,
onRemoveConnection,
}: SendSectionProps) {
const [editingId, setEditingId] = React.useState<string | null>(null);
return (
<div className="space-y-3">
<Label className="flex items-center gap-1 text-xs font-medium">
<ArrowRight className="h-3 w-3 text-blue-500" />
()
</Label>
{/* 기존 연결 목록 */}
{outgoing.map((conn) => (
<div key={conn.id}>
{editingId === conn.id ? (
<ConnectionForm
component={component}
meta={meta}
allComponents={allComponents}
initial={conn}
onSubmit={(data) => {
onUpdateConnection?.(conn.id, data);
setEditingId(null);
}}
onCancel={() => setEditingId(null)}
submitLabel="수정"
/>
) : (
<div className="flex items-center gap-1 rounded border bg-blue-50/50 px-3 py-2">
<span className="flex-1 truncate text-xs">
{conn.label || `${conn.sourceOutput} -> ${conn.targetInput}`}
</span>
<button
onClick={() => setEditingId(conn.id)}
className="shrink-0 p-0.5 text-muted-foreground hover:text-primary"
>
<Pencil className="h-3 w-3" />
</button>
{onRemoveConnection && (
<button
onClick={() => onRemoveConnection(conn.id)}
className="shrink-0 p-0.5 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</button>
)}
</div>
)}
</div>
))}
{/* 새 연결 추가 */}
<ConnectionForm
component={component}
meta={meta}
allComponents={allComponents}
onSubmit={(data) => onAddConnection?.(data)}
submitLabel="연결 추가"
/>
</div>
);
}
// ========================================
// 연결 폼 (추가/수정 공용)
// ========================================
interface ConnectionFormProps {
component: PopComponentDefinitionV5;
meta: ComponentConnectionMeta;
allComponents: PopComponentDefinitionV5[];
initial?: PopDataConnection;
onSubmit: (data: Omit<PopDataConnection, "id">) => 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<string[]>(
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<string[]>([]);
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 (
<div className="space-y-2 rounded border border-dashed p-3">
{onCancel && (
<div className="flex items-center justify-between">
<p className="text-[10px] font-medium text-muted-foreground"> </p>
<button onClick={onCancel} className="text-muted-foreground hover:text-foreground">
<X className="h-3 w-3" />
</button>
</div>
)}
{!onCancel && (
<p className="text-[10px] font-medium text-muted-foreground"> </p>
)}
{/* 보내는 값 */}
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Select value={selectedOutput} onValueChange={setSelectedOutput}>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{meta.sendable.map((s) => (
<SelectItem key={s.key} value={s.key} className="text-xs">
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 받는 컴포넌트 */}
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Select
value={selectedTargetId}
onValueChange={(v) => {
setSelectedTargetId(v);
setSelectedTargetInput("");
setFilterColumns([]);
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="컴포넌트 선택" />
</SelectTrigger>
<SelectContent>
{targetCandidates.map((c) => (
<SelectItem key={c.id} value={c.id} className="text-xs">
{c.label || c.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 받는 방식 */}
{targetMeta && (
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Select value={selectedTargetInput} onValueChange={setSelectedTargetInput}>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{targetMeta.receivable.map((r) => (
<SelectItem key={r.key} value={r.key} className="text-xs">
{r.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* 필터 설정: event 타입 연결이면 숨김 */}
{selectedTargetInput && !isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput) && (
<div className="space-y-2 rounded bg-gray-50 p-2">
<p className="text-[10px] font-medium text-muted-foreground"> </p>
{dbColumnsLoading ? (
<div className="flex items-center gap-2 py-2">
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
<span className="text-[10px] text-muted-foreground"> ...</span>
</div>
) : hasAnyColumns ? (
<div className="space-y-2">
{/* 표시 컬럼 그룹 */}
{displayColumns.length > 0 && (
<div className="space-y-1">
<p className="text-[9px] font-medium text-green-600"> </p>
{displayColumns.map((col) => (
<div key={col} className="flex items-center gap-2">
<Checkbox
id={`col-${col}-${initial?.id || "new"}`}
checked={filterColumns.includes(col)}
onCheckedChange={() => toggleColumn(col)}
/>
<label
htmlFor={`col-${col}-${initial?.id || "new"}`}
className="cursor-pointer text-xs"
>
{col}
</label>
</div>
))}
</div>
)}
{/* 데이터 전용 컬럼 그룹 */}
{dataOnlyColumns.length > 0 && (
<div className="space-y-1">
{displayColumns.length > 0 && (
<div className="my-1 h-px bg-gray-200" />
)}
<p className="text-[9px] font-medium text-amber-600"> </p>
{dataOnlyColumns.map((col) => (
<div key={col} className="flex items-center gap-2">
<Checkbox
id={`col-${col}-${initial?.id || "new"}`}
checked={filterColumns.includes(col)}
onCheckedChange={() => toggleColumn(col)}
/>
<label
htmlFor={`col-${col}-${initial?.id || "new"}`}
className="cursor-pointer text-xs text-muted-foreground"
>
{col}
</label>
</div>
))}
</div>
)}
</div>
) : (
<Input
value={filterColumns[0] || ""}
onChange={(e) => setFilterColumns(e.target.value ? [e.target.value] : [])}
placeholder="컬럼명 입력"
className="h-7 text-xs"
/>
)}
{filterColumns.length > 0 && (
<p className="text-[10px] text-blue-600">
{filterColumns.length}
</p>
)}
{/* 필터 방식 */}
<div className="space-y-1">
<p className="text-[10px] text-muted-foreground"> </p>
<Select value={filterMode} onValueChange={(v: any) => setFilterMode(v)}>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="contains" className="text-xs"></SelectItem>
<SelectItem value="equals" className="text-xs"></SelectItem>
<SelectItem value="starts_with" className="text-xs"></SelectItem>
<SelectItem value="range" className="text-xs"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
{/* 제출 버튼 */}
<Button
size="sm"
variant="outline"
className="h-7 w-full text-xs"
disabled={!selectedOutput || !selectedTargetId || !selectedTargetInput}
onClick={handleSubmit}
>
{!initial && <Plus className="mr-1 h-3 w-3" />}
{submitLabel}
</Button>
</div>
);
}
// ========================================
// 받기 섹션 (읽기 전용)
// ========================================
interface ReceiveSectionProps {
component: PopComponentDefinitionV5;
meta: ComponentConnectionMeta;
allComponents: PopComponentDefinitionV5[];
incoming: PopDataConnection[];
}
function ReceiveSection({
component,
meta,
allComponents,
incoming,
}: ReceiveSectionProps) {
return (
<div className="space-y-3">
<Label className="flex items-center gap-1 text-xs font-medium">
<Unlink2 className="h-3 w-3 text-green-500" />
()
</Label>
<div className="space-y-1">
{meta.receivable.map((r) => (
<div
key={r.key}
className="rounded bg-green-50/50 px-3 py-2 text-xs text-gray-600"
>
<span className="font-medium">{r.label}</span>
{r.description && (
<p className="mt-0.5 text-[10px] text-muted-foreground">
{r.description}
</p>
)}
</div>
))}
</div>
{incoming.length > 0 ? (
<div className="space-y-2">
<p className="text-[10px] text-muted-foreground"> </p>
{incoming.map((conn) => {
const sourceComp = allComponents.find(
(c) => c.id === conn.sourceComponent
);
return (
<div
key={conn.id}
className="flex items-center gap-2 rounded border bg-gray-50 px-3 py-2 text-xs"
>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<span className="truncate">
{sourceComp?.label || conn.sourceComponent}
</span>
</div>
);
})}
</div>
) : (
<p className="text-xs text-muted-foreground">
. .
</p>
)}
</div>
);
}
// ========================================
// 유틸
// ========================================
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}`;
}