feat(workflows): invoice prepared+approved early, dispatch waits; daysBefore in editor; dashboard approvals
- Booking built-ins reordered: prepare the invoice EARLY (admin adjusts line items), admin approves at the review gate whenever, then the wait holds dispatch until the event date and it sends itself. prepInvoice → reviewInvoice → waitEvent → sendInvoice (both booking_full and booking_simple; v3). - Flow editor now reads/edits/saves trigger_config; the pre-event "days before event" lead time is editable in the canvas toolbar (was only in settings, which the cutover removed — closing that gap). - Dashboard: pending-approvals card under "Events Expiring Soon" (workflows flag + non-empty only), with inline Confirm/Deny. Confirms the design: a gate's confirm edge can feed a wait, so an admin OK before the event parks the run at the wait and the scheduler dispatches on the date. New test covers confirm-early-then-wait-dispatches.
This commit is contained in:
@@ -315,15 +315,19 @@ describe('workflow engine', () => {
|
||||
const fullGateKeys = fullNodes.filter((n) => n.type === 'gate').map((n) => n.node_key);
|
||||
expect(fullGateKeys).toEqual(expect.arrayContaining(['reviewContract', 'reviewInvoice']));
|
||||
const fullEdges = await db('workflow_edges').where({ workflow_id: bookingFull.id, version: bookingFull.version });
|
||||
// reviewContract --confirm--> sendContract ; reviewInvoice --confirm--> sendInvoice
|
||||
// reviewContract --confirm--> sendContract. The invoice is prepared + approved
|
||||
// EARLY; reviewInvoice --confirm--> waitEvent, and the wait --> sendInvoice, so
|
||||
// dispatch is held until the event date after the admin's early OK.
|
||||
expect(fullEdges.some((e) => e.from_node === 'reviewContract' && e.from_handle === 'confirm' && e.to_node === 'sendContract')).toBe(true);
|
||||
expect(fullEdges.some((e) => e.from_node === 'reviewInvoice' && e.from_handle === 'confirm' && e.to_node === 'sendInvoice')).toBe(true);
|
||||
expect(fullEdges.some((e) => e.from_node === 'reviewInvoice' && e.from_handle === 'confirm' && e.to_node === 'waitEvent')).toBe(true);
|
||||
expect(fullEdges.some((e) => e.from_node === 'waitEvent' && e.to_node === 'sendInvoice')).toBe(true);
|
||||
|
||||
const bookingSimple = await db('workflows').where({ builtin_key: 'booking_simple' }).first();
|
||||
expect(bookingSimple).toBeTruthy();
|
||||
expect(bookingSimple.trigger_type).toBe('quote.accepted');
|
||||
const simpleEdges = await db('workflow_edges').where({ workflow_id: bookingSimple.id, version: bookingSimple.version });
|
||||
expect(simpleEdges.some((e) => e.from_node === 'reviewInvoice' && e.from_handle === 'confirm' && e.to_node === 'sendInvoice')).toBe(true);
|
||||
expect(simpleEdges.some((e) => e.from_node === 'reviewInvoice' && e.from_handle === 'confirm' && e.to_node === 'waitEvent')).toBe(true);
|
||||
expect(simpleEdges.some((e) => e.from_node === 'waitEvent' && e.to_node === 'sendInvoice')).toBe(true);
|
||||
|
||||
const preEvent = await db('workflows').where({ builtin_key: 'pre_event_email' }).first();
|
||||
expect(preEvent).toBeTruthy();
|
||||
@@ -386,6 +390,43 @@ describe('workflow engine', () => {
|
||||
expect(res.sent).toBe(0);
|
||||
});
|
||||
|
||||
test('admin confirms a gate early; the following wait holds dispatch until its date', async () => {
|
||||
// The booking pattern: prepare → REVIEW GATE → WAIT(event date) → send. The
|
||||
// admin can approve at the gate whenever; the run then parks at the wait and
|
||||
// the scheduler dispatches when the date arrives.
|
||||
const wfId = await makeWorkflow({
|
||||
trigger: 'gatewait.event',
|
||||
nodes: [
|
||||
{ key: 'g0', type: 'trigger' },
|
||||
{ key: 'g1', type: 'gate', config: { prompt: 'Approve invoice?' } },
|
||||
{ key: 'g2', type: 'wait', config: { delayDays: 5 } },
|
||||
{ key: 'g3', type: 'action', config: { action: 'noop' } },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'g0', to: 'g1' },
|
||||
{ from: 'g1', handle: 'confirm', to: 'g2' },
|
||||
{ from: 'g2', to: 'g3' },
|
||||
],
|
||||
});
|
||||
const [runId] = await engine.emitWorkflowEvent('gatewait.event', { entityType: 'invoice', entityId: 7 });
|
||||
let run = await db('workflow_runs').where({ id: runId }).first();
|
||||
expect(run.status).toBe('waiting');
|
||||
expect(run.current_node).toBe('g1'); // parked at the review gate
|
||||
|
||||
// Admin confirms EARLY (before the wait date).
|
||||
const approval = await db('workflow_approvals').where({ run_id: runId, status: 'pending' }).first();
|
||||
await engine.actById(approval.id, 'confirm');
|
||||
run = await db('workflow_runs').where({ id: runId }).first();
|
||||
expect(run.status).toBe('waiting');
|
||||
expect(run.current_node).toBe('g2'); // now holding at the wait, not yet dispatched
|
||||
|
||||
// Date arrives → scheduler dispatches.
|
||||
await db('workflow_runs').where({ id: runId }).update({ wake_at: new Date(Date.now() - 1000).toISOString() });
|
||||
await engine.runDueWaits();
|
||||
run = await db('workflow_runs').where({ id: runId }).first();
|
||||
expect(run.status).toBe('done');
|
||||
});
|
||||
|
||||
test('recoverStaleRuns resumes a run orphaned mid-flow (crash recovery)', async () => {
|
||||
const wfId = await makeWorkflow({
|
||||
trigger: 'recover.event',
|
||||
|
||||
@@ -73,14 +73,14 @@ function buildBookingFullGraph() {
|
||||
{ node_key: 'sendContract', type: 'action', config: { action: 'send_document', document: 'contract', recipient: 'customer' }, pos_x: 320, pos_y: 330 },
|
||||
{ node_key: 'gateSigned', type: 'gate', config: { label: 'Contract signed?' }, pos_x: 320, pos_y: 440 },
|
||||
{ node_key: 'prepEvent', type: 'action', config: { action: 'prepare_event' }, pos_x: 320, pos_y: 550 },
|
||||
{ node_key: 'waitEvent', type: 'wait', config: { untilVar: 'eventDate' }, pos_x: 320, pos_y: 660 },
|
||||
{ node_key: 'prepInvoice', type: 'action', config: { action: 'prepare_invoice' }, pos_x: 320, pos_y: 770 },
|
||||
{ node_key: 'reviewInvoice', type: 'gate', config: { label: 'Review invoice before sending' }, pos_x: 320, pos_y: 880 },
|
||||
{ node_key: 'prepInvoice', type: 'action', config: { action: 'prepare_invoice' }, pos_x: 320, pos_y: 660 },
|
||||
{ node_key: 'reviewInvoice', type: 'gate', config: { label: 'Review invoice (early — dispatch waits for the event)' }, pos_x: 320, pos_y: 770 },
|
||||
{ node_key: 'waitEvent', type: 'wait', config: { untilVar: 'eventDate' }, pos_x: 320, pos_y: 880 },
|
||||
{ node_key: 'sendInvoice', type: 'action', config: { action: 'send_document', document: 'invoice', recipient: 'customer' }, pos_x: 320, pos_y: 990 },
|
||||
{ node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 320, pos_y: 1100 },
|
||||
{ node_key: 'cancelContract', type: 'action', config: { action: 'noop' }, pos_x: 620, pos_y: 220 },
|
||||
{ node_key: 'declined', type: 'action', config: { action: 'noop' }, pos_x: 620, pos_y: 440 },
|
||||
{ node_key: 'cancelInvoice', type: 'action', config: { action: 'noop' }, pos_x: 620, pos_y: 880 },
|
||||
{ node_key: 'cancelInvoice', type: 'action', config: { action: 'noop' }, pos_x: 620, pos_y: 770 },
|
||||
];
|
||||
const edges = [
|
||||
{ from_node: 't', to_node: 'prepContract' },
|
||||
@@ -90,11 +90,13 @@ function buildBookingFullGraph() {
|
||||
{ from_node: 'sendContract', to_node: 'gateSigned' },
|
||||
{ from_node: 'gateSigned', from_handle: 'confirm', to_node: 'prepEvent' },
|
||||
{ from_node: 'gateSigned', from_handle: 'deny', to_node: 'declined' },
|
||||
{ from_node: 'prepEvent', to_node: 'waitEvent' },
|
||||
{ from_node: 'waitEvent', to_node: 'prepInvoice' },
|
||||
// Prepare + approve the invoice EARLY (admin can adjust line items now);
|
||||
// then the wait holds dispatch until the event date, and it sends itself.
|
||||
{ from_node: 'prepEvent', to_node: 'prepInvoice' },
|
||||
{ from_node: 'prepInvoice', to_node: 'reviewInvoice' },
|
||||
{ from_node: 'reviewInvoice', from_handle: 'confirm', to_node: 'sendInvoice' },
|
||||
{ from_node: 'reviewInvoice', from_handle: 'confirm', to_node: 'waitEvent' },
|
||||
{ from_node: 'reviewInvoice', from_handle: 'deny', to_node: 'cancelInvoice' },
|
||||
{ from_node: 'waitEvent', to_node: 'sendInvoice' },
|
||||
{ from_node: 'sendInvoice', to_node: 'done' },
|
||||
];
|
||||
return { nodes, edges };
|
||||
@@ -108,20 +110,21 @@ function buildBookingSimpleGraph() {
|
||||
const nodes = [
|
||||
{ node_key: 't', type: 'trigger', config: {}, pos_x: 320, pos_y: 0 },
|
||||
{ node_key: 'prepEvent', type: 'action', config: { action: 'prepare_event' }, pos_x: 320, pos_y: 110 },
|
||||
{ node_key: 'waitEvent', type: 'wait', config: { untilVar: 'eventDate' }, pos_x: 320, pos_y: 220 },
|
||||
{ node_key: 'prepInvoice', type: 'action', config: { action: 'prepare_invoice' }, pos_x: 320, pos_y: 330 },
|
||||
{ node_key: 'reviewInvoice', type: 'gate', config: { label: 'Review invoice before sending' }, pos_x: 320, pos_y: 440 },
|
||||
{ node_key: 'prepInvoice', type: 'action', config: { action: 'prepare_invoice' }, pos_x: 320, pos_y: 220 },
|
||||
{ node_key: 'reviewInvoice', type: 'gate', config: { label: 'Review invoice (early — dispatch waits for the event)' }, pos_x: 320, pos_y: 330 },
|
||||
{ node_key: 'waitEvent', type: 'wait', config: { untilVar: 'eventDate' }, pos_x: 320, pos_y: 440 },
|
||||
{ node_key: 'sendInvoice', type: 'action', config: { action: 'send_document', document: 'invoice', recipient: 'customer' }, pos_x: 320, pos_y: 550 },
|
||||
{ node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 320, pos_y: 660 },
|
||||
{ node_key: 'cancelInvoice', type: 'action', config: { action: 'noop' }, pos_x: 620, pos_y: 440 },
|
||||
{ node_key: 'cancelInvoice', type: 'action', config: { action: 'noop' }, pos_x: 620, pos_y: 330 },
|
||||
];
|
||||
const edges = [
|
||||
{ from_node: 't', to_node: 'prepEvent' },
|
||||
{ from_node: 'prepEvent', to_node: 'waitEvent' },
|
||||
{ from_node: 'waitEvent', to_node: 'prepInvoice' },
|
||||
// Prepare + approve the invoice early; the wait holds dispatch to the event date.
|
||||
{ from_node: 'prepEvent', to_node: 'prepInvoice' },
|
||||
{ from_node: 'prepInvoice', to_node: 'reviewInvoice' },
|
||||
{ from_node: 'reviewInvoice', from_handle: 'confirm', to_node: 'sendInvoice' },
|
||||
{ from_node: 'reviewInvoice', from_handle: 'confirm', to_node: 'waitEvent' },
|
||||
{ from_node: 'reviewInvoice', from_handle: 'deny', to_node: 'cancelInvoice' },
|
||||
{ from_node: 'waitEvent', to_node: 'sendInvoice' },
|
||||
{ from_node: 'sendInvoice', to_node: 'done' },
|
||||
];
|
||||
return { nodes, edges };
|
||||
@@ -258,7 +261,7 @@ const BUILTINS = [
|
||||
},
|
||||
{
|
||||
key: 'booking_full',
|
||||
version: 2,
|
||||
version: 3,
|
||||
enabled: false,
|
||||
name: 'Booking — quote → contract → event → invoice (built-in)',
|
||||
trigger_type: 'quote.accepted',
|
||||
@@ -266,23 +269,25 @@ const BUILTINS = [
|
||||
description:
|
||||
'On quote acceptance: prepare the contract, let the admin review it (adjust line items / '
|
||||
+ 'terms) and confirm before it is sent, wait for the admin to confirm it is signed, then '
|
||||
+ 'create the event/gallery, wait to the shoot date, prepare the invoice and — after a '
|
||||
+ 'second admin review gate — send it. No document is ever sent without an explicit admin '
|
||||
+ 'OK. Disabled by default — the document actions are stubs until the booking cutover, so '
|
||||
+ 'an enabled run just records observable skipped steps. A starting point to edit.',
|
||||
+ 'create the event/gallery and prepare the invoice EARLY so the admin can adjust it. The '
|
||||
+ 'admin approves the invoice at the review gate whenever they like; dispatch then waits '
|
||||
+ 'until the event date and sends itself. No document is sent without an explicit admin OK. '
|
||||
+ 'Disabled by default — the document actions are stubs until the booking cutover, so an '
|
||||
+ 'enabled run just records observable skipped steps. A starting point to edit.',
|
||||
build: async () => buildBookingFullGraph(),
|
||||
},
|
||||
{
|
||||
key: 'booking_simple',
|
||||
version: 2,
|
||||
version: 3,
|
||||
enabled: false,
|
||||
name: 'Booking — quote → event → invoice (built-in)',
|
||||
trigger_type: 'quote.accepted',
|
||||
trigger_config: {},
|
||||
description:
|
||||
'The no-contract booking path: on quote acceptance create the event/gallery, wait to the '
|
||||
+ 'shoot date, prepare the invoice and — after an admin review gate — send it. Same '
|
||||
+ 'review-before-send rule and stub caveat as the full booking flow; disabled by default.',
|
||||
'The no-contract booking path: on quote acceptance create the event/gallery and prepare the '
|
||||
+ 'invoice early. The admin approves it at the review gate ahead of time; dispatch then waits '
|
||||
+ 'until the event date and sends itself. Same review-before-send rule and stub caveat as the '
|
||||
+ 'full booking flow; disabled by default.',
|
||||
build: async () => buildBookingSimpleGraph(),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -232,7 +232,10 @@
|
||||
"confirm": "Bestätigen",
|
||||
"deny": "Ablehnen",
|
||||
"recorded": "Antwort gespeichert",
|
||||
"defaultPrompt": "Ein Workflow benötigt deine Bestätigung."
|
||||
"defaultPrompt": "Ein Workflow benötigt deine Bestätigung.",
|
||||
"pendingTitle": "Offene Freigaben",
|
||||
"viewAll": "Alle Freigaben ansehen",
|
||||
"acted": "Erledigt"
|
||||
},
|
||||
"editor": {
|
||||
"namePlaceholder": "Workflow-Name",
|
||||
@@ -248,6 +251,7 @@
|
||||
"saved": "Workflow gespeichert",
|
||||
"saveFailed": "Speichern fehlgeschlagen",
|
||||
"badJson": "Konfiguration ist kein gültiges JSON",
|
||||
"daysBefore": "Tage vor dem Anlass",
|
||||
"showAdvanced": "Erweitert (JSON)",
|
||||
"hideAdvanced": "Erweitert ausblenden (JSON)",
|
||||
"triggerHint": "Der Auslöser wird oben in der Leiste gesetzt (Wenn …).",
|
||||
|
||||
@@ -232,7 +232,10 @@
|
||||
"confirm": "Confirm",
|
||||
"deny": "Deny",
|
||||
"recorded": "Response recorded",
|
||||
"defaultPrompt": "A workflow needs your confirmation."
|
||||
"defaultPrompt": "A workflow needs your confirmation.",
|
||||
"pendingTitle": "Pending approvals",
|
||||
"viewAll": "View all approvals",
|
||||
"acted": "Done"
|
||||
},
|
||||
"editor": {
|
||||
"namePlaceholder": "Workflow name",
|
||||
@@ -248,6 +251,7 @@
|
||||
"saved": "Workflow saved",
|
||||
"saveFailed": "Could not save",
|
||||
"badJson": "Config is not valid JSON",
|
||||
"daysBefore": "days before event",
|
||||
"showAdvanced": "Advanced (JSON)",
|
||||
"hideAdvanced": "Hide advanced (JSON)",
|
||||
"triggerHint": "The trigger is set in the toolbar above (When …).",
|
||||
|
||||
@@ -10,18 +10,24 @@ import {
|
||||
HardDrive,
|
||||
Image,
|
||||
Archive,
|
||||
Heart
|
||||
Heart,
|
||||
Inbox,
|
||||
Check,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import { differenceInDays, parseISO } from 'date-fns';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
|
||||
import { Button, Card, Loading } from '../../components/common';
|
||||
import { UpdateNotification } from '../../components/admin/UpdateNotification';
|
||||
import { CrmOverviewSection } from '../../components/admin/CrmOverviewSection';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
import { adminService, ActivityType } from '../../services/admin.service';
|
||||
import { workflowsService } from '../../services/workflows.service';
|
||||
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
|
||||
|
||||
interface StatCard {
|
||||
title: string;
|
||||
@@ -72,6 +78,24 @@ export const AdminDashboard: React.FC = () => {
|
||||
queryFn: () => eventsService.getEvents(1, 5, 'expiring'),
|
||||
});
|
||||
|
||||
// Pending workflow approvals — only when the workflow engine is live. These
|
||||
// are the human-in-the-loop gates (e.g. "review invoice before sending").
|
||||
const { flags } = useFeatureFlags();
|
||||
const qc = useQueryClient();
|
||||
const { data: pendingApprovals } = useQuery({
|
||||
queryKey: ['workflow-approvals'],
|
||||
queryFn: () => workflowsService.approvals(),
|
||||
enabled: !!flags.workflows,
|
||||
});
|
||||
const approvalMutation = useMutation({
|
||||
mutationFn: ({ id, action }: { id: number; action: 'confirm' | 'deny' }) => workflowsService.actApproval(id, action),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['workflow-approvals'] });
|
||||
toast.success(t('workflows.approvals.acted', 'Done') as string);
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error || (t('common.error', 'Something went wrong') as string)),
|
||||
});
|
||||
|
||||
const isLoading = statsLoading || eventsLoading;
|
||||
|
||||
if (isLoading) {
|
||||
@@ -241,6 +265,52 @@ export const AdminDashboard: React.FC = () => {
|
||||
</button>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Pending workflow approvals — the human-in-the-loop gates. Only
|
||||
rendered when the workflow engine is live and something is waiting. */}
|
||||
{!!flags.workflows && pendingApprovals && pendingApprovals.length > 0 && (
|
||||
<Card padding="md" className="mt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('workflows.approvals.pendingTitle', 'Pending approvals')}</h2>
|
||||
<Inbox className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{pendingApprovals.slice(0, 5).map((a) => {
|
||||
const prompt = (a.payload as any)?.prompt as string | undefined;
|
||||
return (
|
||||
<div key={a.id} className="flex items-center justify-between gap-3 p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-medium text-neutral-900 dark:text-neutral-100 truncate">{a.workflow_name}</h3>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 truncate">
|
||||
{prompt || a.type}{a.entity_type ? ` · ${a.entity_type} #${a.entity_id}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button variant="primary" size="sm" isLoading={approvalMutation.isPending}
|
||||
onClick={() => approvalMutation.mutate({ id: a.id, action: 'confirm' })}
|
||||
leftIcon={<Check className="w-4 h-4" />}>
|
||||
{t('workflows.approvals.confirm', 'Confirm')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" isLoading={approvalMutation.isPending}
|
||||
onClick={() => approvalMutation.mutate({ id: a.id, action: 'deny' })}
|
||||
leftIcon={<X className="w-4 h-4" />}>
|
||||
{t('workflows.approvals.deny', 'Deny')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{pendingApprovals.length > 5 && (
|
||||
<button
|
||||
onClick={() => navigate('/admin/workflows/approvals')}
|
||||
className="w-full mt-4 text-sm text-accent hover:opacity-80 font-medium"
|
||||
>
|
||||
{t('workflows.approvals.viewAll', 'View all approvals')} →
|
||||
</button>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
|
||||
@@ -130,6 +130,7 @@ export const WorkflowEditorPage: React.FC = () => {
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
const [name, setName] = useState('');
|
||||
const [triggerType, setTriggerType] = useState('invoice.sent');
|
||||
const [triggerConfig, setTriggerConfig] = useState<Record<string, any>>({});
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [counter, setCounter] = useState(1);
|
||||
@@ -148,6 +149,7 @@ export const WorkflowEditorPage: React.FC = () => {
|
||||
const serializeFlow = () => JSON.stringify({
|
||||
name,
|
||||
trigger_type: triggerType,
|
||||
trigger_config: triggerConfig,
|
||||
enabled,
|
||||
nodes: nodes.map((n) => ({
|
||||
node_key: n.id, type: (n.data as any).nodeType, config: (n.data as any).config || {},
|
||||
@@ -166,6 +168,7 @@ export const WorkflowEditorPage: React.FC = () => {
|
||||
const tt = p.trigger_type || triggerType;
|
||||
if (p.name != null) setName(p.name);
|
||||
if (p.trigger_type) setTriggerType(p.trigger_type);
|
||||
if (p.trigger_config && typeof p.trigger_config === 'object') setTriggerConfig(p.trigger_config);
|
||||
if (p.enabled != null) setEnabled(!!p.enabled);
|
||||
setNodes(p.nodes.map((n: any) => ({
|
||||
id: n.node_key, type: 'wf', position: { x: n.pos_x || 0, y: n.pos_y || 0 },
|
||||
@@ -185,6 +188,7 @@ export const WorkflowEditorPage: React.FC = () => {
|
||||
if (!workflow) return;
|
||||
setName(workflow.name);
|
||||
setTriggerType(workflow.trigger_type);
|
||||
setTriggerConfig((workflow.trigger_config as Record<string, any>) || {});
|
||||
setEnabled(workflow.enabled === true || workflow.enabled === 1);
|
||||
setNodes(workflow.nodes.map((n) => ({
|
||||
id: n.node_key,
|
||||
@@ -236,6 +240,7 @@ export const WorkflowEditorPage: React.FC = () => {
|
||||
mutationFn: () => workflowsService.update(workflowId, {
|
||||
name: name.trim() || 'Untitled',
|
||||
trigger_type: triggerType,
|
||||
trigger_config: triggerConfig,
|
||||
enabled,
|
||||
nodes: nodes.map((n) => ({
|
||||
node_key: n.id, type: (n.data as any).nodeType, config: (n.data as any).config || {},
|
||||
@@ -271,6 +276,17 @@ export const WorkflowEditorPage: React.FC = () => {
|
||||
>
|
||||
{TRIGGERS.map((tr) => <option key={tr} value={tr}>{tr}</option>)}
|
||||
</select>
|
||||
{triggerType === 'event.date_approaching' && (
|
||||
<label className="text-sm text-neutral-600 dark:text-neutral-400 flex items-center gap-1.5">
|
||||
{t('workflows.editor.daysBefore', 'days before event')}
|
||||
<input
|
||||
type="number" min={0} max={365}
|
||||
value={triggerConfig.daysBefore ?? 2}
|
||||
onChange={(e) => setTriggerConfig((c) => ({ ...c, daysBefore: Number(e.target.value) }))}
|
||||
className="w-16 px-2 py-1 rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 text-sm"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label className="text-sm text-neutral-700 dark:text-neutral-300 flex items-center gap-1.5">
|
||||
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
||||
{t('workflows.enabled', 'Enabled')}
|
||||
|
||||
Reference in New Issue
Block a user