Rapport_React/src/views/workflow/Editor.jsx

651 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router';
import {
Background,
Controls,
MiniMap,
Panel,
ReactFlow,
addEdge,
useEdgesState,
useNodesState,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { Button, Card, Col, Container, Form, Modal, Row, Spinner } from 'react-bootstrap';
import Select from 'react-select';
import PageBreadcrumb from '@/components/PageBreadcrumb';
import { isSuccess, signedGet, signedPost } from '@/api/client';
import { dropdownMap } from '@/api/dropdowns';
import { defaultConfig, fromFlow, labelFor, toFlow, NODE_TYPES, edgeLabel, contactMappings } from '@/views/workflow/graph';
function newId() {
return crypto.randomUUID();
}
const MANAGER_LEVELS = [
{ value: 'l1manager', label: 'L1 Manager' },
{ value: 'l2manager', label: 'L2 Manager' },
{ value: 'l3manager', label: 'L3 Manager' },
{ value: 'l4manager', label: 'L4 Manager' },
];
function approverGroups(roles, users) {
return [
{ label: 'Manager', options: MANAGER_LEVELS },
{ label: 'Roles', options: Object.entries(roles).map(([value, label]) => ({ value, label })) },
{ label: 'Users', options: Object.entries(users).map(([value, label]) => ({ value, label })) },
];
}
function ApproverSelect({ values, groups, onChange }) {
const known = groups.flatMap((group) => group.options || []);
const saved = (values || []).filter((value) => !known.some((option) => option.value === value)).map((value) => ({ value, label: value }));
const options = saved.length ? groups.concat({ label: 'Saved', options: saved }) : groups;
const byValue = new Map(known.concat(saved).map((option) => [option.value, option]));
return (
<Select
isMulti
options={options}
classNamePrefix="react-select"
menuPosition="fixed"
menuPortalTarget={typeof document !== 'undefined' ? document.body : null}
styles={{ menuPortal: (base) => ({ ...base, zIndex: 2000 }) }}
value={(values || []).map((value) => byValue.get(value)).filter(Boolean)}
onChange={(selected) => onChange((selected || []).map((option) => option.value))}
/>
);
}
export default function WorkflowEditor() {
const [params] = useSearchParams();
const id = params.get('id') || '';
const [item, setItem] = useState(null);
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const [selected, setSelected] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const [errors, setErrors] = useState([]);
const [contextText, setContextText] = useState('{}');
const [hierarchyId, setHierarchyId] = useState('');
const [testOpen, setTestOpen] = useState(false);
const [approverOptions, setApproverOptions] = useState(() => approverGroups({}, {}));
useEffect(() => {
const load = async () => {
setLoading(true);
setError('');
const response = await signedGet('workflow', 'workflow_definition/get_one', { id });
if (!isSuccess(response)) {
setError(response?.message || 'Unable to load workflow');
setLoading(false);
return;
}
const definition = response.data.item;
const flow = toFlow(definition);
setItem(definition);
setNodes(flow.nodes);
setEdges(flow.edges);
setLoading(false);
};
if (id) load();
}, [id, setEdges, setNodes]);
useEffect(() => {
const loadApprovers = async () => {
try {
const [roles, usersRes] = await Promise.all([
dropdownMap('ums', 'roles', (role) => role.name),
signedGet('ums', 'users/dropdown'),
]);
const users = {};
const items = isSuccess(usersRes) ? usersRes.data?.items || [] : [];
items.forEach((user) => {
if (!user?.id) return;
users[user.id] = `${user.firstname || ''} ${user.lastname || ''} (${user.employee_code || ''})`.trim();
});
setApproverOptions(approverGroups(roles || {}, users));
} catch {
setApproverOptions(approverGroups({}, {}));
}
};
loadApprovers();
}, []);
const selectedNode = useMemo(
() => nodes.find((node) => node.id === selected && node.data),
[nodes, selected]
);
const selectedEdge = useMemo(() => edges.find((edge) => edge.id === selected), [edges, selected]);
const onConnect = useCallback(
(connection) => {
setEdges((current) =>
addEdge(
{
...connection,
id: newId(),
data: { when: null },
},
current
)
);
},
[setEdges]
);
const addNode = (type) => {
const config = defaultConfig(type);
const node = {
id: newId(),
position: { x: 120 + nodes.length * 24, y: 80 + nodes.length * 24 },
data: { nodeType: type, config, label: labelFor({ type, config }) },
};
setNodes((current) => current.concat(node));
};
const patchNode = (config) => {
setNodes((current) =>
current.map((node) => {
if (node.id !== selectedNode.id) return node;
const next = { ...node.data.config, ...config };
return { ...node, data: { ...node.data, config: next, label: labelFor({ type: node.data.nodeType, config: next }) } };
})
);
};
const deleteNode = (nodeId) => {
setNodes((current) => current.filter((node) => node.id !== nodeId));
setEdges((current) => current.filter((edge) => edge.source !== nodeId && edge.target !== nodeId));
setSelected(null);
};
const deleteEdge = (edgeId) => {
setEdges((current) => current.filter((edge) => edge.id !== edgeId));
setSelected(null);
};
const onNodesDelete = useCallback((deleted) => {
const ids = new Set(deleted.map((node) => node.id));
setEdges((current) => current.filter((edge) => !ids.has(edge.source) && !ids.has(edge.target)));
setSelected(null);
}, [setEdges]);
const patchEdge = (when) => {
setEdges((current) =>
current.map((edge) => {
if (edge.id !== selectedEdge.id) return edge;
const next = { ...(edge.data?.when || {}), ...when };
const stored = next.field ? next : null;
return { ...edge, data: { when: stored }, label: edgeLabel(stored) };
})
);
};
const persist = async (publish) => {
setSaving(true);
setError('');
setMessage('');
setErrors([]);
const graph = fromFlow(nodes, edges);
const saved = await signedPost('workflow', 'workflow_definition/update', {
id,
name: item.name,
entity: item.entity,
description: item.description || '',
...graph,
});
if (!isSuccess(saved)) {
setError(saved?.message || 'Save failed');
setSaving(false);
return;
}
if (!publish) {
setMessage('Draft saved');
setSaving(false);
return;
}
const published = await signedPost('workflow', 'workflow_definition/publish', { id });
if (!isSuccess(published)) {
setErrors(published?.data?.errors || []);
setError(published?.message || 'Publish failed');
setSaving(false);
return;
}
setItem((current) => ({ ...current, published_version: published.data.item.published_version }));
setMessage(`Published version ${published.data.item.published_version}`);
setSaving(false);
};
const runTest = async () => {
setError('');
setMessage('');
let context = {};
try {
context = contextText.trim() ? JSON.parse(contextText) : {};
} catch {
setError('Test context must be JSON');
return;
}
const response = await signedPost('workflow', 'workflow_instance/start', {
definition_id: id,
entity: 'manual',
entity_id: `test-${Date.now()}`,
hierarchy_id: hierarchyId,
context,
});
if (!isSuccess(response)) {
setError(response?.message || 'Test run failed');
return;
}
window.location.assign(`/workflow/monitor?id=${response.data.item.id}`);
};
if (loading) {
return (
<Container className="py-4">
<Spinner animation="border" size="sm" /> Loading workflow
</Container>
);
}
return (
<Container fluid className="py-3">
<PageBreadcrumb title={item?.name || 'Workflow'} subtitle="Workflows" />
<div className="d-flex gap-2 mb-3">
<Button as={Link} to="/workflows" variant="light">Back</Button>
<Button disabled={saving} onClick={() => persist(false)}>Save draft</Button>
<Button disabled={saving} variant="primary" onClick={() => persist(true)}>Publish</Button>
<Button variant="outline-primary" onClick={() => setTestOpen(true)}>Test run</Button>
<span className="text-muted align-self-center">
Published v{item?.published_version || 0}
</span>
</div>
{message ? <div className="alert alert-success">{message}</div> : null}
{error ? <div className="alert alert-danger">{error}</div> : null}
{errors.length ? (
<ul className="text-danger">
{errors.map((itemError) => (
<li key={`${itemError.message}-${(itemError.nodeIds || []).join(',')}`}>
{itemError.message}
{itemError.nodeIds?.length ? ` (${itemError.nodeIds.join(', ')})` : ''}
</li>
))}
</ul>
) : null}
<Row>
<Col md={2}>
<Card>
<Card.Header>Add node</Card.Header>
<Card.Body className="d-grid gap-2">
{NODE_TYPES.map((entry) => (
<Button key={entry.type} variant="outline-secondary" onClick={() => addNode(entry.type)}>
{entry.label}
</Button>
))}
</Card.Body>
</Card>
</Col>
<Col>
<div style={{ height: '70vh', border: '1px solid #dee2e6', borderRadius: 8 }}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodesDelete={onNodesDelete}
deleteKeyCode={['Backspace', 'Delete']}
edgesReconnectable
onPaneClick={() => setSelected(null)}
onNodeClick={(_, node) => setSelected(node.id)}
onEdgeClick={(_, edge) => setSelected(edge.id)}
fitView
>
<MiniMap />
<Controls />
<Background />
{selectedNode || selectedEdge ? (
<Panel position="top-right">
<Card className="d-flex flex-column" style={{ width: 360, maxHeight: 'calc(70vh - 24px)', overflow: 'hidden' }}>
<Card.Header className="d-flex align-items-center justify-content-between flex-shrink-0">
<span>{selectedNode ? selectedNode.data.label : 'Relation'}</span>
<button type="button" className="btn-close" aria-label="Close" onClick={() => setSelected(null)} />
</Card.Header>
<Card.Body className="overflow-auto" style={{ minHeight: 0 }}>
{selectedNode ? (
<>
<NodeForm node={selectedNode} approverOptions={approverOptions} onChange={patchNode} />
<Button variant="outline-danger" className="mt-3" onClick={() => deleteNode(selectedNode.id)}>
Delete node
</Button>
</>
) : (
<>
<EdgeForm edge={selectedEdge} onChange={patchEdge} />
<Button variant="outline-danger" className="mt-3" onClick={() => deleteEdge(selectedEdge.id)}>
Delete relation
</Button>
</>
)}
</Card.Body>
</Card>
</Panel>
) : null}
</ReactFlow>
</div>
</Col>
</Row>
<Modal show={testOpen} onHide={() => setTestOpen(false)}>
<Modal.Header closeButton>
<Modal.Title>Test run</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form.Group className="mb-3">
<Form.Label>Hierarchy id</Form.Label>
<Form.Control value={hierarchyId} onChange={(event) => setHierarchyId(event.target.value)} />
</Form.Group>
<Form.Group>
<Form.Label>Context JSON</Form.Label>
<Form.Control as="textarea" rows={8} value={contextText} onChange={(event) => setContextText(event.target.value)} />
</Form.Group>
</Modal.Body>
<Modal.Footer>
<Button variant="light" onClick={() => setTestOpen(false)}>Cancel</Button>
<Button variant="primary" onClick={runTest} disabled={!item?.published_version}>
Run published version
</Button>
</Modal.Footer>
</Modal>
</Container>
);
}
function NodeForm({ node, approverOptions, onChange }) {
const config = node.data.config || {};
const type = node.data.nodeType;
if (type === 'approval') {
return (
<>
<Form.Group className="mb-2">
<Form.Label>Mode</Form.Label>
<Form.Select value={config.mode || 'single'} onChange={(event) => onChange({ mode: event.target.value })}>
<option value="single">Single</option>
<option value="all">All</option>
<option value="sequential">Sequential</option>
</Form.Select>
</Form.Group>
<Form.Group className="mb-2">
<Form.Label>Approvers</Form.Label>
<ApproverSelect
values={config.approvers || []}
groups={approverOptions}
onChange={(approvers) => onChange({ approvers })}
/>
<Form.Text>Pick manager levels, roles, or users. In Sequential mode, the order selected is the approval order.</Form.Text>
</Form.Group>
<Form.Check
label="Escalate when the approver seat is vacant"
checked={config.escalate_vacant !== false}
onChange={(event) => onChange({ escalate_vacant: event.target.checked })}
/>
</>
);
}
if (type === 'notify') {
return (
<>
<Form.Group className="mb-2">
<Form.Label>Recipients</Form.Label>
<Form.Control
value={(config.recipients || []).join(', ')}
onChange={(event) => onChange({ recipients: event.target.value.split(',').map((part) => part.trim()).filter(Boolean) })}
/>
</Form.Group>
<Form.Group className="mb-2">
<Form.Label>Subject</Form.Label>
<Form.Control value={config.subject || ''} onChange={(event) => onChange({ subject: event.target.value })} />
</Form.Group>
<Form.Group>
<Form.Label>Body</Form.Label>
<Form.Control as="textarea" rows={3} value={config.body || ''} onChange={(event) => onChange({ body: event.target.value })} />
</Form.Group>
</>
);
}
if (type === 'contact') {
const mappings = contactMappings(config);
const updateRow = (index, patch) => {
const next = mappings.map((row, rowIndex) => (rowIndex === index ? { ...row, ...patch } : row));
onChange({ mappings: next.map(({ contact, source }) => ({ contact, source })) });
};
return (
<>
<p className="text-muted">Map each contact field to a field on the incoming object. If that field is not on the object, the text is saved as the value. Phone or email is enough. Application and domain come from this workflow. Position comes from the hierarchy, and user info is built from the user.</p>
{mappings.map((row, index) => (
<div className="mb-2" key={`${row.contact}-${index}`}>
<div className="d-flex gap-2">
<Form.Control
placeholder="Contact field"
value={row.contact || ''}
disabled={row.locked}
onChange={(event) => updateRow(index, { contact: event.target.value })}
/>
{row.contact === 'msl_code' ? (
<Form.Select
value={config.msl_mode === 'auto' ? 'auto' : 'map'}
onChange={(event) => onChange({ msl_mode: event.target.value })}
>
<option value="map">Map field</option>
<option value="auto">Autocreate</option>
</Form.Select>
) : (
<Form.Control
placeholder={row.optional ? 'Incoming field' : 'Incoming field *'}
value={row.source || ''}
onChange={(event) => updateRow(index, { source: event.target.value })}
/>
)}
{row.locked && !row.removable ? <span style={{ width: 38 }} /> : (
<Button
variant="outline-danger"
onClick={() => onChange({ mappings: mappings.filter((_, rowIndex) => rowIndex !== index).map(({ contact, source }) => ({ contact, source })) })}
>
×
</Button>
)}
</div>
{row.contact === 'msl_code' && config.msl_mode === 'auto' ? (
<div className="d-flex gap-2 mt-2">
<Form.Control placeholder="Prefix" value={config.msl_prefix || ''} onChange={(event) => onChange({ msl_prefix: event.target.value })} />
<Form.Control placeholder="Suffix" value={config.msl_suffix || ''} onChange={(event) => onChange({ msl_suffix: event.target.value })} />
</div>
) : null}
{row.contact === 'msl_code' && config.msl_mode !== 'auto' ? (
<Form.Control
className="mt-2"
placeholder="Incoming field *"
value={row.source || ''}
onChange={(event) => updateRow(index, { source: event.target.value })}
/>
) : null}
</div>
))}
<Button variant="outline-secondary" onClick={() => onChange({ mappings: mappings.concat({ contact: '', source: '' }).map(({ contact, source }) => ({ contact, source })) })}>
Add field
</Button>
</>
);
}
if (type === 'ocr') {
return (
<>
<Form.Group className="mb-2">
<Form.Label>Document</Form.Label>
<Form.Control
placeholder="visiting_card"
value={config.source || ''}
onChange={(event) => onChange({ source: event.target.value })}
/>
<Form.Text>Incoming field or a document URL. Textract reads the file. A missing field is skipped.</Form.Text>
</Form.Group>
<Form.Check
className="mb-2"
label="Mandatory"
checked={config.mandatory === true}
onChange={(event) => onChange({ mandatory: event.target.checked })}
/>
<Form.Group>
<Form.Label>Save text as</Form.Label>
<Form.Control value={config.output || 'ocr_text'} onChange={(event) => onChange({ output: event.target.value })} />
</Form.Group>
</>
);
}
if (type === 'delay') {
return (
<Form.Group>
<Form.Label>Seconds</Form.Label>
<Form.Control
type="number"
min={1}
max={86400}
value={config.seconds ?? 60}
onChange={(event) => onChange({ seconds: Number(event.target.value) })}
/>
<Form.Text>The run waits at least this long. EventBridge fires on a one-minute grid, so it can take up to about a minute longer.</Form.Text>
</Form.Group>
);
}
if (type === 'llm') {
return (
<>
<Form.Group className="mb-2">
<Form.Label>Model</Form.Label>
<Form.Select value={config.model || 'meta.llama3-8b-instruct-v1:0'} onChange={(event) => onChange({ model: event.target.value })}>
<option value="meta.llama3-8b-instruct-v1:0">Llama 3 8B</option>
<option value="amazon.titan-text-express-v1">Titan Text Express</option>
<option value="anthropic.claude-3-haiku-20240307-v1:0">Claude 3 Haiku</option>
</Form.Select>
</Form.Group>
<Form.Group className="mb-2">
<Form.Label>Prompt</Form.Label>
<Form.Control
as="textarea"
rows={6}
value={config.prompt || ''}
onChange={(event) => onChange({ prompt: event.target.value })}
/>
<Form.Text>Use {'{{doctor_name}}'} or {'{{ocr_text}}'} for a value from the incoming object.</Form.Text>
</Form.Group>
<Form.Group>
<Form.Label>Save answer as</Form.Label>
<Form.Control value={config.output || 'llm_text'} onChange={(event) => onChange({ output: event.target.value })} />
</Form.Group>
</>
);
}
if (type === 'action') {
const mappings = Array.isArray(config.mappings) ? config.mappings : [];
const updateRow = (index, patch) => {
onChange({
mappings: mappings.map((row, rowIndex) => (rowIndex === index ? { ...row, ...patch } : row)),
});
};
return (
<>
<Form.Group className="mb-2">
<Form.Label>Action</Form.Label>
<Form.Select
value={config.name || 'set_status'}
onChange={(event) => {
const name = event.target.value;
onChange(name === 'update_submission'
? { name, mappings: mappings.length ? mappings : [{ field: '', source: '' }] }
: { name });
}}
>
<option value="set_status">Set status</option>
<option value="update_submission">Form submission update</option>
</Form.Select>
</Form.Group>
{config.name === 'update_submission' ? (
<>
<p className="text-muted">Map each submission field to a field on this run. If that field is not on the run, the text is saved as the value. Use formdata.doctor_name for an answer, or workflow_status for the submission record.</p>
{mappings.map((row, index) => (
<div className="d-flex gap-2 mb-2" key={`submission-field-${index}`}>
<Form.Control
placeholder="Submission field"
value={row.field || ''}
onChange={(event) => updateRow(index, { field: event.target.value })}
/>
<Form.Control
placeholder="Incoming field"
value={row.source || ''}
onChange={(event) => updateRow(index, { source: event.target.value })}
/>
<Button
variant="outline-danger"
onClick={() => onChange({ mappings: mappings.filter((_, rowIndex) => rowIndex !== index) })}
>
×
</Button>
</div>
))}
<Button variant="outline-secondary" onClick={() => onChange({ mappings: mappings.concat({ field: '', source: '' }) })}>
Add field
</Button>
</>
) : (
<Form.Group>
<Form.Label>Status</Form.Label>
<Form.Control value={config.status || ''} onChange={(event) => onChange({ status: event.target.value })} />
</Form.Group>
)}
</>
);
}
if (type === 'end') {
return (
<Form.Group>
<Form.Label>Outcome</Form.Label>
<Form.Select value={config.outcome || 'completed'} onChange={(event) => onChange({ outcome: event.target.value })}>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
<option value="completed">Completed</option>
</Form.Select>
</Form.Group>
);
}
if (type === 'condition') {
return <p className="text-muted mb-0">Set the branch on each outgoing edge. One edge can stay empty as the default.</p>;
}
return <p className="text-muted mb-0">Start has no settings.</p>;
}
function EdgeForm({ edge, onChange }) {
const when = edge.data?.when || { field: '', op: 'eq', value: '' };
return (
<>
<Form.Group className="mb-2">
<Form.Label>Field</Form.Label>
<Form.Control value={when.field || ''} onChange={(event) => onChange({ field: event.target.value })} />
</Form.Group>
<Form.Group className="mb-2">
<Form.Label>Operator</Form.Label>
<Form.Select value={when.op || 'eq'} onChange={(event) => onChange({ op: event.target.value })}>
{['eq', 'neq', 'in', 'gt', 'lt', 'exists', 'empty'].map((op) => (
<option key={op} value={op}>{op}</option>
))}
</Form.Select>
</Form.Group>
<Form.Group>
<Form.Label>Value</Form.Label>
<Form.Control value={when.value ?? ''} onChange={(event) => onChange({ value: event.target.value })} />
</Form.Group>
</>
);
}