Merge pull request #667 from Luca-Timo/feat/workflow-engine

feat: admin-configurable workflow engine + dunning/Mahngebühr rework (RFC — feedback welcome)
This commit is contained in:
Paul Nothaft
2026-06-25 19:17:03 +02:00
committed by GitHub
54 changed files with 5742 additions and 235 deletions
+11
View File
@@ -46,6 +46,9 @@ import { QuoteResponsePage } from './pages/public/QuoteResponsePage';
import { ContractResponsePage } from './pages/public/ContractResponsePage';
import { ProjectsListPage } from './pages/admin/projects/ProjectsListPage';
import { ProjectCockpitPage } from './pages/admin/projects/ProjectCockpitPage';
import { WorkflowsListPage } from './pages/admin/workflows/WorkflowsListPage';
import { WorkflowApprovalsPage } from './pages/admin/workflows/WorkflowApprovalsPage';
import { WorkflowEditorPage } from './pages/admin/workflows/WorkflowEditorPage';
import { ContractsListPage } from './pages/admin/contracts/ContractsListPage';
import { ContractEditorPage } from './pages/admin/contracts/ContractEditorPage';
import { ContractDetailPage } from './pages/admin/contracts/ContractDetailPage';
@@ -347,6 +350,14 @@ function App() {
<Route path="customers" element={<Navigate to="/admin/clients/accounts" replace />} />
<Route path="customers/:id" element={<RedirectCustomerDetail />} />
{/* Workflows (automation engine) — top-level area gated
by the `workflows` flag. */}
<Route element={<RequireFeature flag="workflows" />}>
<Route path="workflows" element={<WorkflowsListPage />} />
<Route path="workflows/approvals" element={<WorkflowApprovalsPage />} />
<Route path="workflows/:id" element={<WorkflowEditorPage />} />
</Route>
<Route path="settings" element={<SettingsPage />} />
<Route path="system-health" element={<SystemHealthPage />} />
<Route path="webhooks/:id/deliveries" element={<WebhookDeliveriesPage />} />
@@ -11,6 +11,7 @@ import {
Users,
Briefcase,
Landmark,
Workflow,
PanelLeftClose,
PanelLeftOpen,
} from 'lucide-react';
@@ -110,6 +111,12 @@ const navigation: NavItem[] = [
permission: 'accounting.view',
featureFlag: 'accounting',
},
// Workflows (automation engine) — top-level, gated by the `workflows` flag.
{
nameKey: 'navigation.workflows', href: '/admin/workflows', icon: Workflow,
permission: 'workflows.view',
featureFlag: 'workflows',
},
];
export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose, collapsed = false, onToggleCollapse }) => {
@@ -64,6 +64,9 @@ export const DEFAULT_FLAGS: FeatureFlags = {
whatsapp: false,
// Live Slideshow ("Diashow") — opt-in; gates all slideshow admin UI.
slideshow: false,
// Workflow / automation engine — opt-in; gates the Workflows admin area
// and the engine runtime (triggers/actions/gates).
workflows: false,
};
export const FEATURE_FLAGS_QUERY_KEY = ['feature-flags'] as const;
@@ -23,6 +23,7 @@ import {
Wallet,
FolderKanban,
MonitorPlay,
Workflow,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Card } from '../../../components/common';
@@ -134,6 +135,24 @@ export const FeaturesTab: React.FC = () => {
/>
</Section>
{/* Automation — the visual workflow engine. Master kill-switch for the
Workflows admin area and the runtime; off by default. */}
<Section title={t('settings.features.sections.automation', 'Automation')}>
<FeatureCard
icon={Workflow}
title={t('settings.features.workflows.title', 'Workflows')}
description={t(
'settings.features.workflows.description',
'Build visual automations on a canvas — triggers, conditions, branches, loops and admin approval gates. Your reminder ladder and booking steps become editable flows. Strictly opt-in.',
)}
status="new"
statusLabel={statusLabel('new')}
sidebarLabel={t('settings.features.workflows.sidebar', 'Workflows')}
enabled={staged.workflows}
onToggle={(next) => setFlag('workflows', next)}
/>
</Section>
{/* Clients (#354 follow-up). Visual grouping for the CRM-area
sub-features. The "Clients" sidebar section itself is gated
by a derived `clients` flag (computed from whether any
+127 -2
View File
@@ -199,8 +199,96 @@
"calendar": "Kalender",
"clients": "CRM",
"accounting": "Buchhaltung",
"workflows": "Workflows",
"betaTag": "Beta"
},
"workflows": {
"title": "Workflows",
"subtitle": "Visuelle Automatisierungen Auslöser, Bedingungen, Freigaben und Aktionen.",
"new": "Neuer Workflow",
"empty": "Noch keine Workflows. Erstelle einen, um Rechnungsstellung und Buchungsschritte zu automatisieren.",
"builtin": "integriert",
"triggerLabel": "Auslöser",
"enabled": "Aktiv",
"disabled": "Inaktiv",
"confirmDelete": "Diesen Workflow löschen?",
"toggle": {
"confirmDisableBuiltin": "Das Deaktivieren dieses eingebauten Ablaufs stellt das vorherige Standardverhalten wieder her die Automatisierung wird dadurch nicht abgeschaltet. Fortfahren?"
},
"test": {
"title": "Testlauf",
"hint": "Probelauf: durchläuft den ganzen Ablauf sofort (Wartezeiten übersprungen, Gates automatisch bestätigt), Nebeneffekte werden nur simuliert keine echten E-Mails. Optional eine Entitäts-ID (z. B. eine Rechnung) angeben, damit Bedingungen sie lesen können.",
"entityId": "Entitäts-ID (optional, z. B. Rechnungs-ID)",
"run": "Probelauf starten",
"result": "Ergebnis",
"failed": "Testlauf fehlgeschlagen"
},
"toast": {
"createFailed": "Workflow konnte nicht erstellt werden",
"deleted": "Workflow gelöscht",
"deleteFailed": "Workflow konnte nicht gelöscht werden"
},
"approvals": {
"title": "Freigaben",
"subtitle": "Workflow-Durchläufe, die auf deine Bestätigung warten.",
"empty": "Aktuell wartet nichts auf dich.",
"confirm": "Bestätigen",
"deny": "Ablehnen",
"recorded": "Antwort gespeichert",
"defaultPrompt": "Ein Workflow benötigt deine Bestätigung.",
"pendingTitle": "Offene Freigaben",
"viewAll": "Alle Freigaben ansehen",
"acted": "Erledigt"
},
"editor": {
"namePlaceholder": "Workflow-Name",
"when": "Wenn",
"cleanUp": "Layout aufräumen",
"textView": "Text",
"canvasView": "Canvas",
"loadText": "In Editor laden",
"textLoaded": "Geladen prüfen und speichern",
"textNeedsArrays": "Benötigt „nodes“- und „edges“-Arrays",
"textNeedsTrigger": "Benötigt genau einen Trigger-Knoten",
"textHint": "Der gesamte Ablauf als JSON zum Teilen kopieren oder an ein LLM geben, oder einen Ablauf einfügen und in den Editor laden. Nach dem Import „Layout aufräumen“ klicken.",
"saved": "Workflow gespeichert",
"saveFailed": "Speichern fehlgeschlagen",
"badJson": "Konfiguration ist kein gültiges JSON",
"daysBefore": "Tage vor dem Anlass",
"templateGroup": "Vorlagengruppe für Erinnerungen",
"templateGroupHint": "Die genaue Vorlage wird je Anlasstyp innerhalb dieser Gruppe automatisch gewählt: «Gruppe»_«Anlasstyp», falls vorhanden, sonst «Gruppe»_default. Leer = event_reminder.",
"showAdvanced": "Erweitert (JSON)",
"hideAdvanced": "Erweitert ausblenden (JSON)",
"triggerHint": "Der Auslöser wird oben in der Leiste gesetzt (Wenn …).",
"actionLabel": "Aktion",
"recipient": "Empfänger",
"recipientCustomer": "Kunde (berücksichtigt Geschäftszeiten)",
"recipientAdmin": "Admin (sofort gesendet)",
"emailTemplate": "E-Mail-Vorlagenschlüssel",
"webhookUrl": "Webhook-URL",
"webhookTarget": "Webhook",
"webhookNone": "— Konfigurierten Webhook wählen —",
"webhookInactive": "(inaktiv)",
"webhookHint": "Wird über die Webhook-Pipeline zugestellt (Signatur, Wiederholungen, SSRF-Prüfungen). Endpunkte unter Einstellungen → Webhooks verwalten.",
"condition": "Bedingung",
"exprField": "Feld",
"exprOp": "Operator",
"exprValue": "Wert",
"conditionHint": "Führt bei „wahr“ zur „yes“-Kante, sonst zur „no“-Kante.",
"waitType": "Warten",
"waitUntil": "Bis zu einem Datum",
"waitDelay": "Feste Verzögerung",
"waitAnchor": "Warten bis",
"days": "Tage",
"hours": "Stunden",
"minutes": "Min",
"maxIterations": "Höchstens wiederholen (mal)",
"gatePrompt": "Frage an den Admin",
"gatePromptPh": "z. B. Keine Zahlung erhalten Mahnung senden?",
"gateTimeout": "Automatisch ablaufen nach (Tagen, optional)",
"gateHint": "Sendet dem Admin einen Bestätigen/Ablehnen-Link; führt zur „confirm“- oder „deny“-Kante."
}
},
"eventTypes": {
"title": "Veranstaltungsarten",
"subtitle": "Veranstaltungsarten und deren Standard-Themes anpassen",
@@ -1683,7 +1771,8 @@
"accounting": "Buchhaltung",
"insights": "Auswertungen & Zugriff",
"customers": "Kunden",
"clients": "CRM"
"clients": "CRM",
"automation": "Automatisierung"
},
"status": {
"stable": "stabil",
@@ -1799,6 +1888,11 @@
"slideshow": {
"title": "Live-Diashow",
"description": "Ein separater Vollbild-„Diashow“-Link pro Event für Beamer bei Live-Events übernimmt neue Uploads automatisch, mit Voreinstellungen je Event-Typ und globalen Wasserzeichen-Vorgaben unter Einstellungen → Diashow."
},
"workflows": {
"title": "Workflows",
"description": "Visuelle Automatisierungen auf einer Canvas erstellen Auslöser, Bedingungen, Verzweigungen, Schleifen und Freigabe-Gates für Admins. Deine Mahnstufen und Buchungsschritte werden zu bearbeitbaren Abläufen. Strikt optional.",
"sidebar": "Workflows"
}
},
"customerSurface": {
@@ -4845,6 +4939,25 @@
"crm_invoices_late_fee_minor": {
"label": "Mahngebühr (Rappen / Cent)"
},
"crm_invoices_late_fee_type": {
"label": "Art der Mahngebühr"
},
"crm_invoices_late_fee_percent": {
"label": "Mahngebühr (% der Rechnung)"
},
"lateFeeType": {
"flat": "Fester Betrag (Rappen)",
"percent": "Prozentsatz der Rechnung"
},
"lateFeeAgb": {
"title": "Mahngebühren müssen in den AGB stehen",
"body": "Vertragliche Pflicht: Sätze wie „Es werden Mahnspesen erhoben“ reichen nicht aus. In den AGB muss die konkrete Gebühr klar beziffert sein (z.B. „CHF 20 ab der 2. Mahnung“). Mit dem Treuhänder prüfen."
},
"dunningMoved": {
"title": "Der Mahnrhythmus liegt jetzt in den Workflows",
"body": "Wann und wie oft Zahlungserinnerungen für überfällige Rechnungen verschickt werden, wird im Workflow „Rechnungsmahnung“ festgelegt. Die Mahngebühren unten gelten weiterhin.",
"link": "Workflows öffnen"
},
"crm_invoices_late_fee_label": {
"label": "Bezeichnung Mahngebühr"
},
@@ -4879,7 +4992,7 @@
"label": "Automatische Mahnungen aktivieren"
},
"crm_invoices_late_fee_enabled": {
"label": "Mahngebühr aktivieren"
"label": "Mahngebühr ab der 2. Mahnung (jede weitere Mahnung)"
},
"crm_quotes_tos_required": {
"label": "Kunden müssen „Ich akzeptiere die AGB“ ankreuzen, bevor sie annehmen können"
@@ -4948,6 +5061,11 @@
},
"reminderTemplates": {
"title": "Erinnerungs-E-Mails vor dem Anlass",
"scheduleMoved": {
"title": "Der Versandzeitpunkt liegt jetzt in den Workflows",
"body": "Ob Erinnerungen vor dem Anlass verschickt werden und wie viele Tage vorher, wird im Workflow „Erinnerung vor dem Anlass“ festgelegt. Auf dieser Seite werden die E-Mail-Vorlagen bearbeitet; anlassspezifische Überschreibungen bleiben auf der jeweiligen Anlass-Detailseite.",
"link": "Workflows öffnen"
},
"globalSection": "Globales Verhalten",
"globalHelp": "Standardmässig aus — aktiviere, um Erinnerungen zu senden. Der unten gesetzte Offset ist der Standard; jeder Anlass kann ihn auf der Detailseite überschreiben.",
"enableLabel": "Erinnerungs-E-Mails vor dem Anlass senden",
@@ -5144,6 +5262,13 @@
"eventHelp": "Wird auf den Vertrag übernommen und an jeden daraus erzeugten Anlass / jede Rechnung weitergegeben. Setzen Sie dies, damit Kundenportal und Mahn-E-Mails die richtige Bezeichnung \"Hochzeit Doe / Müller\" anzeigen.",
"eventName": "Anlassname",
"eventNamePlaceholder": "z. B. Hochzeit Doe / Müller",
"eventType": "Anlasstyp",
"eventTypeNone": "— Standard verwenden —",
"eventTypeHint": "Wird für den Anlass verwendet, der bei Annahme dieses Angebots erstellt wird.",
"bookingWorkflow": "Buchungs-Workflow (bei Annahme)",
"bookingWorkflowNone": "— Keiner —",
"bookingWorkflowDisabled": "(deaktiviert)",
"bookingWorkflowHint": "Der Ablauf, der startet, wenn die Kundin/der Kunde annimmt. „Keiner“ = kein Buchungsablauf. Der Ablauf muss aktiviert sein, um zu starten.",
"eventSection": "Anlass (optional)",
"eventTimeEnd": "Ende",
"eventTimeStart": "Beginn",
+127 -2
View File
@@ -199,8 +199,96 @@
"calendar": "Calendar",
"clients": "CRM",
"accounting": "Accounting",
"workflows": "Workflows",
"betaTag": "Beta"
},
"workflows": {
"title": "Workflows",
"subtitle": "Visual automations — triggers, conditions, gates and actions.",
"new": "New workflow",
"empty": "No workflows yet. Create one to automate your invoicing and booking steps.",
"builtin": "built-in",
"triggerLabel": "Trigger",
"enabled": "Enabled",
"disabled": "Disabled",
"confirmDelete": "Delete this workflow?",
"toggle": {
"confirmDisableBuiltin": "Disabling this built-in reverts to the previous built-in behaviour — it does not turn the automation off. Continue?"
},
"test": {
"title": "Test run",
"hint": "Dry run: walks the whole flow now (waits skipped, gates auto-confirmed) with side effects mocked — no real emails. Optionally give an entity id (e.g. an invoice) so conditions can read it.",
"entityId": "Entity id (optional, e.g. invoice id)",
"run": "Run dry test",
"result": "Result",
"failed": "Test run failed"
},
"toast": {
"createFailed": "Could not create workflow",
"deleted": "Workflow deleted",
"deleteFailed": "Could not delete workflow"
},
"approvals": {
"title": "Approvals",
"subtitle": "Workflow runs waiting on your confirmation.",
"empty": "Nothing waiting for you right now.",
"confirm": "Confirm",
"deny": "Deny",
"recorded": "Response recorded",
"defaultPrompt": "A workflow needs your confirmation.",
"pendingTitle": "Pending approvals",
"viewAll": "View all approvals",
"acted": "Done"
},
"editor": {
"namePlaceholder": "Workflow name",
"when": "When",
"cleanUp": "Clean up layout",
"textView": "Text",
"canvasView": "Canvas",
"loadText": "Load into editor",
"textLoaded": "Loaded — review and Save",
"textNeedsArrays": "Needs \"nodes\" and \"edges\" arrays",
"textNeedsTrigger": "Needs exactly one trigger node",
"textHint": "The whole flow as JSON — copy it to share or hand to an LLM, or paste a flow and load it into the editor. Click “Clean up layout” after importing.",
"saved": "Workflow saved",
"saveFailed": "Could not save",
"badJson": "Config is not valid JSON",
"daysBefore": "days before event",
"templateGroup": "Reminder template group",
"templateGroupHint": "The exact template is auto-picked per event type within this group: «group»_«eventType» if you authored one, else «group»_default. Blank = event_reminder.",
"showAdvanced": "Advanced (JSON)",
"hideAdvanced": "Hide advanced (JSON)",
"triggerHint": "The trigger is set in the toolbar above (When …).",
"actionLabel": "Action",
"recipient": "Recipient",
"recipientCustomer": "Customer (respects business hours)",
"recipientAdmin": "Admin (sent immediately)",
"emailTemplate": "Email template key",
"webhookUrl": "Webhook URL",
"webhookTarget": "Webhook",
"webhookNone": "— Select a configured webhook —",
"webhookInactive": "(inactive)",
"webhookHint": "Delivered via the webhook pipeline (signing, retries, SSRF checks). Manage endpoints in Settings → Webhooks.",
"condition": "Condition",
"exprField": "Field",
"exprOp": "Operator",
"exprValue": "Value",
"conditionHint": "Routes to the “yes” edge when true, “no” when false.",
"waitType": "Wait",
"waitUntil": "Until a date",
"waitDelay": "A fixed delay",
"waitAnchor": "Wait until",
"days": "Days",
"hours": "Hours",
"minutes": "Min",
"maxIterations": "Repeat at most (times)",
"gatePrompt": "Question for the admin",
"gatePromptPh": "e.g. No payment received — send a reminder?",
"gateTimeout": "Auto-expire after (days, optional)",
"gateHint": "Emails the admin a confirm/deny link; routes to the “confirm” or “deny” edge."
}
},
"archives": {
"title": "Archives",
"subtitle": "Manage archived photo galleries",
@@ -1239,7 +1327,8 @@
"accounting": "Accounting",
"insights": "Insights & Access",
"customers": "Customers",
"clients": "CRM"
"clients": "CRM",
"automation": "Automation"
},
"status": {
"stable": "stable",
@@ -1355,6 +1444,11 @@
"slideshow": {
"title": "Live Slideshow",
"description": "A separate fullscreen \"Diashow\" link per event for projectors at live events — auto-picks-up new uploads, with per-event-type presets and global watermark defaults under Settings → Slideshow."
},
"workflows": {
"title": "Workflows",
"description": "Build visual automations on a canvas — triggers, conditions, branches, loops and admin approval gates. Your reminder ladder and booking steps become editable flows. Strictly opt-in.",
"sidebar": "Workflows"
}
},
"customerSurface": {
@@ -4843,6 +4937,25 @@
"crm_invoices_late_fee_minor": {
"label": "Late fee (minor units / Rappen)"
},
"crm_invoices_late_fee_type": {
"label": "Late fee type"
},
"crm_invoices_late_fee_percent": {
"label": "Late fee (% of invoice)"
},
"lateFeeType": {
"flat": "Flat amount (Rappen)",
"percent": "Percentage of invoice"
},
"lateFeeAgb": {
"title": "Late fees must be itemised in your terms (AGB)",
"body": "A contractual duty: phrases like “late fees apply” aren't enough. Your terms must state the concrete fee (e.g. “CHF 20 from the 2nd reminder”). Verify with your Treuhänder."
},
"dunningMoved": {
"title": "Reminder schedule is now in Workflows",
"body": "When and how often overdue reminders go out is configured in the “Invoice dunning” workflow. The late-fee amounts below still apply.",
"link": "Open Workflows"
},
"crm_invoices_late_fee_label": {
"label": "Late fee label"
},
@@ -4877,7 +4990,7 @@
"label": "Send automatic reminders for overdue invoices"
},
"crm_invoices_late_fee_enabled": {
"label": "Add a late fee on the second reminder"
"label": "Add a late fee on every reminder after the first"
},
"crm_quotes_tos_required": {
"label": "Require customers to tick \"I accept the Terms of Service\" before accepting"
@@ -4946,6 +5059,11 @@
},
"reminderTemplates": {
"title": "Pre-event reminder emails",
"scheduleMoved": {
"title": "The reminder schedule is now in Workflows",
"body": "Whether pre-event reminders are sent, and how many days before the event, is configured in the “Pre-event reminder” workflow. This page edits the email templates; per-event overrides stay on each events detail page.",
"link": "Open Workflows"
},
"globalSection": "Global behaviour",
"globalHelp": "Off by default — turn on to start sending pre-event reminders. The offset below is the default; each event can override on its detail page.",
"enableLabel": "Send pre-event reminder emails",
@@ -5142,6 +5260,13 @@
"eventHelp": "Snapshotted onto the contract and propagated to any event / invoice generated from it. Set this so the customer portal and dunning emails show the right \"Wedding Doe / Müller\" label.",
"eventName": "Event name",
"eventNamePlaceholder": "e.g. Wedding Doe / Müller",
"eventType": "Event type",
"eventTypeNone": "— Use default —",
"eventTypeHint": "Used for the event created when this quote is accepted.",
"bookingWorkflow": "Booking workflow (on acceptance)",
"bookingWorkflowNone": "— None —",
"bookingWorkflowDisabled": "(disabled)",
"bookingWorkflowHint": "The flow that runs when the customer accepts. Leave as None to run no booking flow. The flow must be enabled to fire.",
"eventSection": "Event (optional)",
"eventTimeEnd": "End",
"eventTimeStart": "Start",
+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 */}
@@ -28,6 +28,9 @@ import { ProjectSelect } from '../../../components/admin/ProjectSelect';
import { VatRateSelect } from '../../../components/admin/VatRateSelect';
import { accountingService } from '../../../services/accounting.service';
import { vatCodesService } from '../../../services/vatCodes.service';
import { eventTypesService } from '../../../services/eventTypes.service';
import { workflowsService } from '../../../services/workflows.service';
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
import { InstallmentsPanel } from '../../../components/admin/InstallmentsPanel';
import { customerAdminService } from '../../../services/customerAdmin.service';
import { userManagementService } from '../../../services/userManagement.service';
@@ -47,6 +50,8 @@ interface FormState {
validUntil: string;
eventName: string;
eventDate: string;
eventType: string;
bookingWorkflowId: number | null;
eventTimeStart: string;
eventTimeEnd: string;
expectedDurationHours: string;
@@ -83,6 +88,8 @@ const empty: FormState = {
validUntil: '',
eventName: '',
eventDate: '',
eventType: '',
bookingWorkflowId: null,
eventTimeStart: '',
eventTimeEnd: '',
expectedDurationHours: '',
@@ -115,6 +122,8 @@ function buildPayload(f: FormState): QuoteCreatePayload {
validUntil: f.validUntil || undefined,
eventName: f.eventName || undefined,
eventDate: f.eventDate || undefined,
eventType: f.eventType || null,
bookingWorkflowId: f.bookingWorkflowId,
eventTimeStart: f.eventTimeStart || undefined,
eventTimeEnd: f.eventTimeEnd || undefined,
expectedDurationHours: f.expectedDurationHours ? Number(f.expectedDurationHours) : undefined,
@@ -197,6 +206,22 @@ export const QuoteEditorPage: React.FC = () => {
// convert to) don't silently start at 0%. Never clobbers a touched value.
const { data: acctSettings } = useQuery({ queryKey: ['accounting-settings'], queryFn: () => accountingService.getSettings() });
const { data: outputVatCodes } = useQuery({ queryKey: ['vat-codes', 'output'], queryFn: () => vatCodesService.listOutput() });
// Active event types — drives the event-type dropdown (and the type of the
// event this quote converts into).
const { data: eventTypes = [] } = useQuery({ queryKey: ['event-types-active'], queryFn: () => eventTypesService.getActiveEventTypes() });
// Booking-workflow picker: the flows that run on quote acceptance. Only shown
// when the workflow engine is live.
const { flags } = useFeatureFlags();
const workflowsLive = !!flags.workflows;
const { data: allWorkflows = [] } = useQuery({
queryKey: ['workflows'],
queryFn: () => workflowsService.list(),
enabled: workflowsLive,
});
const bookingWorkflows = useMemo(
() => allWorkflows.filter((w) => w.trigger_type === 'quote.accepted'),
[allWorkflows],
);
const didSeedVatRef = useRef(false);
useEffect(() => {
if (isEdit || didSeedVatRef.current) return;
@@ -231,6 +256,8 @@ export const QuoteEditorPage: React.FC = () => {
validUntil: q.validUntil || '',
eventName: q.eventName || '',
eventDate: q.eventDate || '',
eventType: q.eventType || '',
bookingWorkflowId: q.bookingWorkflowId ?? null,
eventTimeStart: q.eventTimeStart || '',
eventTimeEnd: q.eventTimeEnd || '',
expectedDurationHours: q.expectedDurationHours?.toString() || '',
@@ -531,6 +558,46 @@ export const QuoteEditorPage: React.FC = () => {
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Input label={t('quotes.field.eventName', 'Event name') as string} value={form.eventName}
onChange={(e) => setForm((f) => ({ ...f, eventName: e.target.value }))} />
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('quotes.field.eventType', 'Event type')}
</label>
<select
value={form.eventType}
onChange={(e) => setForm((f) => ({ ...f, eventType: e.target.value }))}
className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100"
>
<option value="">{t('quotes.field.eventTypeNone', '— Use default —')}</option>
{eventTypes.map((et) => (
<option key={et.id} value={et.slug_prefix}>{et.emoji ? `${et.emoji} ` : ''}{et.name}</option>
))}
</select>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
{t('quotes.field.eventTypeHint', 'Used for the event created when this quote is accepted.')}
</p>
</div>
{workflowsLive && (
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('quotes.field.bookingWorkflow', 'Booking workflow (on acceptance)')}
</label>
<select
value={form.bookingWorkflowId ?? ''}
onChange={(e) => setForm((f) => ({ ...f, bookingWorkflowId: e.target.value ? Number(e.target.value) : null }))}
className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100"
>
<option value="">{t('quotes.field.bookingWorkflowNone', '— None —')}</option>
{bookingWorkflows.map((w) => (
<option key={w.id} value={w.id}>
{w.name}{(w.enabled === true || w.enabled === 1) ? '' : ` ${t('quotes.field.bookingWorkflowDisabled', '(disabled)')}`}
</option>
))}
</select>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
{t('quotes.field.bookingWorkflowHint', 'The flow that runs when the customer accepts. Leave as None to run no booking flow. The flow must be enabled to fire.')}
</p>
</div>
)}
<LocalizedDateInput label={t('quotes.field.eventDate', 'Event date') as string} value={form.eventDate}
onChange={(iso) => setForm((f) => ({ ...f, eventDate: iso }))} />
<TimeField label={t('quotes.field.eventTimeStart', 'Start time') as string} value={form.eventTimeStart}
@@ -8,8 +8,9 @@
*/
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Save as SaveIcon } from 'lucide-react';
import { Save as SaveIcon, Workflow as WorkflowIcon } from 'lucide-react';
import { Button, Card, Loading, Input } from '../../../components/common';
import { settingsService } from '../../../services/settings.service';
import { quotesService } from '../../../services/quotes.service';
@@ -31,7 +32,10 @@ const SETTING_KEYS = [
'crm_invoices_reminder_first_days',
'crm_invoices_reminder_second_days',
'crm_invoices_late_fee_enabled',
'crm_invoices_late_fee_type',
'crm_invoices_late_fee_minor',
'crm_invoices_late_fee_percent',
'crm_invoices_late_fee_vat_enabled',
'crm_invoices_late_fee_label',
'crm_invoices_skonto_business_days',
'crm_invoices_skonto_percent_default',
@@ -80,6 +84,10 @@ export const CrmSettingsPage: React.FC = () => {
// invoice-pipeline + revenue tiles, all of which are CRM-money).
const showQuotes = !!flags.quotes;
const showInvoices = !!flags.bills;
// When the workflow engine is live, reminder TIMING is owned by the Invoice
// dunning flow — show a pointer instead of the legacy schedule controls. When
// it's off, the legacy reminder ladder still runs, so keep its controls.
const workflowsLive = !!flags.workflows;
const showContracts = !!flags.contracts;
const showDashboardOverview = !!(flags.quotes || flags.bills);
const anySection = showQuotes || showInvoices || showContracts || showDashboardOverview;
@@ -241,21 +249,69 @@ export const CrmSettingsPage: React.FC = () => {
<Card>
<h3 className="font-semibold mb-3">{t('crmSettings.section.invoices', 'Invoices')}</h3>
{checkbox('crm_invoices_qr_enabled', 'Render payment QR on invoice PDFs')}
{checkbox('crm_invoices_reminders_enabled', 'Send automatic reminders for overdue invoices')}
{checkbox('crm_invoices_late_fee_enabled', 'Add a late fee on the second reminder')}
{/* Reminder TIMING: owned by the Invoice dunning workflow when the
engine is live (callout); otherwise the legacy schedule controls. The
late-fee math below is configured here in both cases — it's the fee
the dunning path applies, not part of the schedule. */}
{workflowsLive ? (
<div className="mt-2 rounded-lg border border-blue-300 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/20 p-3 text-sm text-blue-800 dark:text-blue-200 flex items-start gap-2">
<WorkflowIcon className="w-4 h-4 mt-0.5 shrink-0" />
<div>
<p className="font-medium">{t('crmSettings.dunningMoved.title', 'Reminder schedule is now in Workflows')}</p>
<p className="mt-1">
{t('crmSettings.dunningMoved.body', 'When and how often overdue reminders go out is configured in the “Invoice dunning” workflow. Late-fee amounts below still apply.')}{' '}
<Link to="/admin/workflows" className="underline font-medium">{t('crmSettings.dunningMoved.link', 'Open Workflows')}</Link>
</p>
</div>
</div>
) : (
checkbox('crm_invoices_reminders_enabled', 'Send automatic reminders for overdue invoices')
)}
{checkbox('crm_invoices_late_fee_enabled', 'Add a late fee (Mahngebühr) on every reminder after the first')}
<div className="mt-2 rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 p-3 text-sm text-amber-800 dark:text-amber-200">
<p className="font-medium">{t('crmSettings.lateFeeAgb.title', 'Late fees must be itemised in your terms (AGB)')}</p>
<p className="mt-1">{t('crmSettings.lateFeeAgb.body', 'Vertragliche Pflicht: Sätze wie „Es werden Mahnspesen erhoben“ reichen nicht aus. In den AGB muss die konkrete Gebühr klar beziffert sein (z.B. „CHF 20 ab der 2. Mahnung“). Mit dem Treuhänder prüfen.')}</p>
</div>
{checkbox('crm_invoices_late_fee_vat_enabled', 'Charge VAT on late fees (Switzerland — leave off for DE/AT; no effect if your organisation has no VAT rate)')}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
<Input type="number" min={1} max={365}
label={t('crmSettings.crm_invoices_reminder_first_days.label', 'First reminder after (days past due)') as string}
value={values.crm_invoices_reminder_first_days ?? 14}
onChange={(e) => setVal('crm_invoices_reminder_first_days', Number(e.target.value))} />
<Input type="number" min={1} max={365}
label={t('crmSettings.crm_invoices_reminder_second_days.label', 'Second reminder after (days past due)') as string}
value={values.crm_invoices_reminder_second_days ?? 30}
onChange={(e) => setVal('crm_invoices_reminder_second_days', Number(e.target.value))} />
<Input type="number" min={0}
label={t('crmSettings.crm_invoices_late_fee_minor.label', 'Late fee (minor units / Rappen)') as string}
value={values.crm_invoices_late_fee_minor ?? 0}
onChange={(e) => setVal('crm_invoices_late_fee_minor', Number(e.target.value))} />
{!workflowsLive && (
<>
<Input type="number" min={1} max={365}
label={t('crmSettings.crm_invoices_reminder_first_days.label', 'First reminder after (days past due)') as string}
value={values.crm_invoices_reminder_first_days ?? 14}
onChange={(e) => setVal('crm_invoices_reminder_first_days', Number(e.target.value))} />
<Input type="number" min={1} max={365}
label={t('crmSettings.crm_invoices_reminder_second_days.label', 'Second reminder after (days past due)') as string}
value={values.crm_invoices_reminder_second_days ?? 30}
onChange={(e) => setVal('crm_invoices_reminder_second_days', Number(e.target.value))} />
</>
)}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('crmSettings.crm_invoices_late_fee_type.label', 'Late fee type')}
</label>
<select
value={values.crm_invoices_late_fee_type ?? 'flat'}
onChange={(e) => setVal('crm_invoices_late_fee_type', e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100"
>
<option value="flat">{t('crmSettings.lateFeeType.flat', 'Flat amount (Rappen)')}</option>
<option value="percent">{t('crmSettings.lateFeeType.percent', 'Percentage of invoice')}</option>
</select>
</div>
{(values.crm_invoices_late_fee_type ?? 'flat') === 'percent' ? (
<Input type="number" min={0} step="0.01" max={100}
label={t('crmSettings.crm_invoices_late_fee_percent.label', 'Late fee (% of invoice)') as string}
value={values.crm_invoices_late_fee_percent ?? 0}
onChange={(e) => setVal('crm_invoices_late_fee_percent', Number(e.target.value))} />
) : (
<Input type="number" min={0}
label={t('crmSettings.crm_invoices_late_fee_minor.label', 'Late fee (minor units / Rappen)') as string}
value={values.crm_invoices_late_fee_minor ?? 0}
onChange={(e) => setVal('crm_invoices_late_fee_minor', Number(e.target.value))} />
)}
<Input
label={t('crmSettings.crm_invoices_late_fee_label.label', 'Late fee label') as string}
value={values.crm_invoices_late_fee_label ?? 'Mahngebühr'}
@@ -33,13 +33,14 @@ import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { ArrowLeft, Save, AlertTriangle } from 'lucide-react';
import { ArrowLeft, Save, AlertTriangle, Workflow as WorkflowIcon } from 'lucide-react';
import { Button, Card, Loading, Input } from '../../../components/common';
import { SUPPORTED_LANGUAGES } from '../../../components/common/LanguageSelector';
import { EmailTemplateEditor } from '../../../components/admin/EmailTemplateEditor';
import { eventTypesService } from '../../../services/eventTypes.service';
import { emailService, type EmailTemplateTranslation } from '../../../services/email.service';
import { settingsService } from '../../../services/settings.service';
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
const TEMPLATE_KEY_DEFAULT = 'event_reminder_default';
const TEMPLATE_KEY_PREFIX = 'event_reminder_';
@@ -61,16 +62,20 @@ export const ReminderTemplatesPage: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
// ---- Global toggles ---------------------------------------------------
// Only two keys are read on this page — fetch just those instead of
// the full ~100-row app_settings dict. Saves transferring + parsing
// every unrelated setting.
// Global on/off + lead time: owned by the "Pre-event reminder" workflow when
// the engine is live; otherwise the legacy crm_event_reminders_* settings drive
// the hourly pass, so we keep their controls. Per-event override (disable /
// offset / custom body) always lives on the event detail page.
const { flags } = useFeatureFlags();
const workflowsLive = !!flags.workflows;
const { data: settings } = useQuery({
queryKey: ['reminder-settings'],
queryFn: () => settingsService.getSettings([
'crm_event_reminders_enabled',
'crm_event_reminders_days_before',
]),
enabled: !workflowsLive,
});
const [enabled, setEnabled] = useState<boolean>(false);
const [daysBefore, setDaysBefore] = useState<number>(2);
@@ -259,49 +264,51 @@ export const ReminderTemplatesPage: React.FC = () => {
</h1>
</div>
{/* Global toggles strip */}
{/* Schedule (on/off + lead time): in Workflows when the engine is live,
else the legacy global controls. */}
<Card className="mb-4">
<h3 className="font-semibold text-sm mb-2">
{t('reminderTemplates.globalSection', 'Global behaviour')}
</h3>
<p className="text-xs text-muted-theme mb-3">
{t('reminderTemplates.globalHelp',
'Off by default — turn on to start sending pre-event reminders. The offset below is the default; each event can override on its detail page.')}
</p>
<div className="flex items-center gap-6 flex-wrap">
<label className="inline-flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
{t('reminderTemplates.enableLabel', 'Send pre-event reminder emails')}
</label>
<div className="flex items-center gap-2">
<label htmlFor="reminder-days-before" className="text-sm">
{t('reminderTemplates.daysBeforeLabel', 'Days before the event')}
</label>
<Input
id="reminder-days-before"
type="number"
min={0}
max={365}
value={daysBefore}
onChange={(e) => setDaysBefore(Number(e.target.value))}
className="w-24"
/>
{workflowsLive ? (
<div className="flex items-start gap-2 text-sm text-blue-800 dark:text-blue-200">
<WorkflowIcon className="w-4 h-4 mt-0.5 shrink-0" />
<div>
<p className="font-medium">{t('reminderTemplates.scheduleMoved.title', 'The reminder schedule is now in Workflows')}</p>
<p className="mt-1 text-muted-theme">
{t('reminderTemplates.scheduleMoved.body', 'Whether pre-event reminders are sent, and how many days before the event, is configured in the “Pre-event reminder” workflow. This page edits the email templates; per-event overrides stay on each events detail page.')}{' '}
<Link to="/admin/workflows" className="underline font-medium">{t('reminderTemplates.scheduleMoved.link', 'Open Workflows')}</Link>
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => saveSettingsMutation.mutate()}
isLoading={saveSettingsMutation.isPending}
disabled={saveSettingsMutation.isPending}
leftIcon={<Save className="w-4 h-4" />}
>
{t('reminderTemplates.saveSettings', 'Save global settings')}
</Button>
</div>
) : (
<>
<h3 className="font-semibold text-sm mb-2">
{t('reminderTemplates.globalSection', 'Global behaviour')}
</h3>
<p className="text-xs text-muted-theme mb-3">
{t('reminderTemplates.globalHelp',
'Off by default — turn on to start sending pre-event reminders. The offset below is the default; each event can override on its detail page.')}
</p>
<div className="flex items-center gap-6 flex-wrap">
<label className="inline-flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
{t('reminderTemplates.enableLabel', 'Send pre-event reminder emails')}
</label>
<div className="flex items-center gap-2">
<label htmlFor="reminder-days-before" className="text-sm">
{t('reminderTemplates.daysBeforeLabel', 'Days before the event')}
</label>
<Input id="reminder-days-before" type="number" min={0} max={365}
value={daysBefore} onChange={(e) => setDaysBefore(Number(e.target.value))} className="w-24" />
</div>
<Button variant="outline" size="sm"
onClick={() => saveSettingsMutation.mutate()}
isLoading={saveSettingsMutation.isPending}
disabled={saveSettingsMutation.isPending}
leftIcon={<Save className="w-4 h-4" />}>
{t('reminderTemplates.saveSettings', 'Save global settings')}
</Button>
</div>
</>
)}
</Card>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
@@ -0,0 +1,225 @@
/**
* Structured config editor for a workflow node — dropdowns + typed fields per
* node type, so admins don't hand-edit JSON. An "Advanced (JSON)" expander is
* kept for power users / config the form doesn't cover. Changes are applied
* live to the node (the global Save persists them).
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
type Cfg = Record<string, any>;
interface WebhookOption { id: number; name: string; active: boolean }
interface Props {
nodeType: string;
config: Cfg;
onChange: (next: Cfg) => void;
webhooks?: WebhookOption[];
}
const field = 'w-full px-2 py-1.5 rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 text-sm';
const lbl = 'block text-xs text-neutral-500 dark:text-neutral-400 mb-1';
const ACTIONS = [
['queue_payment_check', 'Send payment-check email (dunning gate)'],
['escalate_to_collections', 'Hand off to collections (email admin)'],
['send_email', 'Send email'],
['notify_pre_event', 'Send pre-event reminder'],
['notify_gallery_expiring', 'Send gallery-expiring warning'],
['notify_gallery_expired', 'Send gallery-expired email'],
['reserve_date', 'Reserve the event date'],
['prepare_quote', 'Prepare a quote (draft)'],
['prepare_contract', 'Prepare a contract (draft)'],
['prepare_invoice', 'Prepare an invoice (draft)'],
['prepare_event', 'Create an event (draft)'],
['prepare_gallery', 'Create a gallery (draft)'],
['send_document', 'Send the document'],
['webhook', 'Call a webhook'],
['noop', 'Do nothing'],
];
const CONDITIONS = [
['invoice_paid', 'Invoice is paid'],
['expr', 'Compare a field'],
['always', 'Always → yes'],
['never', 'Never → no'],
];
const WAIT_ANCHORS = [
['dueDate', 'the invoice due date'],
['issueDate', 'the invoice date'],
['eventDate', 'the event date'],
];
const OPS = ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'truthy', 'falsy'];
const Row: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
<div><label className={lbl}>{label}</label>{children}</div>
);
export const NodeConfigPanel: React.FC<Props> = ({ nodeType, config, onChange, webhooks = [] }) => {
const { t } = useTranslation();
const [showJson, setShowJson] = useState(false);
const [jsonText, setJsonText] = useState(JSON.stringify(config || {}, null, 2));
const [jsonErr, setJsonErr] = useState<string | null>(null);
const set = (patch: Cfg) => onChange({ ...config, ...patch });
const num = (v: string) => (v === '' ? undefined : Number(v));
const applyJson = (text: string) => {
setJsonText(text);
try { onChange(JSON.parse(text || '{}')); setJsonErr(null); }
catch (e) { setJsonErr(t('workflows.editor.badJson', 'Config is not valid JSON') as string); }
};
const waitMode = config.untilVar ? 'until' : 'delay';
return (
<div className="space-y-3">
{nodeType === 'trigger' && (
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('workflows.editor.triggerHint', 'The trigger is set in the toolbar above (When …).')}
</p>
)}
{(nodeType === 'action' || nodeType === 'webhook') && (
<Row label={t('workflows.editor.actionLabel', 'Action')}>
<select className={field} value={config.action || (nodeType === 'webhook' ? 'webhook' : 'noop')} onChange={(e) => set({ action: e.target.value })}>
{ACTIONS.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</Row>
)}
{nodeType === 'action' && config.action === 'send_email' && (
<>
<Row label={t('workflows.editor.recipient', 'Recipient')}>
<select className={field} value={config.recipientClass || 'customer'} onChange={(e) => set({ recipientClass: e.target.value })}>
<option value="customer">{t('workflows.editor.recipientCustomer', 'Customer (respects business hours)')}</option>
<option value="admin">{t('workflows.editor.recipientAdmin', 'Admin (sent immediately)')}</option>
</select>
</Row>
<Row label={t('workflows.editor.emailTemplate', 'Email template key')}>
<input className={field} value={config.emailType || ''} onChange={(e) => set({ emailType: e.target.value })} placeholder="invoice_reminder" />
</Row>
</>
)}
{nodeType === 'action' && config.action === 'notify_pre_event' && (
<Row label={t('workflows.editor.templateGroup', 'Reminder template group')}>
<input className={field} value={config.templateGroup || ''} onChange={(e) => set({ templateGroup: e.target.value })} placeholder="event_reminder" />
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('workflows.editor.templateGroupHint', 'The exact template is auto-picked per event type within this group: «group»_«eventType» if you authored one, else «group»_default. Blank = event_reminder.')}
</p>
</Row>
)}
{(nodeType === 'action' || nodeType === 'webhook') && (config.action === 'webhook' || nodeType === 'webhook') && (
<Row label={t('workflows.editor.webhookTarget', 'Webhook')}>
<select className={field} value={config.webhookId ?? ''} onChange={(e) => set({ webhookId: e.target.value ? Number(e.target.value) : undefined })}>
<option value="">{t('workflows.editor.webhookNone', '— Select a configured webhook —')}</option>
{webhooks.map((w) => (
<option key={w.id} value={w.id}>{w.name}{w.active ? '' : ` ${t('workflows.editor.webhookInactive', '(inactive)')}`}</option>
))}
</select>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('workflows.editor.webhookHint', 'Delivered via the webhook pipeline (signing, retries, SSRF checks). Manage endpoints in Settings → Webhooks.')}
</p>
</Row>
)}
{(nodeType === 'condition' || nodeType === 'branch') && (
<>
<Row label={t('workflows.editor.condition', 'Condition')}>
<select className={field} value={config.condition || 'expr'} onChange={(e) => set({ condition: e.target.value })}>
{CONDITIONS.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</Row>
{config.condition === 'expr' && (
<>
<Row label={t('workflows.editor.exprField', 'Field')}>
<input className={field} value={config.field || ''} onChange={(e) => set({ field: e.target.value })} />
</Row>
<Row label={t('workflows.editor.exprOp', 'Operator')}>
<select className={field} value={config.op || 'truthy'} onChange={(e) => set({ op: e.target.value })}>
{OPS.map((o) => <option key={o} value={o}>{o}</option>)}
</select>
</Row>
{!['truthy', 'falsy'].includes(config.op) && (
<Row label={t('workflows.editor.exprValue', 'Value')}>
<input className={field} value={config.value ?? ''} onChange={(e) => set({ value: e.target.value })} />
</Row>
)}
</>
)}
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('workflows.editor.conditionHint', 'Routes to the “yes” edge when true, “no” when false.')}
</p>
</>
)}
{nodeType === 'wait' && (
<>
<Row label={t('workflows.editor.waitType', 'Wait')}>
<select
className={field}
value={waitMode}
onChange={(e) => (e.target.value === 'until'
? onChange({ untilVar: 'dueDate' })
: onChange({ delayDays: config.delayDays || 1 }))}
>
<option value="until">{t('workflows.editor.waitUntil', 'Until a date')}</option>
<option value="delay">{t('workflows.editor.waitDelay', 'A fixed delay')}</option>
</select>
</Row>
{waitMode === 'until' ? (
<Row label={t('workflows.editor.waitAnchor', 'Wait until')}>
<select className={field} value={config.untilVar || 'dueDate'} onChange={(e) => onChange({ untilVar: e.target.value })}>
{WAIT_ANCHORS.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</Row>
) : (
<div className="grid grid-cols-3 gap-2">
<Row label={t('workflows.editor.days', 'Days')}>
<input type="number" min={0} className={field} value={config.delayDays ?? ''} onChange={(e) => set({ delayDays: num(e.target.value) })} />
</Row>
<Row label={t('workflows.editor.hours', 'Hours')}>
<input type="number" min={0} className={field} value={config.delayHours ?? ''} onChange={(e) => set({ delayHours: num(e.target.value) })} />
</Row>
<Row label={t('workflows.editor.minutes', 'Min')}>
<input type="number" min={0} className={field} value={config.delayMinutes ?? ''} onChange={(e) => set({ delayMinutes: num(e.target.value) })} />
</Row>
</div>
)}
</>
)}
{nodeType === 'loop' && (
<Row label={t('workflows.editor.maxIterations', 'Repeat at most (times)')}>
<input type="number" min={1} className={field} value={config.maxIterations ?? 3} onChange={(e) => set({ maxIterations: num(e.target.value) })} />
</Row>
)}
{nodeType === 'gate' && (
<>
<Row label={t('workflows.editor.gatePrompt', 'Question for the admin')}>
<textarea className={field} rows={2} value={config.prompt || ''} onChange={(e) => set({ prompt: e.target.value })} placeholder={t('workflows.editor.gatePromptPh', 'e.g. No payment received — send a reminder?') as string} />
</Row>
<Row label={t('workflows.editor.gateTimeout', 'Auto-expire after (days, optional)')}>
<input type="number" min={0} className={field} value={config.timeoutDays ?? ''} onChange={(e) => set({ timeoutDays: num(e.target.value) })} />
</Row>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('workflows.editor.gateHint', 'Emails the admin a confirm/deny link; routes to the “confirm” or “deny” edge.')}
</p>
</>
)}
<button type="button" className="text-xs text-neutral-500 dark:text-neutral-400 underline" onClick={() => { setJsonText(JSON.stringify(config || {}, null, 2)); setShowJson((s) => !s); }}>
{showJson ? t('workflows.editor.hideAdvanced', 'Hide advanced (JSON)') : t('workflows.editor.showAdvanced', 'Advanced (JSON)')}
</button>
{showJson && (
<div className="space-y-1">
<textarea className={`${field} font-mono`} rows={8} value={jsonText} onChange={(e) => applyJson(e.target.value)} />
{jsonErr && <p className="text-xs text-red-600 dark:text-red-400">{jsonErr}</p>}
</div>
)}
</div>
);
};
@@ -0,0 +1,81 @@
/**
* Admin → Workflows → Approvals inbox. Lists workflow runs paused on a gate
* (e.g. "confirm there's no payment") and lets the admin confirm or deny,
* resuming the run down the matching edge. The same decision is also possible
* from the emailed confirm/deny link.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { ArrowLeft, Check, X } from 'lucide-react';
import { Button, Card, Loading } from '../../../components/common';
import { workflowsService, type WorkflowApproval } from '../../../services/workflows.service';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
export const WorkflowApprovalsPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const qc = useQueryClient();
const { formatDateTime } = useLocalizedDate();
const { data: approvals, isLoading } = useQuery({
queryKey: ['workflow-approvals'],
queryFn: () => workflowsService.approvals(),
});
const actMutation = useMutation({
mutationFn: ({ id, action }: { id: number; action: 'confirm' | 'deny' }) => workflowsService.actApproval(id, action),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['workflow-approvals'] });
toast.success(t('workflows.approvals.recorded', 'Response recorded') as string);
},
onError: (err: any) => toast.error(err?.response?.data?.error || (t('common.error', 'Something went wrong') as string)),
});
const promptOf = (a: WorkflowApproval) => (a.payload && (a.payload.prompt as string)) || t('workflows.approvals.defaultPrompt', 'A workflow needs your confirmation.');
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => navigate('/admin/workflows')} aria-label={t('common.back', 'Back') as string}>
<ArrowLeft className="w-4 h-4" />
</Button>
<div>
<h1 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">{t('workflows.approvals.title', 'Approvals')}</h1>
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('workflows.approvals.subtitle', 'Workflow runs waiting on your confirmation.')}</p>
</div>
</div>
<Card padding="none">
{isLoading ? (
<div className="p-10"><Loading /></div>
) : !approvals || approvals.length === 0 ? (
<div className="p-10 text-center text-neutral-500 dark:text-neutral-400">{t('workflows.approvals.empty', 'Nothing waiting for you right now.')}</div>
) : (
<ul className="divide-y divide-neutral-200 dark:divide-neutral-700">
{approvals.map((a) => (
<li key={a.id} className="flex items-center gap-3 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="text-sm text-neutral-900 dark:text-neutral-100">{promptOf(a)}</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">
{a.workflow_name}
{a.entity_type ? ` · ${a.entity_type} #${a.entity_id}` : ''}
{a.created_at ? ` · ${formatDateTime(a.created_at)}` : ''}
</div>
</div>
<Button variant="outline" size="sm" isLoading={actMutation.isPending} onClick={() => actMutation.mutate({ id: a.id, action: 'confirm' })} leftIcon={<Check className="w-4 h-4" />}>
{t('workflows.approvals.confirm', 'Confirm')}
</Button>
<Button variant="ghost" size="sm" onClick={() => actMutation.mutate({ id: a.id, action: 'deny' })} leftIcon={<X className="w-4 h-4" />}>
{t('workflows.approvals.deny', 'Deny')}
</Button>
</li>
))}
</ul>
)}
</Card>
</div>
);
};
@@ -0,0 +1,385 @@
/**
* Admin → Workflows → canvas editor (React Flow).
*
* Drag nodes from the palette, connect handle→handle (branch/gate/loop expose
* yes/no · confirm/deny · loop/exit handles, labelled on the node), click a
* node to edit it in the structured side panel, and Save (writes a new
* version; in-flight runs keep theirs). The graph maps 1:1 onto
* workflow_nodes/workflow_edges. Honours the admin light/dark theme.
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import {
ReactFlow, Background, Controls, MiniMap, addEdge, useNodesState, useEdgesState,
Handle, Position, type Connection, type Node, type Edge,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import dagre from '@dagrejs/dagre';
import { ArrowLeft, Save, Trash2, Wand2, Code } from 'lucide-react';
import { Button, Loading } from '../../../components/common';
import { api } from '../../../config/api';
import { useAdminDarkMode } from '../../../contexts/AdminDarkModeContext';
import { workflowsService, type WorkflowNodeType } from '../../../services/workflows.service';
import { NodeConfigPanel } from './NodeConfigPanel';
const PALETTE: WorkflowNodeType[] = ['trigger', 'condition', 'branch', 'loop', 'wait', 'action', 'gate', 'webhook'];
const TRIGGERS = [
'invoice.sent', 'invoice.paid', 'invoice.overdue',
'quote.sent', 'quote.accepted', 'quote.declined',
'contract.sent', 'contract.signed',
'event.date_approaching',
'gallery.published', 'gallery.expiring', 'gallery.expired',
'customer.created',
];
const COLORS: Record<string, string> = {
trigger: '#1D9E75', condition: '#BA7517', branch: '#BA7517', loop: '#378ADD',
wait: '#888780', action: '#534AB7', gate: '#7F77DD', webhook: '#888780',
};
const SOURCE_HANDLES: Record<string, string[]> = {
condition: ['yes', 'no'], branch: ['yes', 'no'], gate: ['confirm', 'deny'], loop: ['loop', 'exit'],
};
const WAIT_ANCHOR_LABEL: Record<string, string> = { dueDate: 'due date', issueDate: 'invoice date', eventDate: 'event date' };
const ACTION_LABEL: Record<string, string> = {
queue_payment_check: 'Send payment-check email', escalate_to_collections: 'Collections handoff', send_email: 'Send email', reserve_date: 'Reserve the date',
notify_pre_event: 'Send pre-event reminder', notify_gallery_expiring: 'Send gallery-expiring warning', notify_gallery_expired: 'Send gallery-expired email',
prepare_quote: 'Prepare quote', prepare_contract: 'Prepare contract', prepare_invoice: 'Prepare invoice',
prepare_event: 'Create event', prepare_gallery: 'Create gallery', send_document: 'Send document',
webhook: 'Call webhook', noop: 'Do nothing', set_context: 'Set value',
};
// Human-readable label derived live from a node's type + config.
function describeNode(type: string, config: any = {}, triggerType?: string): string {
const c = config || {};
switch (type) {
case 'trigger': return triggerType || 'When…';
case 'wait':
if (c.untilVar) return `Wait until ${WAIT_ANCHOR_LABEL[c.untilVar] || c.untilVar}`;
return `Wait ${[c.delayDays && `${c.delayDays}d`, c.delayHours && `${c.delayHours}h`, c.delayMinutes && `${c.delayMinutes}m`].filter(Boolean).join(' ') || '…'}`;
case 'condition':
case 'branch':
if (c.condition === 'invoice_paid') return 'Invoice paid?';
if (c.condition === 'expr') return `${c.field || 'field'} ${c.op || ''} ${c.value ?? ''}`.trim();
if (c.condition === 'always') return 'Always';
if (c.condition === 'never') return 'Never';
return c.condition || 'Condition';
case 'loop': return `Repeat ≤ ${c.maxIterations ?? 3}×`;
case 'action': return ACTION_LABEL[c.action] || c.action || 'Action';
case 'gate': return c.prompt ? `Ask: ${String(c.prompt).slice(0, 28)}${String(c.prompt).length > 28 ? '…' : ''}` : 'Admin confirm';
case 'webhook': return 'Call webhook';
default: return type;
}
}
function WfNode({ data }: { data: any }) {
const handles = SOURCE_HANDLES[data.nodeType];
const color = COLORS[data.nodeType] || '#888780';
const label = describeNode(data.nodeType, data.config, data.triggerType);
const pos = (i: number, n: number) => `${(100 / (n + 1)) * (i + 1)}%`;
return (
<div style={{ borderColor: color }} className="relative rounded-md border-2 bg-white dark:bg-neutral-900 px-3 pt-2 pb-4 min-w-[152px] text-center shadow-sm">
{data.nodeType !== 'trigger' && <Handle type="target" position={Position.Top} />}
<div className="text-[10px] uppercase tracking-wide" style={{ color }}>{data.nodeType}</div>
<div className="text-sm text-neutral-900 dark:text-neutral-100">{label}</div>
{handles ? handles.map((h, i) => (
<React.Fragment key={h}>
<span className="absolute text-[9px] text-neutral-400 dark:text-neutral-500" style={{ bottom: 3, left: pos(i, handles.length), transform: 'translateX(-50%)' }}>{h}</span>
<Handle id={h} type="source" position={Position.Bottom} style={{ left: pos(i, handles.length) }} />
</React.Fragment>
)) : <Handle type="source" position={Position.Bottom} />}
</div>
);
}
const nodeTypes = { wf: WfNode };
// Tidy the graph into a top-to-bottom tree with dagre (handles the loop-back
// cycle by breaking it internally).
function layoutGraph(nodes: Node[], edges: Edge[]): Node[] {
const g = new dagre.graphlib.Graph();
g.setDefaultEdgeLabel(() => ({}));
g.setGraph({ rankdir: 'TB', nodesep: 70, ranksep: 80 });
const W = 170;
const H = 70;
nodes.forEach((n) => g.setNode(n.id, { width: W, height: H }));
edges.forEach((e) => g.setEdge(e.source, e.target));
dagre.layout(g);
return nodes.map((n) => {
const p = g.node(n.id);
return p ? { ...n, position: { x: p.x - W / 2, y: p.y - H / 2 } } : n;
});
}
export const WorkflowEditorPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const qc = useQueryClient();
const { isDark } = useAdminDarkMode();
const { id } = useParams<{ id: string }>();
const workflowId = Number(id);
const { data: workflow, isLoading } = useQuery({
queryKey: ['workflow', workflowId],
queryFn: () => workflowsService.get(workflowId),
enabled: Number.isFinite(workflowId),
});
// Configured webhook subscriptions — the webhook node references one of these
// (the delivery then rides the webhook worker pipeline).
const { data: webhooks = [] } = useQuery({
queryKey: ['admin-webhooks'],
queryFn: async () => (await api.get<Array<{ id: number; name: string; active: boolean }>>('/admin/webhooks')).data,
});
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
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);
const rfRef = useRef<any>(null);
const cleanUp = useCallback(() => {
setNodes((nds) => layoutGraph(nds, edges));
setTimeout(() => rfRef.current?.fitView?.({ padding: 0.2, duration: 300 }), 60);
}, [edges, setNodes]);
// --- Advanced text mode: the whole flow as JSON (share / LLM round-trip) ---
const [textMode, setTextMode] = useState(false);
const [text, setText] = useState('');
const [textErr, setTextErr] = useState<string | null>(null);
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 || {},
pos_x: Math.round(n.position.x), pos_y: Math.round(n.position.y),
})),
edges: edges.map((e) => ({ from_node: e.source, from_handle: e.sourceHandle || null, to_node: e.target })),
}, null, 2);
const openText = () => { setText(serializeFlow()); setTextErr(null); setTextMode(true); };
const applyText = () => {
let p: any;
try { p = JSON.parse(text); } catch (e) { setTextErr(t('workflows.editor.badJson', 'Config is not valid JSON') as string); return; }
if (!Array.isArray(p.nodes) || !Array.isArray(p.edges)) { setTextErr(t('workflows.editor.textNeedsArrays', 'Needs "nodes" and "edges" arrays') as string); return; }
if (p.nodes.filter((n: any) => n.type === 'trigger').length !== 1) { setTextErr(t('workflows.editor.textNeedsTrigger', 'Needs exactly one trigger node') as string); return; }
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 },
data: { nodeType: n.type, config: n.config || {}, triggerType: tt },
})));
setEdges(p.edges.map((e: any, i: number) => ({
id: `e${i}`, source: e.from_node, target: e.to_node, sourceHandle: e.from_handle || undefined, label: e.from_handle || undefined,
})));
setTextErr(null);
setTextMode(false);
toast.success(t('workflows.editor.textLoaded', 'Loaded — review and Save') as string);
};
const copyText = () => { navigator.clipboard?.writeText(text); toast.success(t('common.copied', 'Copied') as string); };
useEffect(() => {
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,
type: 'wf',
position: { x: n.pos_x || 0, y: n.pos_y || 0 },
data: { nodeType: n.type, config: n.config || {}, triggerType: workflow.trigger_type },
})));
setEdges(workflow.edges.map((e, i) => ({
id: `e${i}`,
source: e.from_node,
target: e.to_node,
sourceHandle: e.from_handle || undefined,
label: e.from_handle || undefined,
})));
}, [workflow, setNodes, setEdges]);
// Keep the trigger node's label in sync when the trigger is changed in the toolbar.
useEffect(() => {
setNodes((nds) => nds.map((n) => (n.data?.nodeType === 'trigger' ? { ...n, data: { ...n.data, triggerType } } : n)));
}, [triggerType, setNodes]);
const onConnect = useCallback((c: Connection) => {
setEdges((eds) => addEdge({ ...c, label: c.sourceHandle || undefined }, eds));
}, [setEdges]);
const addNode = (type: WorkflowNodeType) => {
const key = type === 'trigger' ? `trigger_${counter}` : `${type}_${counter}`;
setCounter((c) => c + 1);
setNodes((nds) => nds.concat({
id: key, type: 'wf', position: { x: 140 + Math.random() * 220, y: 140 + Math.random() * 220 },
data: { nodeType: type, config: {}, triggerType },
}));
};
const selectedNode = useMemo(() => nodes.find((n) => n.id === selectedId) || null, [nodes, selectedId]);
const updateNodeConfig = (nodeId: string, config: Record<string, unknown>) => {
setNodes((nds) => nds.map((n) => (n.id === nodeId ? { ...n, data: { ...n.data, config } } : n)));
};
const deleteSelected = () => {
if (!selectedId) return;
setNodes((nds) => nds.filter((n) => n.id !== selectedId));
setEdges((eds) => eds.filter((e) => e.source !== selectedId && e.target !== selectedId));
setSelectedId(null);
};
const saveMutation = useMutation({
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 || {},
pos_x: Math.round(n.position.x), pos_y: Math.round(n.position.y),
})),
edges: edges.map((e) => ({ from_node: e.source, from_handle: e.sourceHandle || null, to_node: e.target })),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['workflow', workflowId] });
qc.invalidateQueries({ queryKey: ['workflows'] });
toast.success(t('workflows.editor.saved', 'Workflow saved') as string);
},
onError: (err: any) => toast.error(err?.response?.data?.error || (t('workflows.editor.saveFailed', 'Could not save') as string)),
});
if (isLoading) return <div className="p-10"><Loading /></div>;
return (
<div className="space-y-3">
<div className="flex items-center gap-3 flex-wrap">
<Button variant="ghost" size="sm" onClick={() => navigate('/admin/workflows')} aria-label={t('common.back', 'Back') as string}>
<ArrowLeft className="w-4 h-4" />
</Button>
<input
value={name} onChange={(e) => setName(e.target.value)}
className="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"
placeholder={t('workflows.editor.namePlaceholder', 'Workflow name') as string}
/>
<label className="text-sm text-neutral-600 dark:text-neutral-400">{t('workflows.editor.when', 'When')}</label>
<select
value={triggerType} onChange={(e) => setTriggerType(e.target.value)}
className="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"
>
{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')}
</label>
<div className="ml-auto flex items-center gap-2">
<Button variant="outline" onClick={() => (textMode ? setTextMode(false) : openText())} leftIcon={<Code className="w-4 h-4" />}>
{textMode ? t('workflows.editor.canvasView', 'Canvas') : t('workflows.editor.textView', 'Text')}
</Button>
<Button variant="primary" isLoading={saveMutation.isPending} onClick={() => saveMutation.mutate()} leftIcon={<Save className="w-4 h-4" />}>
{t('common.saveChanges', 'Save changes')}
</Button>
</div>
</div>
{!textMode && (
<div className="flex flex-wrap items-center gap-2">
{PALETTE.map((type) => (
<button
key={type} type="button" onClick={() => addNode(type)}
className="text-xs px-2 py-1 rounded border border-neutral-300 dark:border-neutral-600 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-800"
>
+ {type}
</button>
))}
<button
type="button" onClick={cleanUp}
className="ml-auto inline-flex items-center gap-1 text-xs px-2 py-1 rounded border border-neutral-300 dark:border-neutral-600 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-800"
>
<Wand2 className="w-3.5 h-3.5" /> {t('workflows.editor.cleanUp', 'Clean up layout')}
</button>
</div>
)}
{textMode ? (
<div className="space-y-2">
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('workflows.editor.textHint', 'The whole flow as JSON — copy it to share or hand to an LLM, or paste a flow and load it into the editor. Click “Clean up layout” after importing.')}
</p>
<textarea
value={text} onChange={(e) => setText(e.target.value)} spellCheck={false}
className="w-full font-mono text-xs p-2 rounded border border-neutral-300 dark:border-neutral-600 bg-neutral-50 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
style={{ height: '62vh' }}
/>
{textErr && <p className="text-xs text-red-600 dark:text-red-400">{textErr}</p>}
<div className="flex gap-2">
<Button variant="outline" onClick={copyText}>{t('common.copy', 'Copy')}</Button>
<Button variant="primary" onClick={applyText}>{t('workflows.editor.loadText', 'Load into editor')}</Button>
</div>
</div>
) : (
<div className="flex gap-3" style={{ height: '70vh' }}>
<div className="flex-1 rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden">
<ReactFlow
colorMode={isDark ? 'dark' : 'light'}
nodes={nodes} edges={edges} nodeTypes={nodeTypes}
onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onConnect={onConnect}
onNodeClick={(_, n) => setSelectedId(n.id)} onInit={(inst) => { rfRef.current = inst; }} fitView
>
<Background />
<Controls />
<MiniMap pannable zoomable />
</ReactFlow>
</div>
{selectedNode && (
<div className="w-80 rounded-lg border border-neutral-200 dark:border-neutral-700 p-3 space-y-3 bg-white dark:bg-neutral-900 overflow-y-auto">
<div className="flex items-center justify-between">
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{(selectedNode.data as any).nodeType} · {selectedNode.id}
</div>
<Button variant="ghost" size="sm" onClick={deleteSelected} aria-label={t('common.delete', 'Delete') as string}>
<Trash2 className="w-4 h-4 text-red-600 dark:text-red-400" />
</Button>
</div>
<NodeConfigPanel
nodeType={(selectedNode.data as any).nodeType}
config={(selectedNode.data as any).config || {}}
onChange={(cfg) => updateNodeConfig(selectedNode.id, cfg)}
webhooks={webhooks}
/>
</div>
)}
</div>
)}
</div>
);
};
@@ -0,0 +1,203 @@
/**
* Admin → Workflows list. Shows every automation flow with its trigger and
* enabled state, a quick enable toggle, edit (canvas) and delete. "New
* workflow" mints a minimal trigger→action graph and opens the editor. A
* pending-approvals shortcut sits in the header.
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { Plus, Workflow as WorkflowIcon, Inbox, Trash2, Pencil, FlaskConical } from 'lucide-react';
import { Button, Card, Loading } from '../../../components/common';
import { workflowsService, type WorkflowSummary, type WorkflowSavePayload, type WorkflowTestResult } from '../../../services/workflows.service';
const NEW_WORKFLOW: WorkflowSavePayload = {
name: 'New workflow',
trigger_type: 'invoice.sent',
enabled: false,
nodes: [
{ node_key: 'trigger', type: 'trigger', pos_x: 240, pos_y: 40 },
{ node_key: 'step1', type: 'action', config: { action: 'noop' }, pos_x: 240, pos_y: 200 },
],
edges: [{ from_node: 'trigger', to_node: 'step1' }],
};
export const WorkflowsListPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const qc = useQueryClient();
const { data: workflows, isLoading } = useQuery({
queryKey: ['workflows'],
queryFn: () => workflowsService.list(),
});
const createMutation = useMutation({
mutationFn: () => workflowsService.create(NEW_WORKFLOW),
onSuccess: (res) => {
qc.invalidateQueries({ queryKey: ['workflows'] });
navigate(`/admin/workflows/${res.id}`);
},
onError: (err: any) => toast.error(err?.response?.data?.error || (t('workflows.toast.createFailed', 'Could not create workflow') as string)),
});
const [testTarget, setTestTarget] = useState<WorkflowSummary | null>(null);
const [testEntityId, setTestEntityId] = useState('');
const [testResult, setTestResult] = useState<WorkflowTestResult | null>(null);
const testMutation = useMutation({
mutationFn: () => workflowsService.testRun(testTarget!.id, {
entityId: testEntityId ? Number(testEntityId) : null,
dryRun: true,
}),
onSuccess: (res) => setTestResult(res),
onError: (err: any) => toast.error(err?.response?.data?.error || (t('workflows.test.failed', 'Test run failed') as string)),
});
const toggleMutation = useMutation({
mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) => workflowsService.setEnabled(id, enabled),
onSuccess: () => qc.invalidateQueries({ queryKey: ['workflows'] }),
onError: (err: any) => toast.error(err?.response?.data?.error || (t('common.error', 'Something went wrong') as string)),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => workflowsService.remove(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['workflows'] });
toast.success(t('workflows.toast.deleted', 'Workflow deleted') as string);
},
onError: (err: any) => toast.error(err?.response?.data?.error || (t('workflows.toast.deleteFailed', 'Could not delete workflow') as string)),
});
const isEnabled = (w: WorkflowSummary) => w.enabled === true || w.enabled === 1;
const isBuiltin = (w: WorkflowSummary) => w.is_builtin === true || w.is_builtin === 1;
// Disabling a built-in reverts to the legacy hardcoded behaviour (it does NOT
// stop the automation) — warn so the admin isn't surprised. Enabling is guarded
// server-side (a flow using unimplemented actions is refused with a clear error).
const toggle = (w: WorkflowSummary) => {
const next = !isEnabled(w);
if (!next && isBuiltin(w)) {
const msg = t('workflows.toggle.confirmDisableBuiltin',
'Disabling this built-in reverts to the previous built-in behaviour — it does not turn the automation off. Continue?') as string;
if (!window.confirm(msg)) return;
}
toggleMutation.mutate({ id: w.id, enabled: next });
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-accent-soft text-on-accent-soft flex items-center justify-center">
<WorkflowIcon className="w-5 h-5" />
</div>
<div>
<h1 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">{t('workflows.title', 'Workflows')}</h1>
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('workflows.subtitle', 'Visual automations — triggers, conditions, gates and actions.')}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => navigate('/admin/workflows/approvals')} leftIcon={<Inbox className="w-4 h-4" />}>
{t('workflows.approvals.title', 'Approvals')}
</Button>
<Button variant="primary" isLoading={createMutation.isPending} onClick={() => createMutation.mutate()} leftIcon={<Plus className="w-4 h-4" />}>
{t('workflows.new', 'New workflow')}
</Button>
</div>
</div>
<Card padding="none">
{isLoading ? (
<div className="p-10"><Loading /></div>
) : !workflows || workflows.length === 0 ? (
<div className="p-10 text-center text-neutral-500 dark:text-neutral-400">{t('workflows.empty', 'No workflows yet. Create one to automate your invoicing and booking steps.')}</div>
) : (
<ul className="divide-y divide-neutral-200 dark:divide-neutral-700">
{workflows.map((w) => (
<li key={w.id} className="flex items-center gap-3 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Link to={`/admin/workflows/${w.id}`} className="font-medium text-neutral-900 dark:text-neutral-100 truncate hover:underline">{w.name}</Link>
{isBuiltin(w) && (
<span className="text-[11px] px-1.5 py-0.5 rounded bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-300">{t('workflows.builtin', 'built-in')}</span>
)}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">
{t('workflows.triggerLabel', 'Trigger')}: <code>{w.trigger_type}</code> · v{w.version}
</div>
</div>
<button
type="button"
onClick={() => toggle(w)}
className={`text-xs px-2 py-1 rounded-full border ${isEnabled(w)
? 'bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 border-green-300 dark:border-green-700'
: 'bg-neutral-50 dark:bg-neutral-800 text-neutral-500 dark:text-neutral-400 border-neutral-300 dark:border-neutral-600'}`}
>
{isEnabled(w) ? t('workflows.enabled', 'Enabled') : t('workflows.disabled', 'Disabled')}
</button>
<Button variant="ghost" size="sm" onClick={() => { setTestResult(null); setTestEntityId(''); setTestTarget(w); }} aria-label={t('workflows.test.title', 'Test run') as string}>
<FlaskConical className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => navigate(`/admin/workflows/${w.id}`)} aria-label={t('common.edit', 'Edit') as string}>
<Pencil className="w-4 h-4" />
</Button>
{!isBuiltin(w) && (
<Button
variant="ghost"
size="sm"
onClick={() => { if (window.confirm(t('workflows.confirmDelete', 'Delete this workflow?') as string)) deleteMutation.mutate(w.id); }}
aria-label={t('common.delete', 'Delete') as string}
>
<Trash2 className="w-4 h-4 text-red-600 dark:text-red-400" />
</Button>
)}
</li>
))}
</ul>
)}
</Card>
{testTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={() => setTestTarget(null)}>
<div className="w-full max-w-lg rounded-lg bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 p-4 space-y-3" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('workflows.test.title', 'Test run')} {testTarget.name}</h2>
<button type="button" onClick={() => setTestTarget(null)} aria-label={t('common.close', 'Close') as string} className="text-neutral-500 dark:text-neutral-400"></button>
</div>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('workflows.test.hint', 'Dry run: walks the whole flow now (waits skipped, gates auto-confirmed) with side effects mocked — no real emails. Optionally give an entity id (e.g. an invoice) so conditions can read it.')}
</p>
<input
value={testEntityId} onChange={(e) => setTestEntityId(e.target.value)}
placeholder={t('workflows.test.entityId', 'Entity id (optional, e.g. invoice id)') as string}
className="w-full px-2 py-1.5 rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 text-sm"
/>
<Button variant="primary" isLoading={testMutation.isPending} onClick={() => testMutation.mutate()}>
{t('workflows.test.run', 'Run dry test')}
</Button>
{testResult && (
<div className="mt-2">
<div className="text-sm mb-1 text-neutral-700 dark:text-neutral-300">
{t('workflows.test.result', 'Result')}: <span className="font-medium">{testResult.status}</span>
</div>
<ol className="text-xs space-y-1 max-h-72 overflow-y-auto">
{testResult.steps.map((s, i) => (
<li key={i} className="flex items-start gap-2 border-b border-neutral-100 dark:border-neutral-800 pb-1">
<span className="text-neutral-400 w-6 shrink-0">{i + 1}.</span>
<span className="font-mono text-neutral-700 dark:text-neutral-300">{s.node_type}:{s.node_key}</span>
<span className="text-neutral-500 dark:text-neutral-400">{s.status}</span>
{s.result && (s.result as any).would ? <span className="text-purple-600 dark:text-purple-400"> would {String((s.result as any).would)}</span> : null}
{s.error ? <span className="text-red-600 dark:text-red-400">{s.error}</span> : null}
</li>
))}
</ol>
</div>
)}
</div>
</div>
)}
</div>
);
};
@@ -66,7 +66,11 @@ export type FeatureKey =
| 'whatsapp'
// Live Slideshow ("Diashow") — per-event fullscreen kiosk link + presets +
// global watermark settings tab. Strictly opt-in; gates all slideshow UI.
| 'slideshow';
| 'slideshow'
// Workflow / automation engine — admin-configurable visual flows (triggers,
// conditions, branches, loops, approval gates) built on a canvas. Strictly
// opt-in; gates the Workflows admin area and the engine runtime.
| 'workflows';
export type FeatureFlags = Record<FeatureKey, boolean>;
+4
View File
@@ -63,6 +63,8 @@ export interface QuoteSummary {
validUntil: string | null;
eventName: string | null;
eventDate: string | null;
eventType: string | null;
bookingWorkflowId: number | null;
totalAmountMinor: number;
sentAt: string | null;
acceptedAt: string | null;
@@ -178,6 +180,8 @@ export interface QuoteCreatePayload {
validUntil?: string;
eventName?: string;
eventDate?: string;
eventType?: string | null;
bookingWorkflowId?: number | null;
eventTimeStart?: string;
eventTimeEnd?: string;
expectedDurationHours?: number;
+111
View File
@@ -0,0 +1,111 @@
import { api } from '../config/api';
export type WorkflowNodeType =
| 'trigger' | 'condition' | 'branch' | 'loop' | 'wait' | 'action' | 'gate' | 'webhook';
export interface WorkflowNode {
id?: number;
node_key: string;
type: WorkflowNodeType;
config?: Record<string, unknown>;
pos_x?: number;
pos_y?: number;
}
export interface WorkflowEdge {
id?: number;
from_node: string;
from_handle?: string | null;
to_node: string;
label?: string | null;
loop_back?: boolean;
}
export interface WorkflowSummary {
id: number;
name: string;
description?: string | null;
enabled: boolean | number;
version: number;
trigger_type: string;
is_builtin?: boolean | number;
builtin_key?: string | null;
}
export interface WorkflowDetail extends WorkflowSummary {
trigger_config?: Record<string, unknown> | null;
nodes: WorkflowNode[];
edges: WorkflowEdge[];
}
export interface WorkflowRun {
id: number;
workflow_id: number;
version: number;
trigger_event: string;
entity_type?: string | null;
entity_id?: number | null;
status: string;
current_node?: string | null;
started_at?: string;
finished_at?: string | null;
error?: string | null;
}
export interface WorkflowApproval {
id: number;
type: string;
payload: Record<string, unknown>;
created_at: string;
expires_at?: string | null;
run_id: number;
entity_type?: string | null;
entity_id?: number | null;
workflow_id: number;
workflow_name: string;
}
export interface WorkflowSavePayload {
name: string;
description?: string | null;
enabled?: boolean;
trigger_type: string;
trigger_config?: Record<string, unknown> | null;
nodes: WorkflowNode[];
edges: WorkflowEdge[];
}
export const workflowsService = {
list: async (): Promise<WorkflowSummary[]> => (await api.get<WorkflowSummary[]>('/admin/workflows')).data,
get: async (id: number): Promise<WorkflowDetail> => (await api.get<WorkflowDetail>(`/admin/workflows/${id}`)).data,
create: async (payload: WorkflowSavePayload): Promise<{ id: number }> =>
(await api.post<{ id: number }>('/admin/workflows', payload)).data,
update: async (id: number, payload: WorkflowSavePayload): Promise<{ id: number; version: number }> =>
(await api.put<{ id: number; version: number }>(`/admin/workflows/${id}`, payload)).data,
setEnabled: async (id: number, enabled: boolean) =>
(await api.patch(`/admin/workflows/${id}/enabled`, { enabled })).data,
remove: async (id: number) => (await api.delete(`/admin/workflows/${id}`)).data,
runs: async (id: number): Promise<WorkflowRun[]> => (await api.get<WorkflowRun[]>(`/admin/workflows/${id}/runs`)).data,
approvals: async (): Promise<WorkflowApproval[]> => (await api.get<WorkflowApproval[]>('/admin/workflows/approvals')).data,
actApproval: async (id: number, action: 'confirm' | 'deny') =>
(await api.post(`/admin/workflows/approvals/${id}/${action}`)).data,
testRun: async (
id: number,
body: { entityType?: string | null; entityId?: number | null; payload?: Record<string, unknown>; dryRun?: boolean },
): Promise<WorkflowTestResult> => (await api.post<WorkflowTestResult>(`/admin/workflows/${id}/test-run`, body)).data,
};
export interface WorkflowTestStep {
node_key: string;
node_type?: string | null;
status: string;
result?: Record<string, unknown> | null;
error?: string | null;
}
export interface WorkflowTestResult {
runId: number;
dryRun: boolean;
status: string;
steps: WorkflowTestStep[];
}