feat(phase2): add Flow Builder UI and internal flow routes

- Add FlowBuilder.tsx with React Flow visual editor
- Add FlowList.tsx for flow management
- Add /internal/flow/send endpoint for flow-engine messaging
- Add reactflow dependency to frontend

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Claude AI
2026-01-29 10:23:02 +00:00
parent 14a579d5ca
commit c97d380635
4 changed files with 457 additions and 1 deletions

View File

@@ -0,0 +1,159 @@
import { useState, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import ReactFlow, {
Node,
Edge,
Controls,
Background,
useNodesState,
useEdgesState,
addEdge,
Connection,
NodeTypes,
} from 'reactflow';
import 'reactflow/dist/style.css';
import { Button, message, Space } from 'antd';
import { SaveOutlined, ArrowLeftOutlined } from '@ant-design/icons';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../api/client';
const TriggerNode = ({ data }: { data: any }) => (
<div style={{ padding: 10, border: '2px solid #52c41a', borderRadius: 8, background: '#f6ffed' }}>
<strong>🚀 {data.label}</strong>
</div>
);
const MessageNode = ({ data }: { data: any }) => (
<div style={{ padding: 10, border: '2px solid #1890ff', borderRadius: 8, background: '#e6f7ff', minWidth: 150 }}>
<strong>💬 Mensaje</strong>
<div style={{ fontSize: 12, marginTop: 4 }}>{data.config?.text?.slice(0, 50) || 'Sin texto'}</div>
</div>
);
const ConditionNode = ({ data }: { data: any }) => (
<div style={{ padding: 10, border: '2px solid #faad14', borderRadius: 8, background: '#fffbe6' }}>
<strong> Condición</strong>
</div>
);
const WaitInputNode = ({ data }: { data: any }) => (
<div style={{ padding: 10, border: '2px solid #722ed1', borderRadius: 8, background: '#f9f0ff' }}>
<strong> Esperar Input</strong>
</div>
);
const nodeTypes: NodeTypes = {
trigger: TriggerNode,
message: MessageNode,
condition: ConditionNode,
wait_input: WaitInputNode,
};
interface Flow {
id: string;
name: string;
nodes: Node[];
edges: Edge[];
}
export default function FlowBuilder() {
const { flowId } = useParams<{ flowId: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const { data: flow, isLoading } = useQuery({
queryKey: ['flow', flowId],
queryFn: async () => {
const data = await apiClient.get<Flow>(`/api/flows/${flowId}`);
setNodes(data.nodes || []);
setEdges(data.edges || []);
return data;
},
enabled: !!flowId,
});
const saveMutation = useMutation({
mutationFn: async () => {
await apiClient.put(`/api/flows/${flowId}`, {
nodes,
edges,
});
},
onSuccess: () => {
message.success('Flujo guardado');
queryClient.invalidateQueries({ queryKey: ['flow', flowId] });
},
onError: () => {
message.error('Error al guardar');
},
});
const onConnect = useCallback(
(params: Connection) => setEdges((eds) => addEdge(params, eds)),
[setEdges]
);
const addNode = (type: string) => {
const newNode: Node = {
id: `${type}-${Date.now()}`,
type,
position: { x: 250, y: nodes.length * 100 + 50 },
data: {
label: type === 'trigger' ? 'Inicio' : type,
type,
config: type === 'message' ? { text: 'Hola {{contact.name}}!' } : {}
},
};
setNodes((nds) => [...nds, newNode]);
};
if (isLoading) {
return <div>Cargando...</div>;
}
return (
<div style={{ height: 'calc(100vh - 140px)' }}>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
<Space>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/flows')}>
Volver
</Button>
<span style={{ fontSize: 18, fontWeight: 'bold' }}>{flow?.name}</span>
</Space>
<Space>
<Button onClick={() => addNode('trigger')}>+ Trigger</Button>
<Button onClick={() => addNode('message')}>+ Mensaje</Button>
<Button onClick={() => addNode('condition')}>+ Condición</Button>
<Button onClick={() => addNode('wait_input')}>+ Esperar Input</Button>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={() => saveMutation.mutate()}
loading={saveMutation.isPending}
style={{ background: '#25D366' }}
>
Guardar
</Button>
</Space>
</div>
<div style={{ height: 'calc(100% - 50px)', border: '1px solid #d9d9d9', borderRadius: 8 }}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
nodeTypes={nodeTypes}
fitView
>
<Controls />
<Background />
</ReactFlow>
</div>
</div>
);
}

View File

@@ -0,0 +1,239 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Card,
Button,
Table,
Tag,
Modal,
Form,
Input,
Select,
message,
Space,
Typography,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
} from '@ant-design/icons';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../api/client';
const { Title } = Typography;
interface Flow {
id: string;
name: string;
trigger_type: string;
is_active: boolean;
version: number;
updated_at: string;
}
const triggerLabels: Record<string, string> = {
welcome: 'Bienvenida',
keyword: 'Palabra clave',
fallback: 'Fallback',
event: 'Evento',
manual: 'Manual',
};
export default function FlowList() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [form] = Form.useForm();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { data: flows, isLoading } = useQuery({
queryKey: ['flows'],
queryFn: () => apiClient.get<Flow[]>('/api/flows'),
});
const createMutation = useMutation({
mutationFn: (data: any) => apiClient.post<Flow>('/api/flows', data),
onSuccess: (data) => {
message.success('Flujo creado');
setIsModalOpen(false);
form.resetFields();
queryClient.invalidateQueries({ queryKey: ['flows'] });
navigate(`/flows/${data.id}`);
},
onError: () => {
message.error('Error al crear flujo');
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/api/flows/${id}`),
onSuccess: () => {
message.success('Flujo eliminado');
queryClient.invalidateQueries({ queryKey: ['flows'] });
},
});
const toggleActiveMutation = useMutation({
mutationFn: async ({ id, active }: { id: string; active: boolean }) => {
const endpoint = active ? 'activate' : 'deactivate';
await apiClient.post(`/api/flows/${id}/${endpoint}`, {});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['flows'] });
message.success('Estado actualizado');
},
});
const columns = [
{
title: 'Nombre',
dataIndex: 'name',
key: 'name',
},
{
title: 'Trigger',
dataIndex: 'trigger_type',
key: 'trigger_type',
render: (type: string) => triggerLabels[type] || type,
},
{
title: 'Estado',
dataIndex: 'is_active',
key: 'is_active',
render: (active: boolean) => (
<Tag color={active ? 'green' : 'default'}>
{active ? 'Activo' : 'Inactivo'}
</Tag>
),
},
{
title: 'Versión',
dataIndex: 'version',
key: 'version',
},
{
title: 'Acciones',
key: 'actions',
render: (_: any, record: Flow) => (
<Space>
<Button
icon={<EditOutlined />}
onClick={() => navigate(`/flows/${record.id}`)}
>
Editar
</Button>
<Button
icon={record.is_active ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
onClick={() =>
toggleActiveMutation.mutate({ id: record.id, active: !record.is_active })
}
>
{record.is_active ? 'Desactivar' : 'Activar'}
</Button>
<Button
danger
icon={<DeleteOutlined />}
onClick={() => {
Modal.confirm({
title: '¿Eliminar flujo?',
onOk: () => deleteMutation.mutate(record.id),
});
}}
/>
</Space>
),
},
];
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Title level={4} style={{ margin: 0 }}>Flujos de Chatbot</Title>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setIsModalOpen(true)}
style={{ background: '#25D366' }}
>
Crear Flujo
</Button>
</div>
<Card>
<Table
dataSource={flows}
columns={columns}
rowKey="id"
loading={isLoading}
pagination={false}
/>
</Card>
<Modal
title="Crear nuevo flujo"
open={isModalOpen}
onCancel={() => {
setIsModalOpen(false);
form.resetFields();
}}
footer={null}
>
<Form
form={form}
layout="vertical"
onFinish={(values) => createMutation.mutate(values)}
>
<Form.Item
name="name"
label="Nombre"
rules={[{ required: true, message: 'Ingresa un nombre' }]}
>
<Input placeholder="Ej: Bienvenida Principal" />
</Form.Item>
<Form.Item
name="trigger_type"
label="Tipo de Trigger"
rules={[{ required: true, message: 'Selecciona un trigger' }]}
>
<Select placeholder="Selecciona...">
<Select.Option value="welcome">Bienvenida (primer mensaje)</Select.Option>
<Select.Option value="keyword">Palabra clave</Select.Option>
<Select.Option value="fallback">Fallback (sin coincidencia)</Select.Option>
</Select>
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prev, curr) => prev.trigger_type !== curr.trigger_type}
>
{({ getFieldValue }) =>
getFieldValue('trigger_type') === 'keyword' && (
<Form.Item
name="trigger_value"
label="Palabras clave"
rules={[{ required: true }]}
>
<Input placeholder="hola, menu, ayuda (separadas por coma)" />
</Form.Item>
)
}
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
loading={createMutation.isPending}
block
>
Crear
</Button>
</Form.Item>
</Form>
</Modal>
</div>
);
}