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:
159
frontend/src/pages/FlowBuilder.tsx
Normal file
159
frontend/src/pages/FlowBuilder.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
239
frontend/src/pages/FlowList.tsx
Normal file
239
frontend/src/pages/FlowList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user