@adatechnology/conversations-ui 0.1.0-rc.24 → 0.1.0-rc.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1254 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
2
+ import {
3
+ ReactFlow,
4
+ Background,
5
+ Controls,
6
+ MiniMap,
7
+ MarkerType,
8
+ applyNodeChanges,
9
+ type Node,
10
+ type NodeChange,
11
+ type Edge,
12
+ type Connection,
13
+ type ReactFlowInstance,
14
+ } from '@xyflow/react'
15
+ import '@xyflow/react/dist/style.css'
16
+ import { Plus, Trash2, LayoutGrid, AlertTriangle, AlertCircle, Save, Undo2, Map as MapIcon, Workflow } from 'lucide-react'
17
+
18
+ import { useIsDarkTheme } from '../useDarkMode'
19
+ import { flowNodeTypes, nodeLabel, type FlowNodeCardData } from './FlowNodeCard'
20
+ import { flowPortalNodeTypes, type FlowPortalNodeData } from './FlowPortalNode'
21
+ import { flowGroupHeaderNodeTypes, type FlowGroupHeaderData } from './FlowGroupHeader'
22
+ import { flowGroupFrameNodeTypes, type FlowGroupFrameData } from './FlowGroupFrame'
23
+ import { FlowNodePanel } from './FlowNodePanel'
24
+ import { FlowPalette, type FlowPaletteActionOption, type NewNodeSpec } from './FlowPalette'
25
+ import { FlowMapCanvas } from './FlowMapCanvas'
26
+ import { mergeFlowEditorLabels, type FlowEditorLabels } from './labels'
27
+ import {
28
+ computeAutoLayout,
29
+ slugifyNodeId,
30
+ targetsOf,
31
+ validateGraph,
32
+ isCrossFlowTarget,
33
+ crossFlowKey,
34
+ crossFlowTargetsOf,
35
+ findCollectionChains,
36
+ estimateNodeHeight,
37
+ NODE_CARD_WIDTH,
38
+ CROSS_FLOW_PREFIX,
39
+ type FlowGraphData,
40
+ type FlowNodeData,
41
+ type GraphIssue,
42
+ } from './flowGraph'
43
+
44
+ const RF_NODE_TYPES = {
45
+ ...flowNodeTypes,
46
+ ...flowPortalNodeTypes,
47
+ ...flowGroupHeaderNodeTypes,
48
+ ...flowGroupFrameNodeTypes,
49
+ }
50
+
51
+ const CHAIN_FRAME_PADDING = 36
52
+ const COLUMN_GAP = 360
53
+ const ROW_GAP = 40
54
+ const NS_SEP = '::'
55
+ const FLOW_KEY_PATTERN = /^[a-z0-9_]{2,40}$/
56
+
57
+ const EDGE_COLOR_LINEAR = '#94a3b8'
58
+ const EDGE_COLOR_BRANCH = '#8b5cf6'
59
+ const EDGE_COLOR_FALLBACK = '#cbd5e1'
60
+ const EDGE_COLOR_LIVE = '#3b82f6'
61
+ const EDGE_COLOR_CROSS_FLOW = '#06b6d4'
62
+ const BACKGROUND_COLOR_LIGHT = '#cbd5e1'
63
+ const BACKGROUND_COLOR_DARK = '#334155'
64
+
65
+ /** Onde cada conversa viva está parada agora, para o card pulsar com a contagem. */
66
+ export interface FlowLivePosition {
67
+ currentState: string
68
+ flow: string | null
69
+ nodeId: string | null
70
+ menuNodeId: string | null
71
+ }
72
+
73
+ export interface CreateFlowInput {
74
+ key: string
75
+ label: string
76
+ showInMenu: boolean
77
+ /** Ausente quando `showInMenu` é falso — não há opção de menu para rotular. */
78
+ menuOptionLabel?: string
79
+ }
80
+
81
+ /**
82
+ * Backend de fluxos do host. Funções cruas em vez de um cliente HTTP: o pacote roda em produtos com
83
+ * axios, fetch e react-query, e nenhum deles precisa entrar como dependência daqui.
84
+ */
85
+ export interface FlowsWorkspaceApi {
86
+ getGraphs(): Promise<Record<string, FlowGraphData>>
87
+ saveGraph(key: string, graph: FlowGraphData): Promise<void>
88
+ /**
89
+ * Criar e excluir fluxo são **opcionais por capacidade**: produto cujos fluxos vêm de um seed
90
+ * versionado não expõe rota para isso, e a tela simplesmente não desenha os botões — em vez de
91
+ * oferecer uma ação que estoura no clique.
92
+ */
93
+ createFlow?(input: CreateFlowInput): Promise<void>
94
+ deleteFlow?(key: string): Promise<void>
95
+ /** Contagem de conversas vivas por nó. Ausente, os cards não pulsam e nada é consultado. */
96
+ getLivePositions?(): Promise<FlowLivePosition[]>
97
+ }
98
+
99
+ export interface FlowsWorkspaceProps {
100
+ readonly api: FlowsWorkspaceApi
101
+ /** Fluxo raiz — o que abre por padrão e o único que não pode ser excluído. */
102
+ readonly rootFlowKey?: string
103
+ readonly labels?: Partial<FlowEditorLabels>
104
+ /** Kinds de ação do produto oferecidos na paleta (`trigger_simulation`, `abrir_comanda`…). */
105
+ readonly actionOptions?: readonly FlowPaletteActionOption[]
106
+ /** Seletor de arquivos do nó `send_media` — a biblioteca é do host, então entra por slot. */
107
+ // Recebe o grafo junto do nó: com fluxos fundidos, o nó em edição pode pertencer a um fluxo que
108
+ // não é o raiz, e o seletor do host precisa da chave dele para saber onde gravar.
109
+ readonly renderMediaPicker?: (node: FlowNodeData, graph: FlowGraphData) => ReactNode
110
+ /** Intervalo do polling de posições vivas. Só tem efeito com `getLivePositions`. */
111
+ readonly livePollIntervalMs?: number
112
+ readonly className?: string
113
+ }
114
+
115
+ // Um portal por (nó de origem, fluxo alvo) — se duas opções do mesmo nó apontarem pro mesmo
116
+ // fluxo, compartilham um único portal (menos poluição visual, ainda uma ligação por opção).
117
+ function portalNodeId(sourceId: string, target: string): string {
118
+ return `__portal__${sourceId}__${target}`
119
+ }
120
+
121
+ // Namespacing de ids: com fusão editável, nós de fluxos diferentes convivem no mesmo canvas
122
+ // React Flow, que exige ids únicos globalmente — "flowKey::nodeId" evita colisão entre fluxos
123
+ // que reutilizem o mesmo id de nó (ex.: vários fluxos com um nó "root").
124
+ function ns(flowKey: string, nodeId: string): string {
125
+ return `${flowKey}${NS_SEP}${nodeId}`
126
+ }
127
+
128
+ function parseNs(id: string): { flowKey: string; nodeId: string } {
129
+ const index = id.indexOf(NS_SEP)
130
+ return index === -1
131
+ ? { flowKey: '', nodeId: id }
132
+ : { flowKey: id.slice(0, index), nodeId: id.slice(index + NS_SEP.length) }
133
+ }
134
+
135
+ // Um fluxo aberto no canvas de detalhe — o primeiro da lista é o "primário" (dono da paleta,
136
+ // organizar, publicar e excluir-fluxo); os demais chegaram por fusão editável (clique num portal)
137
+ // e ficam com um cabeçalho flutuante pra focar neles sozinhos ou fechar.
138
+ type OpenFlow = { key: string; offset: { x: number; y: number } }
139
+
140
+ // Fecho transitivo de saltos flow:<key> a partir de rootKey — "o fluxo completo": abrir um
141
+ // fluxo já traz junto tudo que ele referencia (e o que essas referências referenciam), sem
142
+ // precisar clicar em cada portal manualmente.
143
+ function autoMergeAll(rootKey: string, graphsSource: Record<string, FlowGraphData>): OpenFlow[] {
144
+ const visited = new Set<string>()
145
+ const queue = [rootKey]
146
+ while (queue.length > 0) {
147
+ const key = queue.shift()!
148
+ if (visited.has(key) || !graphsSource[key]) continue
149
+ visited.add(key)
150
+ for (const target of crossFlowTargetsOf(graphsSource[key]!)) {
151
+ if (!visited.has(target) && graphsSource[target]) queue.push(target)
152
+ }
153
+ }
154
+ return [...visited].map((key) => ({ key, offset: { x: 0, y: 0 } }))
155
+ }
156
+
157
+ // Layout único pra TODOS os nós de TODOS os fluxos abertos juntos — ao contrário de posicionar
158
+ // cada fluxo independente e só deslocar (que não evita sobreposição entre fluxos nem considera a
159
+ // altura real de cada card), aqui o ranqueamento por BFS roda sobre o grafo mesclado inteiro,
160
+ // usando ligações reais (inclusive saltos flow:<key> já resolvidos pro nó inicial do fluxo
161
+ // alvo). Mesma orientação do computeAutoLayout: da esquerda para a direita, um rank por coluna.
162
+ function computeMergedLayout(
163
+ openFlows: readonly OpenFlow[],
164
+ workingGraphs: Record<string, FlowGraphData>,
165
+ primaryFlowKey: string,
166
+ ): Map<string, { x: number; y: number }> {
167
+ const openKeys = new Set(openFlows.map((flow) => flow.key))
168
+
169
+ const nodeByNsId = new Map<string, FlowNodeData>()
170
+ for (const { key } of openFlows) {
171
+ const graph = workingGraphs[key]
172
+ if (!graph) continue
173
+ for (const node of Object.values(graph.nodes)) nodeByNsId.set(ns(key, node.id), node)
174
+ }
175
+
176
+ function forwardEdges(flowKey: string, node: FlowNodeData): string[] {
177
+ const result: string[] = []
178
+ for (const { target } of targetsOf(node)) {
179
+ if (isCrossFlowTarget(target)) {
180
+ const targetFlowKey = crossFlowKey(target)
181
+ const targetGraph = openKeys.has(targetFlowKey) ? workingGraphs[targetFlowKey] : undefined
182
+ if (targetGraph) result.push(ns(targetFlowKey, targetGraph.startNodeId))
183
+ } else if (workingGraphs[flowKey]?.nodes[target]) {
184
+ result.push(ns(flowKey, target))
185
+ }
186
+ }
187
+ return result
188
+ }
189
+
190
+ const rank = new Map<string, number>()
191
+ const primaryGraph = workingGraphs[primaryFlowKey]
192
+ const rootId = primaryGraph ? ns(primaryFlowKey, primaryGraph.startNodeId) : undefined
193
+ if (rootId && nodeByNsId.has(rootId)) {
194
+ rank.set(rootId, 0)
195
+ const queue = [rootId]
196
+ while (queue.length > 0) {
197
+ const id = queue.shift()!
198
+ const node = nodeByNsId.get(id)
199
+ if (!node) continue
200
+ for (const nextId of forwardEdges(parseNs(id).flowKey, node)) {
201
+ if (!rank.has(nextId)) {
202
+ rank.set(nextId, rank.get(id)! + 1)
203
+ queue.push(nextId)
204
+ }
205
+ }
206
+ }
207
+ }
208
+
209
+ // Nós não alcançados a partir do início do fluxo primário (outro fluxo mesclado sem ligação
210
+ // de volta pro primário, ou nó órfão) vão TODOS numa única camada extra abaixo de tudo — uma
211
+ // camada por órfão empurrava cada um para uma linha própria, cada vez mais longe da área
212
+ // visível, e desligar um fio dava a impressão de ter apagado o card.
213
+ const strayRank = Math.max(0, ...rank.values()) + 1
214
+ for (const nsId of nodeByNsId.keys()) {
215
+ if (!rank.has(nsId)) rank.set(nsId, strayRank)
216
+ }
217
+
218
+ const layers = new Map<number, string[]>()
219
+ for (const [nsId, nodeRank] of rank) {
220
+ if (!layers.has(nodeRank)) layers.set(nodeRank, [])
221
+ layers.get(nodeRank)!.push(nsId)
222
+ }
223
+
224
+ const positions = new Map<string, { x: number; y: number }>()
225
+ for (const nodeRank of [...layers.keys()].sort((a, b) => a - b)) {
226
+ // Alinhado pelo topo pelo mesmo motivo de `computeAutoLayout`: coluna centralizada saltava
227
+ // inteira a cada nó a mais num ramo.
228
+ let cursorY = 0
229
+ for (const nsId of layers.get(nodeRank)!) {
230
+ positions.set(nsId, { x: nodeRank * COLUMN_GAP, y: cursorY })
231
+ cursorY += estimateNodeHeight(nodeByNsId.get(nsId)!) + ROW_GAP
232
+ }
233
+ }
234
+ return positions
235
+ }
236
+
237
+ function computeLiveCounts(
238
+ flowKey: string,
239
+ rootFlowKey: string,
240
+ livePositions: readonly FlowLivePosition[] | undefined,
241
+ ): Record<string, number> {
242
+ const counts: Record<string, number> = {}
243
+ for (const position of livePositions ?? []) {
244
+ if (position.flow !== flowKey && flowKey !== rootFlowKey) continue
245
+ const nodeId = flowKey === rootFlowKey ? position.menuNodeId : position.nodeId
246
+ if (!nodeId) continue
247
+ counts[nodeId] = (counts[nodeId] ?? 0) + 1
248
+ }
249
+ return counts
250
+ }
251
+
252
+ // Constrói as ligações de TODOS os fluxos abertos juntos. Um salto "flow:<key>" vira ligação
253
+ // real até o nó inicial do fluxo alvo quando esse fluxo já está mesclado no canvas; caso
254
+ // contrário, continua indo até o portal (pseudo-nó) daquele fluxo, como antes da fusão.
255
+ function buildAllEdges(
256
+ openFlows: readonly OpenFlow[],
257
+ workingGraphs: Record<string, FlowGraphData>,
258
+ rootFlowKey: string,
259
+ livePositions: readonly FlowLivePosition[] | undefined,
260
+ ): Edge[] {
261
+ const openKeys = new Set(openFlows.map((flow) => flow.key))
262
+ const edges: Edge[] = []
263
+
264
+ for (const { key: flowKey } of openFlows) {
265
+ const graph = workingGraphs[flowKey]
266
+ if (!graph) continue
267
+ const liveCounts = computeLiveCounts(flowKey, rootFlowKey, livePositions)
268
+
269
+ for (const [id, node] of Object.entries(graph.nodes)) {
270
+ const isLive = (liveCounts[id] ?? 0) > 0
271
+ for (const { target, optionId, isDefault } of targetsOf(node)) {
272
+ const crossFlow = isCrossFlowTarget(target)
273
+ let targetFlowKey = flowKey
274
+ let rawTargetId = target
275
+ if (crossFlow) {
276
+ const wantedKey = crossFlowKey(target)
277
+ const targetGraph = openKeys.has(wantedKey) ? workingGraphs[wantedKey] : undefined
278
+ if (targetGraph) {
279
+ targetFlowKey = wantedKey
280
+ rawTargetId = targetGraph.startNodeId
281
+ } else {
282
+ rawTargetId = portalNodeId(id, target)
283
+ }
284
+ }
285
+ const source = ns(flowKey, id)
286
+ const edgeTarget = ns(targetFlowKey, rawTargetId)
287
+
288
+ if (optionId === undefined && !isDefault) {
289
+ const color = crossFlow ? EDGE_COLOR_CROSS_FLOW : isLive ? EDGE_COLOR_LIVE : EDGE_COLOR_LINEAR
290
+ edges.push({
291
+ id: `${source}->${edgeTarget}`,
292
+ source,
293
+ target: edgeTarget,
294
+ type: 'bezier',
295
+ animated: isLive,
296
+ style: crossFlow
297
+ ? { stroke: color, strokeWidth: 1.5, strokeDasharray: '3 3' }
298
+ : { stroke: color, strokeWidth: isLive ? 2.5 : 1.5 },
299
+ markerEnd: { type: MarkerType.ArrowClosed, color, width: 18, height: 18 },
300
+ })
301
+ } else if (isDefault) {
302
+ const color = crossFlow ? EDGE_COLOR_CROSS_FLOW : EDGE_COLOR_FALLBACK
303
+ edges.push({
304
+ id: `${source}->${edgeTarget}-default`,
305
+ source,
306
+ sourceHandle: '__default',
307
+ target: edgeTarget,
308
+ type: 'bezier',
309
+ style: { stroke: color, strokeWidth: 1.5, strokeDasharray: '5 4' },
310
+ markerEnd: { type: MarkerType.ArrowClosed, color, width: 16, height: 16 },
311
+ })
312
+ } else {
313
+ const color = crossFlow ? EDGE_COLOR_CROSS_FLOW : isLive ? EDGE_COLOR_LIVE : EDGE_COLOR_BRANCH
314
+ edges.push({
315
+ id: `${source}->${edgeTarget}-${optionId}`,
316
+ source,
317
+ sourceHandle: optionId,
318
+ target: edgeTarget,
319
+ type: 'bezier',
320
+ animated: isLive,
321
+ style: crossFlow
322
+ ? { stroke: color, strokeWidth: 1.75, strokeDasharray: '3 3' }
323
+ : { stroke: color, strokeWidth: isLive ? 2.5 : 1.75 },
324
+ markerEnd: { type: MarkerType.ArrowClosed, color, width: 18, height: 18 },
325
+ })
326
+ }
327
+ }
328
+ }
329
+ }
330
+ return edges
331
+ }
332
+
333
+ function newNodeFromSpec(spec: NewNodeSpec, existingIds: Set<string>): FlowNodeData {
334
+ if (spec.kind === 'question') {
335
+ const id = slugifyNodeId('nova_pergunta', existingIds)
336
+ return { id, type: 'question', questionType: spec.questionType, contextKey: id, question: '' }
337
+ }
338
+ if (spec.kind === 'decision') {
339
+ const id = slugifyNodeId('nova_decisao', existingIds)
340
+ return {
341
+ id,
342
+ type: 'question',
343
+ questionType: 'choice',
344
+ contextKey: id,
345
+ question: '',
346
+ options: [
347
+ ['1', 'Opção 1'],
348
+ ['2', 'Opção 2'],
349
+ ],
350
+ }
351
+ }
352
+ if (spec.kind === 'condition') {
353
+ const id = slugifyNodeId('nova_condicao', existingIds)
354
+ return { id, type: 'condition', conditionOperator: '>' }
355
+ }
356
+ const id = slugifyNodeId('nova_acao', existingIds)
357
+ return { id, type: 'action', actionKind: spec.actionKind }
358
+ }
359
+
360
+ // Remove referências ao nó excluído: destinos apontando para ele viram '' (destino vazio),
361
+ // para a validação sinalizar claramente em vez de manter uma string órfã silenciosa.
362
+ function removeNodeAndCleanRefs(
363
+ nodes: Record<string, FlowNodeData>,
364
+ nodeId: string,
365
+ ): Record<string, FlowNodeData> {
366
+ const { [nodeId]: _removed, ...rest } = nodes
367
+ return Object.fromEntries(
368
+ Object.entries(rest).map(([id, node]) => {
369
+ if (!node.next) return [id, node]
370
+ if (typeof node.next === 'string') {
371
+ return [id, node.next === nodeId ? { ...node, next: undefined } : node]
372
+ }
373
+ return [
374
+ id,
375
+ {
376
+ ...node,
377
+ next: {
378
+ byAnswer: Object.fromEntries(
379
+ Object.entries(node.next.byAnswer).map(([key, value]) => [key, value === nodeId ? '' : value]),
380
+ ),
381
+ default: node.next.default === nodeId ? '' : node.next.default,
382
+ },
383
+ },
384
+ ]
385
+ }),
386
+ )
387
+ }
388
+
389
+ function extractErrorMessage(error: unknown): string | undefined {
390
+ if (error instanceof Error) return error.message
391
+ return undefined
392
+ }
393
+
394
+ const OUTLINE_BUTTON =
395
+ 'inline-flex items-center gap-1.5 rounded-lg border border-gray-200 dark:border-gray-700 px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-300 hover:border-blue-300 disabled:opacity-40 disabled:cursor-not-allowed'
396
+ const PRIMARY_BUTTON =
397
+ 'inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed'
398
+ const DIALOG_INPUT =
399
+ 'w-full border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400'
400
+
401
+ /**
402
+ * Editor de fluxograma completo — barra de ações, abas de fluxo, paleta, canvas com fusão
403
+ * editável, painel de nó, mapa de fluxos e diálogos de criar/excluir.
404
+ *
405
+ * É a tela inteira, não as peças: cada produto que remontava esse grid à mão acabava com uma
406
+ * versão diferente do mesmo editor. Customização entra por `labels`, `actionOptions` e
407
+ * `renderMediaPicker` — nunca por cópia do arquivo.
408
+ */
409
+ export function FlowsWorkspace({
410
+ api,
411
+ rootFlowKey = 'menu',
412
+ labels: labelsOverride,
413
+ actionOptions,
414
+ renderMediaPicker,
415
+ livePollIntervalMs = 5000,
416
+ className,
417
+ }: FlowsWorkspaceProps) {
418
+ const labels = useMemo(() => mergeFlowEditorLabels(labelsOverride), [labelsOverride])
419
+ const isDark = useIsDarkTheme()
420
+
421
+ const [graphs, setGraphs] = useState<Record<string, FlowGraphData> | undefined>(undefined)
422
+ const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading')
423
+ const [livePositions, setLivePositions] = useState<FlowLivePosition[] | undefined>(undefined)
424
+ const [viewMode, setViewMode] = useState<'detail' | 'map'>('detail')
425
+ const [openFlows, setOpenFlows] = useState<OpenFlow[]>([{ key: rootFlowKey, offset: { x: 0, y: 0 } }])
426
+ const [hasAutoMerged, setHasAutoMerged] = useState(false)
427
+ const [workingGraphs, setWorkingGraphs] = useState<Record<string, FlowGraphData>>({})
428
+ const [editingRef, setEditingRef] = useState<{ flowKey: string; nodeId: string } | null>(null)
429
+ const [saveState, setSaveState] = useState<'idle' | 'saving' | 'success' | 'error'>('idle')
430
+ const [saveErrorMessage, setSaveErrorMessage] = useState<string | undefined>(undefined)
431
+ const [showCreateDialog, setShowCreateDialog] = useState(false)
432
+ const [showDeleteDialog, setShowDeleteDialog] = useState(false)
433
+ const [newFlow, setNewFlow] = useState({ key: '', label: '', showInMenu: false, menuOptionLabel: '' })
434
+ const [flowMutationState, setFlowMutationState] = useState<{ pending: boolean; error?: string }>({ pending: false })
435
+ const [rfNodes, setRfNodes] = useState<Node[]>([])
436
+ const [flowInstance, setFlowInstance] = useState<ReactFlowInstance | null>(null)
437
+ const [pendingFocusNodeId, setPendingFocusNodeId] = useState<string | null>(null)
438
+
439
+ const reloadGraphs = useCallback(async () => {
440
+ try {
441
+ const loaded = await api.getGraphs()
442
+ setGraphs(loaded)
443
+ setLoadState('ready')
444
+ return loaded
445
+ } catch {
446
+ setLoadState('error')
447
+ return undefined
448
+ }
449
+ }, [api])
450
+
451
+ useEffect(() => {
452
+ void reloadGraphs()
453
+ }, [reloadGraphs])
454
+
455
+ // Polling das posições vivas. `active` corta a resposta que chega depois do desmonte — o
456
+ // intervalo é longo o bastante para uma resposta lenta atravessar a troca de tela.
457
+ useEffect(() => {
458
+ const fetchLive = api.getLivePositions
459
+ if (!fetchLive) return
460
+ let active = true
461
+ async function poll(): Promise<void> {
462
+ try {
463
+ const positions = await fetchLive!()
464
+ if (active) setLivePositions(positions)
465
+ } catch {
466
+ // Contagem viva é enfeite: falhar aqui não pode derrubar o editor.
467
+ }
468
+ }
469
+ void poll()
470
+ const timer = setInterval(() => void poll(), livePollIntervalMs)
471
+ return () => {
472
+ active = false
473
+ clearInterval(timer)
474
+ }
475
+ }, [api, livePollIntervalMs])
476
+
477
+ const primaryFlowKey = openFlows[0]?.key ?? rootFlowKey
478
+ const primaryGraph = workingGraphs[primaryFlowKey]
479
+
480
+ // Assim que os fluxos carregam pela primeira vez, mescla automaticamente todo o fecho
481
+ // transitivo referenciado a partir da raiz — "o fluxo completo" aparece de cara, sem precisar
482
+ // clicar em cada portal. Só roda uma vez (hasAutoMerged); depois disso, focar/mesclar/fechar
483
+ // fica inteiramente sob controle do usuário.
484
+ useEffect(() => {
485
+ if (!graphs || hasAutoMerged) return
486
+ setOpenFlows(autoMergeAll(rootFlowKey, graphs))
487
+ setHasAutoMerged(true)
488
+ }, [graphs, hasAutoMerged, rootFlowKey])
489
+
490
+ // Semeia o rascunho local de qualquer fluxo recém-aberto (seleção inicial, foco ou fusão) —
491
+ // nunca sobrescreve um fluxo que já tem rascunho (preserva edições não publicadas mesmo em
492
+ // recargas de fundo).
493
+ useEffect(() => {
494
+ if (!graphs) return
495
+ setWorkingGraphs((prev) => {
496
+ let changed = false
497
+ const next = { ...prev }
498
+ for (const { key } of openFlows) {
499
+ if (!next[key] && graphs[key]) {
500
+ next[key] = graphs[key]
501
+ changed = true
502
+ }
503
+ }
504
+ return changed ? next : prev
505
+ })
506
+ }, [graphs, openFlows])
507
+
508
+ const isFlowDirty = useCallback(
509
+ (key: string): boolean => {
510
+ const working = workingGraphs[key]
511
+ const server = graphs?.[key]
512
+ if (!working || !server) return false
513
+ return JSON.stringify(working) !== JSON.stringify(server)
514
+ },
515
+ [workingGraphs, graphs],
516
+ )
517
+
518
+ const issuesByFlow = useMemo<Record<string, GraphIssue[]>>(() => {
519
+ const map: Record<string, GraphIssue[]> = {}
520
+ for (const { key } of openFlows) {
521
+ const graph = workingGraphs[key]
522
+ if (graph) map[key] = validateGraph(graph, labels.validation)
523
+ }
524
+ return map
525
+ }, [openFlows, workingGraphs, labels])
526
+
527
+ const errorCount = Object.values(issuesByFlow).reduce(
528
+ (sum, list) => sum + list.filter((issue) => issue.severity === 'error').length,
529
+ 0,
530
+ )
531
+ const warningCount = Object.values(issuesByFlow).reduce(
532
+ (sum, list) => sum + list.filter((issue) => issue.severity === 'warning').length,
533
+ 0,
534
+ )
535
+ const dirtyKeys = openFlows.map((flow) => flow.key).filter(isFlowDirty)
536
+ const isDirty = dirtyKeys.length > 0
537
+
538
+ const updateFlow = useCallback((flowKey: string, updater: (graph: FlowGraphData) => FlowGraphData) => {
539
+ setWorkingGraphs((prev) => {
540
+ const graph = prev[flowKey]
541
+ if (!graph) return prev
542
+ return { ...prev, [flowKey]: updater(graph) }
543
+ })
544
+ }, [])
545
+
546
+ // "Focar": troca o fluxo primário e re-mescla o fecho transitivo dele (o fluxo completo
547
+ // referenciado a partir dele) — não isola mais num único fluxo sozinho, já que o padrão
548
+ // agora é sempre mostrar tudo que está conectado.
549
+ const focusFlow = useCallback(
550
+ (key: string) => {
551
+ const others = openFlows.filter((flow) => flow.key !== key)
552
+ if (others.some((flow) => isFlowDirty(flow.key)) && !window.confirm(labels.workspace.unsavedChangesConfirm)) return
553
+ setOpenFlows(graphs ? autoMergeAll(key, graphs) : [{ key, offset: { x: 0, y: 0 } }])
554
+ if (editingRef && editingRef.flowKey !== key) setEditingRef(null)
555
+ },
556
+ [openFlows, isFlowDirty, editingRef, graphs, labels],
557
+ )
558
+
559
+ // "Fechar": remove um fluxo mesclado sem trocar o foco do primário.
560
+ const closeFlow = useCallback(
561
+ (key: string) => {
562
+ if (isFlowDirty(key) && !window.confirm(labels.workspace.unsavedChangesConfirm)) return
563
+ setOpenFlows((prev) => prev.filter((flow) => flow.key !== key))
564
+ setWorkingGraphs((prev) => {
565
+ const { [key]: _removed, ...rest } = prev
566
+ return rest
567
+ })
568
+ if (editingRef?.flowKey === key) setEditingRef(null)
569
+ },
570
+ [isFlowDirty, editingRef, labels],
571
+ )
572
+
573
+ // Fusão editável: mescla o fluxo alvo no mesmo canvas, posicionado à direita do nó de
574
+ // origem que o referenciou, com os nós de verdade (editáveis ali mesmo) em vez de um portal.
575
+ const mergeFlow = useCallback(
576
+ (targetFlowKey: string, source: { flowKey: string; nodeId: string }) => {
577
+ setOpenFlows((prev) => {
578
+ if (prev.some((flow) => flow.key === targetFlowKey)) return prev
579
+ const sourceOpen = prev.find((flow) => flow.key === source.flowKey)
580
+ const sourceGraph = workingGraphs[source.flowKey]
581
+ const sourceLocalPosition =
582
+ sourceGraph?.nodes[source.nodeId]?.position ??
583
+ (sourceGraph ? computeAutoLayout(sourceGraph)[source.nodeId] : undefined) ?? { x: 0, y: 0 }
584
+ const baseOffset = sourceOpen?.offset ?? { x: 0, y: 0 }
585
+ return [
586
+ ...prev,
587
+ {
588
+ key: targetFlowKey,
589
+ offset: {
590
+ x: baseOffset.x + sourceLocalPosition.x + 400,
591
+ y: baseOffset.y + sourceLocalPosition.y,
592
+ },
593
+ },
594
+ ]
595
+ })
596
+ },
597
+ [workingGraphs],
598
+ )
599
+
600
+ // Mais de um fluxo aberto = layout global (ignora node.position individual, recalcula tudo
601
+ // junto pra nunca sobrepor); um só fluxo aberto = comportamento de sempre (respeita posição
602
+ // salva/arrastada, com fallback pro auto-layout daquele fluxo isolado).
603
+ const isMerged = openFlows.length > 1
604
+ const mergedPositions = useMemo(
605
+ () => (isMerged ? computeMergedLayout(openFlows, workingGraphs, primaryFlowKey) : null),
606
+ [isMerged, openFlows, workingGraphs, primaryFlowKey],
607
+ )
608
+
609
+ // Última posição em que cada nó foi desenhado, e ela manda sobre o layout calculado.
610
+ //
611
+ // O layout mesclado é recalculado a cada mudança de topologia: ligar ou desligar um fio mexia
612
+ // no rank de todo mundo e o canvas inteiro saltava de lugar — o card que perdeu a ligação ia
613
+ // parar na faixa dos órfãos e os demais mudavam de coluna, o que se lê como "o card sumiu".
614
+ // Aqui o layout vira só a semente de quem ainda não tem lugar; o resto fica onde está até o
615
+ // usuário arrastar ou pedir "Organizar".
616
+ const renderedPositionsRef = useRef(new Map<string, { x: number; y: number }>())
617
+
618
+ const derivedNodes = useMemo<Node[]>(() => {
619
+ const allNodes: Node[] = []
620
+ for (const { key: flowKey, offset } of openFlows) {
621
+ const graph = workingGraphs[flowKey]
622
+ if (!graph) continue
623
+ const fallbackPositions = mergedPositions ? {} : computeAutoLayout(graph)
624
+ const liveCounts = computeLiveCounts(flowKey, rootFlowKey, livePositions)
625
+ const flowIssues = issuesByFlow[flowKey] ?? []
626
+ const isPrimary = flowKey === primaryFlowKey
627
+
628
+ // Quem recebe fio de alguém neste fluxo. O que sobra (fora o nó inicial) está solto: o card
629
+ // ganha contorno tracejado e pulsa, para desconectar ou criar um nó ficar visivelmente
630
+ // "falta ligar isto aqui" em vez de silencioso.
631
+ const connectedTargets = new Set<string>()
632
+ for (const candidate of Object.values(graph.nodes)) {
633
+ for (const { target } of targetsOf(candidate)) {
634
+ if (!isCrossFlowTarget(target)) connectedTargets.add(target)
635
+ }
636
+ }
637
+
638
+ function resolvePosition(nodeId: string): { x: number; y: number } {
639
+ if (mergedPositions) {
640
+ const nsId = ns(flowKey, nodeId)
641
+ return renderedPositionsRef.current.get(nsId) ?? mergedPositions.get(nsId) ?? { x: 0, y: 0 }
642
+ }
643
+ const local = graph!.nodes[nodeId]?.position ?? fallbackPositions[nodeId] ?? { x: 0, y: 0 }
644
+ return { x: local.x + offset.x, y: local.y + offset.y }
645
+ }
646
+
647
+ for (const node of Object.values(graph.nodes)) {
648
+ const position = resolvePosition(node.id)
649
+ allNodes.push({
650
+ id: ns(flowKey, node.id),
651
+ type: 'flowNode',
652
+ position,
653
+ draggable: true,
654
+ data: {
655
+ node,
656
+ liveCount: liveCounts[node.id] ?? 0,
657
+ isStart: node.id === graph.startNodeId,
658
+ isSelected: editingRef?.flowKey === flowKey && editingRef?.nodeId === node.id,
659
+ isDetached: node.id !== graph.startNodeId && !connectedTargets.has(node.id),
660
+ issues: flowIssues,
661
+ labels,
662
+ onSelect: (nodeId: string) => setEditingRef({ flowKey, nodeId }),
663
+ } satisfies FlowNodeCardData,
664
+ })
665
+
666
+ // Um portal por (nó de origem, fluxo alvo único) — só para saltos cujo fluxo alvo AINDA
667
+ // não está mesclado no canvas (hoje raro, já que abrir um fluxo já mescla tudo que ele
668
+ // referencia — mas serve de rede de segurança pra um fluxo criado depois da fusão
669
+ // inicial); se já estiver mesclado, buildAllEdges liga direto ao nó real.
670
+ const crossFlowTargets = [...new Set(targetsOf(node).map((edge) => edge.target).filter(isCrossFlowTarget))]
671
+ crossFlowTargets.forEach((target, index) => {
672
+ const targetFlowKey = crossFlowKey(target)
673
+ if (openFlows.some((flow) => flow.key === targetFlowKey)) return
674
+ allNodes.push({
675
+ id: ns(flowKey, portalNodeId(node.id, target)),
676
+ type: 'flowPortal',
677
+ draggable: false,
678
+ selectable: false,
679
+ position: { x: position.x + 320, y: position.y + index * 70 },
680
+ data: {
681
+ label: graphs?.[targetFlowKey]?.label ?? targetFlowKey,
682
+ onNavigate: () => mergeFlow(targetFlowKey, { flowKey, nodeId: node.id }),
683
+ } satisfies FlowPortalNodeData,
684
+ })
685
+ })
686
+ }
687
+
688
+ // Moldura decorativa por trás de cada cadeia de perguntas lineares que alimenta uma ação —
689
+ // puramente derivada da topologia do grafo, sem precisar marcar manualmente quais perguntas
690
+ // "pertencem" ao cálculo.
691
+ for (const chain of findCollectionChains(graph)) {
692
+ const chainNodeIds = [...chain.nodeIds, chain.actionNodeId]
693
+ const positions = chainNodeIds.map((id) => resolvePosition(id))
694
+ const minX = Math.min(...positions.map((point) => point.x))
695
+ const maxX = Math.max(...positions.map((point) => point.x)) + NODE_CARD_WIDTH
696
+ const minY = Math.min(...positions.map((point) => point.y))
697
+ const maxY = Math.max(...positions.map((point) => point.y)) + estimateNodeHeight(graph.nodes[chain.actionNodeId]!)
698
+ allNodes.push({
699
+ id: ns(flowKey, `__chain__${chain.actionNodeId}`),
700
+ type: 'flowGroupFrame',
701
+ draggable: false,
702
+ selectable: false,
703
+ zIndex: -1,
704
+ position: { x: minX - CHAIN_FRAME_PADDING, y: minY - CHAIN_FRAME_PADDING - 24 },
705
+ style: {
706
+ width: maxX - minX + CHAIN_FRAME_PADDING * 2,
707
+ height: maxY - minY + CHAIN_FRAME_PADDING * 2 + 24,
708
+ },
709
+ data: {
710
+ label: labels.collectionChain.feeds(nodeLabel(graph.nodes[chain.actionNodeId], labels)),
711
+ } satisfies FlowGroupFrameData,
712
+ })
713
+ }
714
+
715
+ // Cabeçalho flutuante só para fluxos mesclados (o primário já tem controles na barra
716
+ // de cima — paleta, organizar, publicar, excluir).
717
+ if (!isPrimary) {
718
+ const startPosition = resolvePosition(graph.startNodeId)
719
+ allNodes.push({
720
+ id: ns(flowKey, '__group_header__'),
721
+ type: 'flowGroupHeader',
722
+ draggable: false,
723
+ selectable: false,
724
+ position: { x: startPosition.x, y: startPosition.y - 60 },
725
+ data: {
726
+ label: graph.label,
727
+ onFocus: () => focusFlow(flowKey),
728
+ onClose: () => closeFlow(flowKey),
729
+ } satisfies FlowGroupHeaderData,
730
+ })
731
+ }
732
+ }
733
+ return allNodes
734
+ }, [
735
+ openFlows,
736
+ workingGraphs,
737
+ mergedPositions,
738
+ livePositions,
739
+ issuesByFlow,
740
+ primaryFlowKey,
741
+ rootFlowKey,
742
+ editingRef,
743
+ graphs,
744
+ mergeFlow,
745
+ focusFlow,
746
+ closeFlow,
747
+ labels,
748
+ ])
749
+
750
+ const edges = useMemo(
751
+ () => buildAllEdges(openFlows, workingGraphs, rootFlowKey, livePositions),
752
+ [openFlows, workingGraphs, rootFlowKey, livePositions],
753
+ )
754
+
755
+ useEffect(() => {
756
+ setRfNodes(derivedNodes)
757
+ for (const node of derivedNodes) {
758
+ if (node.type === 'flowNode') renderedPositionsRef.current.set(node.id, node.position)
759
+ }
760
+ }, [derivedNodes])
761
+
762
+ // Nó recém-criado ainda não tem ligação, e o layout manda todo órfão para uma faixa abaixo de
763
+ // tudo — num canvas com vários fluxos mesclados isso cai longe da área visível, e o card parece
764
+ // ter sumido. Espera ele existir no canvas e leva a viewport até lá.
765
+ useEffect(() => {
766
+ if (!pendingFocusNodeId || !flowInstance) return
767
+ const target = rfNodes.find((node) => node.id === pendingFocusNodeId)
768
+ if (!target) return
769
+ flowInstance.setCenter(target.position.x + NODE_CARD_WIDTH / 2, target.position.y, { zoom: 1, duration: 400 })
770
+ setPendingFocusNodeId(null)
771
+ }, [pendingFocusNodeId, flowInstance, rfNodes])
772
+
773
+ const onNodesChange = useCallback((changes: NodeChange[]) => {
774
+ setRfNodes((current) => applyNodeChanges(changes, current))
775
+ }, [])
776
+
777
+ const onNodeDragStop = useCallback(
778
+ (_event: unknown, node: Node) => {
779
+ renderedPositionsRef.current.set(node.id, node.position)
780
+ const { flowKey, nodeId } = parseNs(node.id)
781
+ const openFlow = openFlows.find((flow) => flow.key === flowKey)
782
+ if (!openFlow) return
783
+ const localPosition = { x: node.position.x - openFlow.offset.x, y: node.position.y - openFlow.offset.y }
784
+ updateFlow(flowKey, (graph) =>
785
+ graph.nodes[nodeId]
786
+ ? { ...graph, nodes: { ...graph.nodes, [nodeId]: { ...graph.nodes[nodeId]!, position: localPosition } } }
787
+ : graph,
788
+ )
789
+ },
790
+ [openFlows, updateFlow],
791
+ )
792
+
793
+ const onConnect = useCallback(
794
+ (connection: Connection) => {
795
+ const { source, sourceHandle, target } = connection
796
+ if (!source || !target || source === target) return
797
+ const sourceRef = parseNs(source)
798
+ const targetRef = parseNs(target)
799
+
800
+ // Conectar num nó de outro fluxo só faz sentido se for o nó inicial dele — vira um salto
801
+ // "flow:<key>" (o motor do bot não sabe pular pra um nó específico de outro fluxo, só
802
+ // pro início). Conectar num nó do meio de outro fluxo é ignorado silenciosamente.
803
+ let targetValue: string
804
+ if (targetRef.flowKey === sourceRef.flowKey) {
805
+ targetValue = targetRef.nodeId
806
+ } else {
807
+ const targetGraph = workingGraphs[targetRef.flowKey]
808
+ if (!targetGraph || targetGraph.startNodeId !== targetRef.nodeId) return
809
+ targetValue = `${CROSS_FLOW_PREFIX}${targetRef.flowKey}`
810
+ }
811
+
812
+ updateFlow(sourceRef.flowKey, (graph) => {
813
+ const node = graph.nodes[sourceRef.nodeId]
814
+ if (!node) return graph
815
+ const handle = sourceHandle ?? 'next'
816
+ const currentByAnswer = typeof node.next === 'object' && node.next ? node.next.byAnswer : {}
817
+ const currentDefault = typeof node.next === 'object' && node.next ? node.next.default : ''
818
+ const updatedNode: FlowNodeData =
819
+ handle === 'next'
820
+ ? { ...node, next: targetValue }
821
+ : handle === '__default'
822
+ ? { ...node, next: { byAnswer: currentByAnswer, default: targetValue } }
823
+ : { ...node, next: { byAnswer: { ...currentByAnswer, [handle]: targetValue }, default: currentDefault } }
824
+ return { ...graph, nodes: { ...graph.nodes, [sourceRef.nodeId]: updatedNode } }
825
+ })
826
+ },
827
+ [workingGraphs, updateFlow],
828
+ )
829
+
830
+ function handleAddNode(spec: NewNodeSpec) {
831
+ if (!primaryGraph) return
832
+ const newNode = newNodeFromSpec(spec, new Set(Object.keys(primaryGraph.nodes)))
833
+ const maxY = Math.max(0, ...Object.values(primaryGraph.nodes).map((node) => node.position?.y ?? 0))
834
+ newNode.position = { x: 0, y: maxY + 170 }
835
+ updateFlow(primaryFlowKey, (graph) => ({ ...graph, nodes: { ...graph.nodes, [newNode.id]: newNode } }))
836
+ setEditingRef({ flowKey: primaryFlowKey, nodeId: newNode.id })
837
+ setPendingFocusNodeId(ns(primaryFlowKey, newNode.id))
838
+ }
839
+
840
+ function handleNodePanelChange(updated: FlowNodeData) {
841
+ if (!editingRef) return
842
+ updateFlow(editingRef.flowKey, (graph) => ({ ...graph, nodes: { ...graph.nodes, [updated.id]: updated } }))
843
+ setEditingRef(null)
844
+ }
845
+
846
+ function handleNodeDelete(nodeId: string) {
847
+ if (!editingRef) return
848
+ updateFlow(editingRef.flowKey, (graph) => ({ ...graph, nodes: removeNodeAndCleanRefs(graph.nodes, nodeId) }))
849
+ setEditingRef(null)
850
+ }
851
+
852
+ // Único caminho que move card sozinho — as posições são estáveis no resto do tempo, então
853
+ // reorganizar virou uma ação explícita, inclusive com vários fluxos mesclados no canvas.
854
+ function handleOrganize() {
855
+ if (!primaryGraph) return
856
+ renderedPositionsRef.current.clear()
857
+
858
+ if (isMerged) {
859
+ const positions = computeMergedLayout(openFlows, workingGraphs, primaryFlowKey)
860
+ for (const { key, offset } of openFlows) {
861
+ updateFlow(key, (graph) => ({
862
+ ...graph,
863
+ nodes: Object.fromEntries(
864
+ Object.entries(graph.nodes).map(([id, node]) => {
865
+ const position = positions.get(ns(key, id))
866
+ return [
867
+ id,
868
+ position ? { ...node, position: { x: position.x - offset.x, y: position.y - offset.y } } : node,
869
+ ]
870
+ }),
871
+ ),
872
+ }))
873
+ }
874
+ return
875
+ }
876
+
877
+ const positions = computeAutoLayout(primaryGraph)
878
+ updateFlow(primaryFlowKey, (graph) => ({
879
+ ...graph,
880
+ nodes: Object.fromEntries(
881
+ Object.entries(graph.nodes).map(([id, node]) => [id, { ...node, position: positions[id] ?? node.position }]),
882
+ ),
883
+ }))
884
+ }
885
+
886
+ // Descarta o rascunho local e volta ao que está publicado. Sem isso, a única saída de uma
887
+ // edição indesejada era recarregar a página no susto — e recarregar também perde o resto.
888
+ function handleDiscardChanges() {
889
+ if (!graphs || !isDirty) return
890
+ if (!window.confirm(labels.workspace.discardConfirm)) return
891
+ setWorkingGraphs((prev) => {
892
+ const next = { ...prev }
893
+ for (const key of dirtyKeys) {
894
+ const published = graphs[key]
895
+ if (published) next[key] = published
896
+ }
897
+ return next
898
+ })
899
+ renderedPositionsRef.current.clear()
900
+ setEditingRef(null)
901
+ }
902
+
903
+ async function handlePublish() {
904
+ if (dirtyKeys.length === 0 || errorCount > 0) return
905
+ setSaveState('saving')
906
+ setSaveErrorMessage(undefined)
907
+ try {
908
+ await Promise.all(dirtyKeys.map((key) => api.saveGraph(key, workingGraphs[key]!)))
909
+ await reloadGraphs()
910
+ setSaveState('success')
911
+ setTimeout(() => setSaveState('idle'), 3000)
912
+ } catch (error) {
913
+ setSaveState('error')
914
+ setSaveErrorMessage(extractErrorMessage(error))
915
+ }
916
+ }
917
+
918
+ async function handleCreateFlow() {
919
+ const createFlow = api.createFlow
920
+ if (!createFlow) return
921
+ setFlowMutationState({ pending: true })
922
+ try {
923
+ await createFlow({
924
+ key: newFlow.key,
925
+ label: newFlow.label,
926
+ showInMenu: newFlow.showInMenu,
927
+ ...(newFlow.showInMenu ? { menuOptionLabel: newFlow.menuOptionLabel || newFlow.label } : {}),
928
+ })
929
+ await reloadGraphs()
930
+ setOpenFlows([{ key: newFlow.key, offset: { x: 0, y: 0 } }])
931
+ setShowCreateDialog(false)
932
+ setNewFlow({ key: '', label: '', showInMenu: false, menuOptionLabel: '' })
933
+ setFlowMutationState({ pending: false })
934
+ } catch (error) {
935
+ setFlowMutationState({ pending: false, error: extractErrorMessage(error) ?? labels.flowManager.createError })
936
+ }
937
+ }
938
+
939
+ async function handleDeleteFlow() {
940
+ const deleteFlow = api.deleteFlow
941
+ if (!deleteFlow || !primaryGraph) return
942
+ setFlowMutationState({ pending: true })
943
+ try {
944
+ await deleteFlow(primaryGraph.key)
945
+ const reloaded = await reloadGraphs()
946
+ setOpenFlows(reloaded ? autoMergeAll(rootFlowKey, reloaded) : [{ key: rootFlowKey, offset: { x: 0, y: 0 } }])
947
+ setShowDeleteDialog(false)
948
+ setFlowMutationState({ pending: false })
949
+ } catch (error) {
950
+ setFlowMutationState({ pending: false, error: extractErrorMessage(error) ?? labels.flowManager.deleteError })
951
+ }
952
+ }
953
+
954
+ const editingGraph = editingRef ? workingGraphs[editingRef.flowKey] : null
955
+ const editingNode = editingRef && editingGraph ? editingGraph.nodes[editingRef.nodeId] : null
956
+ const otherFlows = useMemo(
957
+ () =>
958
+ Object.values(graphs ?? {})
959
+ .filter((graph) => graph.key !== editingRef?.flowKey)
960
+ .map((graph) => ({ key: graph.key, label: graph.label })),
961
+ [graphs, editingRef?.flowKey],
962
+ )
963
+ const keyIsValid = FLOW_KEY_PATTERN.test(newFlow.key)
964
+ const canCreateFlow = Boolean(api.createFlow)
965
+ const canDeleteFlow = Boolean(api.deleteFlow) && primaryFlowKey !== rootFlowKey
966
+
967
+ return (
968
+ <div className={`space-y-4 h-full flex flex-col ${className ?? ''}`}>
969
+ <div className="flex items-center justify-between flex-wrap gap-3">
970
+ <div>
971
+ <h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{labels.workspace.title}</h2>
972
+ <p className="text-gray-500 dark:text-gray-400 text-sm mt-1">{labels.workspace.subtitle}</p>
973
+ </div>
974
+ <div className="flex items-center gap-3">
975
+ {saveState === 'success' && <span className="text-sm text-green-600">{labels.workspace.saveSuccess}</span>}
976
+ {saveState === 'error' && (
977
+ <span className="text-sm text-red-600">{saveErrorMessage ?? labels.workspace.saveError}</span>
978
+ )}
979
+ <button
980
+ type="button"
981
+ onClick={() => setViewMode((mode) => (mode === 'map' ? 'detail' : 'map'))}
982
+ className={OUTLINE_BUTTON}
983
+ >
984
+ {viewMode === 'map' ? <Workflow size={14} aria-hidden="true" /> : <MapIcon size={14} aria-hidden="true" />}
985
+ {viewMode === 'map' ? labels.flowMap.toggleToDetail : labels.flowMap.toggleToMap}
986
+ </button>
987
+ {viewMode === 'detail' && (
988
+ <>
989
+ <button
990
+ type="button"
991
+ onClick={handleOrganize}
992
+ className={OUTLINE_BUTTON}
993
+ title={labels.workspace.organizeTooltip}
994
+ disabled={!primaryGraph}
995
+ >
996
+ <LayoutGrid size={14} aria-hidden="true" /> {labels.workspace.organize}
997
+ </button>
998
+ <button
999
+ type="button"
1000
+ onClick={handleDiscardChanges}
1001
+ className={OUTLINE_BUTTON}
1002
+ title={labels.workspace.discardTooltip}
1003
+ disabled={!isDirty || saveState === 'saving'}
1004
+ >
1005
+ <Undo2 size={14} aria-hidden="true" /> {labels.workspace.discardChanges}
1006
+ </button>
1007
+ <button
1008
+ type="button"
1009
+ onClick={() => void handlePublish()}
1010
+ className={PRIMARY_BUTTON}
1011
+ disabled={!isDirty || errorCount > 0 || saveState === 'saving'}
1012
+ >
1013
+ <Save size={14} aria-hidden="true" />
1014
+ {saveState === 'saving' ? labels.workspace.saving : labels.workspace.saveGraph}
1015
+ </button>
1016
+ </>
1017
+ )}
1018
+ </div>
1019
+ </div>
1020
+
1021
+ {viewMode === 'detail' && (
1022
+ <div className="flex items-center justify-between gap-2 flex-wrap">
1023
+ <div className="flex items-center gap-2 flex-wrap">
1024
+ {graphs &&
1025
+ Object.values(graphs).map((graph) => (
1026
+ <button
1027
+ key={graph.key}
1028
+ type="button"
1029
+ onClick={() => focusFlow(graph.key)}
1030
+ className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ${
1031
+ primaryFlowKey === graph.key
1032
+ ? 'bg-blue-600 text-white border-blue-600'
1033
+ : 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 border-gray-200 dark:border-gray-700 hover:border-blue-300'
1034
+ }`}
1035
+ >
1036
+ {graph.label}
1037
+ </button>
1038
+ ))}
1039
+ {canCreateFlow && (
1040
+ <button
1041
+ type="button"
1042
+ onClick={() => {
1043
+ setFlowMutationState({ pending: false })
1044
+ setShowCreateDialog(true)
1045
+ }}
1046
+ className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium border border-dashed border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400 hover:border-blue-400 hover:text-blue-600"
1047
+ >
1048
+ <Plus size={12} aria-hidden="true" /> {labels.flowManager.newFlow}
1049
+ </button>
1050
+ )}
1051
+ </div>
1052
+ <div className="flex items-center gap-2">
1053
+ {primaryGraph && (
1054
+ <FlowPalette
1055
+ onAdd={handleAddNode}
1056
+ labels={labels}
1057
+ {...(actionOptions ? { actionOptions: [...actionOptions] } : {})}
1058
+ />
1059
+ )}
1060
+ {primaryGraph && canDeleteFlow && (
1061
+ <button
1062
+ type="button"
1063
+ onClick={() => {
1064
+ setFlowMutationState({ pending: false })
1065
+ setShowDeleteDialog(true)
1066
+ }}
1067
+ className="inline-flex items-center gap-1.5 rounded-lg border border-red-200 dark:border-red-900 px-3 py-1.5 text-xs font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/30"
1068
+ >
1069
+ <Trash2 size={13} aria-hidden="true" /> {labels.flowManager.deleteFlow}
1070
+ </button>
1071
+ )}
1072
+ </div>
1073
+ </div>
1074
+ )}
1075
+
1076
+ {viewMode === 'detail' && (errorCount > 0 || warningCount > 0) && (
1077
+ <div className="flex items-center gap-4 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/60 px-3 py-2 text-xs">
1078
+ <span className="font-medium text-gray-600 dark:text-gray-300">{labels.validation.title}:</span>
1079
+ {errorCount > 0 && (
1080
+ <span className="flex items-center gap-1 text-red-600 dark:text-red-400 font-medium">
1081
+ <AlertCircle size={13} aria-hidden="true" /> {labels.validation.errors(errorCount)}
1082
+ </span>
1083
+ )}
1084
+ {warningCount > 0 && (
1085
+ <span className="flex items-center gap-1 text-amber-600 dark:text-amber-400">
1086
+ <AlertTriangle size={13} aria-hidden="true" /> {labels.validation.warnings(warningCount)}
1087
+ </span>
1088
+ )}
1089
+ </div>
1090
+ )}
1091
+
1092
+ <div className="flex-1 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden relative">
1093
+ {loadState === 'loading' && <p className="text-center text-gray-400 py-12">{labels.workspace.loading}</p>}
1094
+ {loadState === 'error' && <p className="text-center text-red-500 py-12">{labels.workspace.loadError}</p>}
1095
+ {loadState === 'ready' && graphs && viewMode === 'map' && (
1096
+ <FlowMapCanvas
1097
+ graphs={graphs}
1098
+ rootKey={rootFlowKey}
1099
+ labels={labels}
1100
+ onOpenFlow={(key) => {
1101
+ focusFlow(key)
1102
+ setViewMode('detail')
1103
+ }}
1104
+ />
1105
+ )}
1106
+ {loadState === 'ready' && viewMode === 'detail' && primaryGraph && (
1107
+ <ReactFlow
1108
+ nodes={rfNodes}
1109
+ edges={edges}
1110
+ nodeTypes={RF_NODE_TYPES}
1111
+ onNodesChange={onNodesChange}
1112
+ onNodeDragStop={onNodeDragStop}
1113
+ onConnect={onConnect}
1114
+ onInit={setFlowInstance}
1115
+ fitView
1116
+ proOptions={{ hideAttribution: true }}
1117
+ colorMode={isDark ? 'dark' : 'light'}
1118
+ >
1119
+ <Background color={isDark ? BACKGROUND_COLOR_DARK : BACKGROUND_COLOR_LIGHT} />
1120
+ <Controls />
1121
+ <MiniMap pannable zoomable className="!bg-white dark:!bg-gray-800" />
1122
+ </ReactFlow>
1123
+ )}
1124
+ </div>
1125
+
1126
+ {/* `key` por nó: o painel guarda um rascunho local em estado, e sem remontar ao trocar de nó
1127
+ selecionado ele seguia mostrando (e salvando) os campos do nó anterior. */}
1128
+ {editingNode && editingGraph && (
1129
+ <FlowNodePanel
1130
+ key={`${editingRef?.flowKey}:${editingRef?.nodeId}`}
1131
+ graph={editingGraph}
1132
+ node={editingNode}
1133
+ issues={editingRef ? (issuesByFlow[editingRef.flowKey] ?? []) : []}
1134
+ otherFlows={otherFlows}
1135
+ labels={labels}
1136
+ onClose={() => setEditingRef(null)}
1137
+ onChange={handleNodePanelChange}
1138
+ onDelete={handleNodeDelete}
1139
+ {...(renderMediaPicker ? { renderMediaPicker } : {})}
1140
+ />
1141
+ )}
1142
+
1143
+ {showCreateDialog && canCreateFlow && (
1144
+ <FlowDialog title={labels.flowManager.createTitle} onClose={() => setShowCreateDialog(false)}>
1145
+ <div className="space-y-3">
1146
+ <div>
1147
+ <label className="text-xs font-medium text-gray-500 dark:text-gray-400">{labels.flowManager.label}</label>
1148
+ <input
1149
+ value={newFlow.label}
1150
+ onChange={(event) => setNewFlow((prev) => ({ ...prev, label: event.target.value }))}
1151
+ className={`mt-1 ${DIALOG_INPUT}`}
1152
+ />
1153
+ </div>
1154
+ <div>
1155
+ <label className="text-xs font-medium text-gray-500 dark:text-gray-400">{labels.flowManager.key}</label>
1156
+ <input
1157
+ value={newFlow.key}
1158
+ onChange={(event) => setNewFlow((prev) => ({ ...prev, key: event.target.value.toLowerCase() }))}
1159
+ className={`mt-1 ${DIALOG_INPUT}`}
1160
+ />
1161
+ <p className="text-[11px] text-gray-400 mt-1">
1162
+ {newFlow.key && !keyIsValid ? labels.flowManager.keyInvalid : labels.flowManager.keyHint}
1163
+ </p>
1164
+ </div>
1165
+ <label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200">
1166
+ <input
1167
+ type="checkbox"
1168
+ checked={newFlow.showInMenu}
1169
+ onChange={(event) => setNewFlow((prev) => ({ ...prev, showInMenu: event.target.checked }))}
1170
+ />
1171
+ {labels.flowManager.showInMenu}
1172
+ </label>
1173
+ {newFlow.showInMenu && (
1174
+ <div>
1175
+ <label className="text-xs font-medium text-gray-500 dark:text-gray-400">
1176
+ {labels.flowManager.menuOptionLabel}
1177
+ </label>
1178
+ <input
1179
+ value={newFlow.menuOptionLabel}
1180
+ onChange={(event) => setNewFlow((prev) => ({ ...prev, menuOptionLabel: event.target.value }))}
1181
+ placeholder={newFlow.label}
1182
+ className={`mt-1 ${DIALOG_INPUT}`}
1183
+ />
1184
+ </div>
1185
+ )}
1186
+ {flowMutationState.error && <p className="text-xs text-red-600">{flowMutationState.error}</p>}
1187
+ </div>
1188
+ <div className="flex justify-end gap-2 mt-4">
1189
+ <button type="button" className={OUTLINE_BUTTON} onClick={() => setShowCreateDialog(false)}>
1190
+ {labels.nodePanel.cancel}
1191
+ </button>
1192
+ <button
1193
+ type="button"
1194
+ className={PRIMARY_BUTTON}
1195
+ onClick={() => void handleCreateFlow()}
1196
+ disabled={!keyIsValid || !newFlow.label || flowMutationState.pending}
1197
+ >
1198
+ {flowMutationState.pending ? labels.flowManager.creating : labels.flowManager.create}
1199
+ </button>
1200
+ </div>
1201
+ </FlowDialog>
1202
+ )}
1203
+
1204
+ {showDeleteDialog && canDeleteFlow && (
1205
+ <FlowDialog title={labels.flowManager.deleteFlow} onClose={() => setShowDeleteDialog(false)}>
1206
+ <p className="text-sm text-gray-600 dark:text-gray-300">
1207
+ {primaryGraph ? labels.flowManager.deleteConfirm(primaryGraph.label) : ''}
1208
+ </p>
1209
+ {flowMutationState.error && <p className="text-xs text-red-600 mt-2">{flowMutationState.error}</p>}
1210
+ <div className="flex justify-end gap-2 mt-4">
1211
+ <button type="button" className={OUTLINE_BUTTON} onClick={() => setShowDeleteDialog(false)}>
1212
+ {labels.nodePanel.cancel}
1213
+ </button>
1214
+ <button
1215
+ type="button"
1216
+ className="inline-flex items-center gap-1.5 rounded-lg bg-red-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-red-700 disabled:opacity-40"
1217
+ onClick={() => void handleDeleteFlow()}
1218
+ disabled={flowMutationState.pending}
1219
+ >
1220
+ <Trash2 size={13} aria-hidden="true" /> {labels.flowManager.deleteFlow}
1221
+ </button>
1222
+ </div>
1223
+ </FlowDialog>
1224
+ )}
1225
+ </div>
1226
+ )
1227
+ }
1228
+
1229
+ // Diálogo próprio em vez de depender do `Dialog` do produto: o pacote roda em três apps com
1230
+ // bibliotecas de UI diferentes, e exigir uma delas transformaria a tela composta num acoplamento.
1231
+ function FlowDialog({
1232
+ title,
1233
+ onClose,
1234
+ children,
1235
+ }: {
1236
+ title: string
1237
+ onClose: () => void
1238
+ children: ReactNode
1239
+ }) {
1240
+ return (
1241
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4">
1242
+ <div className="absolute inset-0" onClick={onClose} aria-hidden="true" />
1243
+ <div
1244
+ role="dialog"
1245
+ aria-modal="true"
1246
+ aria-label={title}
1247
+ className="relative w-full max-w-md rounded-2xl bg-white dark:bg-gray-800 p-5 shadow-xl"
1248
+ >
1249
+ <h3 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-3">{title}</h3>
1250
+ {children}
1251
+ </div>
1252
+ </div>
1253
+ )
1254
+ }