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:
Luca
2026-06-23 16:11:18 +02:00
parent 0b6c33e59a
commit 182e655fcf
6 changed files with 170 additions and 30 deletions
+5 -1
View File
@@ -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 …).",
+5 -1
View File
@@ -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 …).",
+72 -2
View File
@@ -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')}