fix(workflows): dark-mode canvas + readable nodes + structured config

Addresses editor UX feedback:
- Dark mode: pass React Flow's colorMode (admin isDark) so the zoom/lock
  controls, minimap and selection render dark instead of white-on-black.
- Readable nodes: show a human label derived from type+config (e.g. 'Invoice
  paid?', 'Send payment-check email', 'Repeat ≤ 2×', 'Wait until due date')
  instead of the raw node_key, and label each output handle on the node
  (yes/no, confirm/deny, loop/exit) so branching is self-explanatory.
- Structured config: replace the raw-JSON textarea with a per-node form
  (NodeConfigPanel) — dropdowns for action/condition/recipient/operator,
  typed wait/loop/gate fields, live-applied; an 'Advanced (JSON)' expander
  remains for anything the form doesn't cover.
- en + native de strings for all of it.

tsc 0 errors, build green.
This commit is contained in:
Luca
2026-06-23 11:49:32 +02:00
parent cede885b04
commit 1734aba39c
4 changed files with 330 additions and 49 deletions
+29 -5
View File
@@ -228,12 +228,36 @@
},
"editor": {
"namePlaceholder": "Workflow-Name",
"config": "Konfiguration (JSON)",
"applyConfig": "Konfiguration übernehmen",
"configApplied": "Konfiguration übernommen (zum Speichern nicht vergessen)",
"badJson": "Konfiguration ist kein gültiges JSON",
"when": "Wenn",
"saved": "Workflow gespeichert",
"saveFailed": "Speichern fehlgeschlagen"
"saveFailed": "Speichern fehlgeschlagen",
"badJson": "Konfiguration ist kein gültiges JSON",
"showAdvanced": "Erweitert (JSON)",
"hideAdvanced": "Erweitert ausblenden (JSON)",
"triggerHint": "Der Auslöser wird oben in der Leiste gesetzt (Wenn …).",
"actionLabel": "Aktion",
"recipient": "Empfänger",
"recipientCustomer": "Kunde (berücksichtigt Geschäftszeiten)",
"recipientAdmin": "Admin (sofort gesendet)",
"emailTemplate": "E-Mail-Vorlagenschlüssel",
"webhookUrl": "Webhook-URL",
"condition": "Bedingung",
"exprField": "Feld",
"exprOp": "Operator",
"exprValue": "Wert",
"conditionHint": "Führt bei „wahr“ zur „yes“-Kante, sonst zur „no“-Kante.",
"waitType": "Warten",
"waitUntil": "Bis zu einem Datum",
"waitDelay": "Feste Verzögerung",
"waitAnchor": "Warten bis",
"days": "Tage",
"hours": "Stunden",
"minutes": "Min",
"maxIterations": "Höchstens wiederholen (mal)",
"gatePrompt": "Frage an den Admin",
"gatePromptPh": "z. B. Keine Zahlung erhalten Mahnung senden?",
"gateTimeout": "Automatisch ablaufen nach (Tagen, optional)",
"gateHint": "Sendet dem Admin einen Bestätigen/Ablehnen-Link; führt zur „confirm“- oder „deny“-Kante."
}
},
"eventTypes": {
+29 -5
View File
@@ -228,12 +228,36 @@
},
"editor": {
"namePlaceholder": "Workflow name",
"config": "Config (JSON)",
"applyConfig": "Apply config",
"configApplied": "Config applied (remember to Save)",
"badJson": "Config is not valid JSON",
"when": "When",
"saved": "Workflow saved",
"saveFailed": "Could not save"
"saveFailed": "Could not save",
"badJson": "Config is not valid JSON",
"showAdvanced": "Advanced (JSON)",
"hideAdvanced": "Hide advanced (JSON)",
"triggerHint": "The trigger is set in the toolbar above (When …).",
"actionLabel": "Action",
"recipient": "Recipient",
"recipientCustomer": "Customer (respects business hours)",
"recipientAdmin": "Admin (sent immediately)",
"emailTemplate": "Email template key",
"webhookUrl": "Webhook URL",
"condition": "Condition",
"exprField": "Field",
"exprOp": "Operator",
"exprValue": "Value",
"conditionHint": "Routes to the “yes” edge when true, “no” when false.",
"waitType": "Wait",
"waitUntil": "Until a date",
"waitDelay": "A fixed delay",
"waitAnchor": "Wait until",
"days": "Days",
"hours": "Hours",
"minutes": "Min",
"maxIterations": "Repeat at most (times)",
"gatePrompt": "Question for the admin",
"gatePromptPh": "e.g. No payment received — send a reminder?",
"gateTimeout": "Auto-expire after (days, optional)",
"gateHint": "Emails the admin a confirm/deny link; routes to the “confirm” or “deny” edge."
}
},
"archives": {
@@ -0,0 +1,201 @@
/**
* Structured config editor for a workflow node — dropdowns + typed fields per
* node type, so admins don't hand-edit JSON. An "Advanced (JSON)" expander is
* kept for power users / config the form doesn't cover. Changes are applied
* live to the node (the global Save persists them).
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
type Cfg = Record<string, any>;
interface Props {
nodeType: string;
config: Cfg;
onChange: (next: Cfg) => void;
}
const field = 'w-full px-2 py-1.5 rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 text-sm';
const lbl = 'block text-xs text-neutral-500 dark:text-neutral-400 mb-1';
const ACTIONS = [
['queue_payment_check', 'Send payment-check email (dunning gate)'],
['send_email', 'Send email'],
['reserve_date', 'Reserve the event date'],
['prepare_quote', 'Prepare a quote (draft)'],
['prepare_contract', 'Prepare a contract (draft)'],
['prepare_invoice', 'Prepare an invoice (draft)'],
['prepare_event', 'Create an event (draft)'],
['prepare_gallery', 'Create a gallery (draft)'],
['send_document', 'Send the document'],
['webhook', 'Call a webhook'],
['noop', 'Do nothing'],
];
const CONDITIONS = [
['invoice_paid', 'Invoice is paid'],
['expr', 'Compare a field'],
['always', 'Always → yes'],
['never', 'Never → no'],
];
const WAIT_ANCHORS = [
['dueDate', 'the invoice due date'],
['issueDate', 'the invoice date'],
['eventDate', 'the event date'],
];
const OPS = ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'truthy', 'falsy'];
const Row: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
<div><label className={lbl}>{label}</label>{children}</div>
);
export const NodeConfigPanel: React.FC<Props> = ({ nodeType, config, onChange }) => {
const { t } = useTranslation();
const [showJson, setShowJson] = useState(false);
const [jsonText, setJsonText] = useState(JSON.stringify(config || {}, null, 2));
const [jsonErr, setJsonErr] = useState<string | null>(null);
const set = (patch: Cfg) => onChange({ ...config, ...patch });
const num = (v: string) => (v === '' ? undefined : Number(v));
const applyJson = (text: string) => {
setJsonText(text);
try { onChange(JSON.parse(text || '{}')); setJsonErr(null); }
catch (e) { setJsonErr(t('workflows.editor.badJson', 'Config is not valid JSON') as string); }
};
const waitMode = config.untilVar ? 'until' : 'delay';
return (
<div className="space-y-3">
{nodeType === 'trigger' && (
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('workflows.editor.triggerHint', 'The trigger is set in the toolbar above (When …).')}
</p>
)}
{(nodeType === 'action' || nodeType === 'webhook') && (
<Row label={t('workflows.editor.actionLabel', 'Action')}>
<select className={field} value={config.action || (nodeType === 'webhook' ? 'webhook' : 'noop')} onChange={(e) => set({ action: e.target.value })}>
{ACTIONS.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</Row>
)}
{nodeType === 'action' && config.action === 'send_email' && (
<>
<Row label={t('workflows.editor.recipient', 'Recipient')}>
<select className={field} value={config.recipientClass || 'customer'} onChange={(e) => set({ recipientClass: e.target.value })}>
<option value="customer">{t('workflows.editor.recipientCustomer', 'Customer (respects business hours)')}</option>
<option value="admin">{t('workflows.editor.recipientAdmin', 'Admin (sent immediately)')}</option>
</select>
</Row>
<Row label={t('workflows.editor.emailTemplate', 'Email template key')}>
<input className={field} value={config.emailType || ''} onChange={(e) => set({ emailType: e.target.value })} placeholder="invoice_reminder" />
</Row>
</>
)}
{(nodeType === 'action' || nodeType === 'webhook') && (config.action === 'webhook' || nodeType === 'webhook') && (
<Row label={t('workflows.editor.webhookUrl', 'Webhook URL')}>
<input className={field} value={config.url || ''} onChange={(e) => set({ url: e.target.value })} placeholder="https://…" />
</Row>
)}
{(nodeType === 'condition' || nodeType === 'branch') && (
<>
<Row label={t('workflows.editor.condition', 'Condition')}>
<select className={field} value={config.condition || 'expr'} onChange={(e) => set({ condition: e.target.value })}>
{CONDITIONS.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</Row>
{config.condition === 'expr' && (
<>
<Row label={t('workflows.editor.exprField', 'Field')}>
<input className={field} value={config.field || ''} onChange={(e) => set({ field: e.target.value })} />
</Row>
<Row label={t('workflows.editor.exprOp', 'Operator')}>
<select className={field} value={config.op || 'truthy'} onChange={(e) => set({ op: e.target.value })}>
{OPS.map((o) => <option key={o} value={o}>{o}</option>)}
</select>
</Row>
{!['truthy', 'falsy'].includes(config.op) && (
<Row label={t('workflows.editor.exprValue', 'Value')}>
<input className={field} value={config.value ?? ''} onChange={(e) => set({ value: e.target.value })} />
</Row>
)}
</>
)}
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('workflows.editor.conditionHint', 'Routes to the “yes” edge when true, “no” when false.')}
</p>
</>
)}
{nodeType === 'wait' && (
<>
<Row label={t('workflows.editor.waitType', 'Wait')}>
<select
className={field}
value={waitMode}
onChange={(e) => (e.target.value === 'until'
? onChange({ untilVar: 'dueDate' })
: onChange({ delayDays: config.delayDays || 1 }))}
>
<option value="until">{t('workflows.editor.waitUntil', 'Until a date')}</option>
<option value="delay">{t('workflows.editor.waitDelay', 'A fixed delay')}</option>
</select>
</Row>
{waitMode === 'until' ? (
<Row label={t('workflows.editor.waitAnchor', 'Wait until')}>
<select className={field} value={config.untilVar || 'dueDate'} onChange={(e) => onChange({ untilVar: e.target.value })}>
{WAIT_ANCHORS.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</Row>
) : (
<div className="grid grid-cols-3 gap-2">
<Row label={t('workflows.editor.days', 'Days')}>
<input type="number" min={0} className={field} value={config.delayDays ?? ''} onChange={(e) => set({ delayDays: num(e.target.value) })} />
</Row>
<Row label={t('workflows.editor.hours', 'Hours')}>
<input type="number" min={0} className={field} value={config.delayHours ?? ''} onChange={(e) => set({ delayHours: num(e.target.value) })} />
</Row>
<Row label={t('workflows.editor.minutes', 'Min')}>
<input type="number" min={0} className={field} value={config.delayMinutes ?? ''} onChange={(e) => set({ delayMinutes: num(e.target.value) })} />
</Row>
</div>
)}
</>
)}
{nodeType === 'loop' && (
<Row label={t('workflows.editor.maxIterations', 'Repeat at most (times)')}>
<input type="number" min={1} className={field} value={config.maxIterations ?? 3} onChange={(e) => set({ maxIterations: num(e.target.value) })} />
</Row>
)}
{nodeType === 'gate' && (
<>
<Row label={t('workflows.editor.gatePrompt', 'Question for the admin')}>
<textarea className={field} rows={2} value={config.prompt || ''} onChange={(e) => set({ prompt: e.target.value })} placeholder={t('workflows.editor.gatePromptPh', 'e.g. No payment received — send a reminder?') as string} />
</Row>
<Row label={t('workflows.editor.gateTimeout', 'Auto-expire after (days, optional)')}>
<input type="number" min={0} className={field} value={config.timeoutDays ?? ''} onChange={(e) => set({ timeoutDays: num(e.target.value) })} />
</Row>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('workflows.editor.gateHint', 'Emails the admin a confirm/deny link; routes to the “confirm” or “deny” edge.')}
</p>
</>
)}
<button type="button" className="text-xs text-neutral-500 dark:text-neutral-400 underline" onClick={() => { setJsonText(JSON.stringify(config || {}, null, 2)); setShowJson((s) => !s); }}>
{showJson ? t('workflows.editor.hideAdvanced', 'Hide advanced (JSON)') : t('workflows.editor.showAdvanced', 'Advanced (JSON)')}
</button>
{showJson && (
<div className="space-y-1">
<textarea className={`${field} font-mono`} rows={8} value={jsonText} onChange={(e) => applyJson(e.target.value)} />
{jsonErr && <p className="text-xs text-red-600 dark:text-red-400">{jsonErr}</p>}
</div>
)}
</div>
);
};
@@ -2,9 +2,10 @@
* Admin → Workflows → canvas editor (React Flow).
*
* Drag nodes from the palette, connect handle→handle (branch/gate/loop expose
* yes/no · confirm/deny · loop/exit handles), click a node to edit its config
* in the side panel, and Save (writes a new version; in-flight runs keep
* theirs). The graph maps 1:1 onto workflow_nodes/workflow_edges.
* yes/no · confirm/deny · loop/exit handles, labelled on the node), click a
* node to edit it in the structured side panel, and Save (writes a new
* version; in-flight runs keep theirs). The graph maps 1:1 onto
* workflow_nodes/workflow_edges. Honours the admin light/dark theme.
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -18,7 +19,9 @@ import {
import '@xyflow/react/dist/style.css';
import { ArrowLeft, Save, Trash2 } from 'lucide-react';
import { Button, Loading } from '../../../components/common';
import { useAdminDarkMode } from '../../../contexts/AdminDarkModeContext';
import { workflowsService, type WorkflowNodeType } from '../../../services/workflows.service';
import { NodeConfigPanel } from './NodeConfigPanel';
const PALETTE: WorkflowNodeType[] = ['trigger', 'condition', 'branch', 'loop', 'wait', 'action', 'gate', 'webhook'];
const TRIGGERS = [
@@ -30,27 +33,57 @@ const COLORS: Record<string, string> = {
trigger: '#1D9E75', condition: '#BA7517', branch: '#BA7517', loop: '#378ADD',
wait: '#888780', action: '#534AB7', gate: '#7F77DD', webhook: '#888780',
};
const SOURCE_HANDLES: Record<string, string[]> = {
condition: ['yes', 'no'], branch: ['yes', 'no'], gate: ['confirm', 'deny'], loop: ['loop', 'exit'],
};
function WfNode({ data }: { data: { label: string; nodeType: string } }) {
const WAIT_ANCHOR_LABEL: Record<string, string> = { dueDate: 'due date', issueDate: 'invoice date', eventDate: 'event date' };
const ACTION_LABEL: Record<string, string> = {
queue_payment_check: 'Send payment-check email', send_email: 'Send email', reserve_date: 'Reserve the date',
prepare_quote: 'Prepare quote', prepare_contract: 'Prepare contract', prepare_invoice: 'Prepare invoice',
prepare_event: 'Create event', prepare_gallery: 'Create gallery', send_document: 'Send document',
webhook: 'Call webhook', noop: 'Do nothing', set_context: 'Set value',
};
// Human-readable label derived live from a node's type + config.
function describeNode(type: string, config: any = {}, triggerType?: string): string {
const c = config || {};
switch (type) {
case 'trigger': return triggerType || 'When…';
case 'wait':
if (c.untilVar) return `Wait until ${WAIT_ANCHOR_LABEL[c.untilVar] || c.untilVar}`;
return `Wait ${[c.delayDays && `${c.delayDays}d`, c.delayHours && `${c.delayHours}h`, c.delayMinutes && `${c.delayMinutes}m`].filter(Boolean).join(' ') || '…'}`;
case 'condition':
case 'branch':
if (c.condition === 'invoice_paid') return 'Invoice paid?';
if (c.condition === 'expr') return `${c.field || 'field'} ${c.op || ''} ${c.value ?? ''}`.trim();
if (c.condition === 'always') return 'Always';
if (c.condition === 'never') return 'Never';
return c.condition || 'Condition';
case 'loop': return `Repeat ≤ ${c.maxIterations ?? 3}×`;
case 'action': return ACTION_LABEL[c.action] || c.action || 'Action';
case 'gate': return c.prompt ? `Ask: ${String(c.prompt).slice(0, 28)}${String(c.prompt).length > 28 ? '…' : ''}` : 'Admin confirm';
case 'webhook': return 'Call webhook';
default: return type;
}
}
function WfNode({ data }: { data: any }) {
const handles = SOURCE_HANDLES[data.nodeType];
const color = COLORS[data.nodeType] || '#888780';
const label = describeNode(data.nodeType, data.config, data.triggerType);
const pos = (i: number, n: number) => `${(100 / (n + 1)) * (i + 1)}%`;
return (
<div style={{ borderColor: color }} className="rounded-md border-2 bg-white dark:bg-neutral-900 px-3 py-2 min-w-[140px] text-center shadow-sm">
<div style={{ borderColor: color }} className="relative rounded-md border-2 bg-white dark:bg-neutral-900 px-3 pt-2 pb-4 min-w-[152px] text-center shadow-sm">
{data.nodeType !== 'trigger' && <Handle type="target" position={Position.Top} />}
<div className="text-[10px] uppercase tracking-wide" style={{ color }}>{data.nodeType}</div>
<div className="text-sm text-neutral-900 dark:text-neutral-100">{data.label}</div>
{handles ? (
handles.map((h, i) => (
<Handle key={h} id={h} type="source" position={Position.Bottom} style={{ left: `${(100 / (handles.length + 1)) * (i + 1)}%` }}>
</Handle>
))
) : (
<Handle type="source" position={Position.Bottom} />
)}
<div className="text-sm text-neutral-900 dark:text-neutral-100">{label}</div>
{handles ? handles.map((h, i) => (
<React.Fragment key={h}>
<span className="absolute text-[9px] text-neutral-400 dark:text-neutral-500" style={{ bottom: 3, left: pos(i, handles.length), transform: 'translateX(-50%)' }}>{h}</span>
<Handle id={h} type="source" position={Position.Bottom} style={{ left: pos(i, handles.length) }} />
</React.Fragment>
)) : <Handle type="source" position={Position.Bottom} />}
</div>
);
}
@@ -61,6 +94,7 @@ export const WorkflowEditorPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const qc = useQueryClient();
const { isDark } = useAdminDarkMode();
const { id } = useParams<{ id: string }>();
const workflowId = Number(id);
@@ -76,7 +110,6 @@ export const WorkflowEditorPage: React.FC = () => {
const [triggerType, setTriggerType] = useState('invoice.sent');
const [enabled, setEnabled] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [configText, setConfigText] = useState('{}');
const [counter, setCounter] = useState(1);
useEffect(() => {
@@ -88,7 +121,7 @@ export const WorkflowEditorPage: React.FC = () => {
id: n.node_key,
type: 'wf',
position: { x: n.pos_x || 0, y: n.pos_y || 0 },
data: { label: n.node_key, nodeType: n.type, config: n.config || {} },
data: { nodeType: n.type, config: n.config || {}, triggerType: workflow.trigger_type },
})));
setEdges(workflow.edges.map((e, i) => ({
id: `e${i}`,
@@ -99,30 +132,28 @@ export const WorkflowEditorPage: React.FC = () => {
})));
}, [workflow, setNodes, setEdges]);
// Keep the trigger node's label in sync when the trigger is changed in the toolbar.
useEffect(() => {
setNodes((nds) => nds.map((n) => (n.data?.nodeType === 'trigger' ? { ...n, data: { ...n.data, triggerType } } : n)));
}, [triggerType, setNodes]);
const onConnect = useCallback((c: Connection) => {
setEdges((eds) => addEdge({ ...c, label: c.sourceHandle || undefined }, eds));
}, [setEdges]);
const addNode = (type: WorkflowNodeType) => {
const key = type === 'trigger' ? 'trigger' : `${type}_${counter}`;
const key = type === 'trigger' ? `trigger_${counter}` : `${type}_${counter}`;
setCounter((c) => c + 1);
setNodes((nds) => nds.concat({
id: key, type: 'wf', position: { x: 120 + Math.random() * 240, y: 120 + Math.random() * 240 },
data: { label: key, nodeType: type, config: {} },
id: key, type: 'wf', position: { x: 140 + Math.random() * 220, y: 140 + Math.random() * 220 },
data: { nodeType: type, config: {}, triggerType },
}));
};
const selectedNode = useMemo(() => nodes.find((n) => n.id === selectedId) || null, [nodes, selectedId]);
useEffect(() => {
if (selectedNode) setConfigText(JSON.stringify((selectedNode.data as any).config || {}, null, 2));
}, [selectedNode]);
const applyConfig = () => {
if (!selectedId) return;
let parsed: Record<string, unknown>;
try { parsed = JSON.parse(configText || '{}'); } catch (e) { toast.error(t('workflows.editor.badJson', 'Config is not valid JSON') as string); return; }
setNodes((nds) => nds.map((n) => (n.id === selectedId ? { ...n, data: { ...n.data, config: parsed } } : n)));
toast.success(t('workflows.editor.configApplied', 'Config applied (remember to Save)') as string);
const updateNodeConfig = (nodeId: string, config: Record<string, unknown>) => {
setNodes((nds) => nds.map((n) => (n.id === nodeId ? { ...n, data: { ...n.data, config } } : n)));
};
const deleteSelected = () => {
@@ -141,9 +172,7 @@ export const WorkflowEditorPage: React.FC = () => {
node_key: n.id, type: (n.data as any).nodeType, config: (n.data as any).config || {},
pos_x: Math.round(n.position.x), pos_y: Math.round(n.position.y),
})),
edges: edges.map((e) => ({
from_node: e.source, from_handle: e.sourceHandle || null, to_node: e.target,
})),
edges: edges.map((e) => ({ from_node: e.source, from_handle: e.sourceHandle || null, to_node: e.target })),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['workflow', workflowId] });
@@ -166,6 +195,7 @@ export const WorkflowEditorPage: React.FC = () => {
className="px-2 py-1 rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100"
placeholder={t('workflows.editor.namePlaceholder', 'Workflow name') as string}
/>
<label className="text-sm text-neutral-600 dark:text-neutral-400">{t('workflows.editor.when', 'When')}</label>
<select
value={triggerType} onChange={(e) => setTriggerType(e.target.value)}
className="px-2 py-1 rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 text-sm"
@@ -197,6 +227,7 @@ export const WorkflowEditorPage: React.FC = () => {
<div className="flex gap-3" style={{ height: '70vh' }}>
<div className="flex-1 rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden">
<ReactFlow
colorMode={isDark ? 'dark' : 'light'}
nodes={nodes} edges={edges} nodeTypes={nodeTypes}
onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onConnect={onConnect}
onNodeClick={(_, n) => setSelectedId(n.id)} fitView
@@ -208,19 +239,20 @@ export const WorkflowEditorPage: React.FC = () => {
</div>
{selectedNode && (
<div className="w-72 rounded-lg border border-neutral-200 dark:border-neutral-700 p-3 space-y-2 bg-white dark:bg-neutral-900">
<div className="w-80 rounded-lg border border-neutral-200 dark:border-neutral-700 p-3 space-y-3 bg-white dark:bg-neutral-900 overflow-y-auto">
<div className="flex items-center justify-between">
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{(selectedNode.data as any).nodeType} · {selectedNode.id}</div>
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{(selectedNode.data as any).nodeType} · {selectedNode.id}
</div>
<Button variant="ghost" size="sm" onClick={deleteSelected} aria-label={t('common.delete', 'Delete') as string}>
<Trash2 className="w-4 h-4 text-red-600 dark:text-red-400" />
</Button>
</div>
<label className="block text-xs text-neutral-500 dark:text-neutral-400">{t('workflows.editor.config', 'Config (JSON)')}</label>
<textarea
value={configText} onChange={(e) => setConfigText(e.target.value)} rows={10}
className="w-full text-xs font-mono p-2 rounded border border-neutral-300 dark:border-neutral-600 bg-neutral-50 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
<NodeConfigPanel
nodeType={(selectedNode.data as any).nodeType}
config={(selectedNode.data as any).config || {}}
onChange={(cfg) => updateNodeConfig(selectedNode.id, cfg)}
/>
<Button variant="outline" size="sm" onClick={applyConfig}>{t('workflows.editor.applyConfig', 'Apply config')}</Button>
</div>
)}
</div>