feat(workflows): hard cutover of gallery-expiry + dunning + pre-event to flows

Seed gallery_expiring / gallery_expired built-ins and make the live automations
flow-owned, with zero feature loss:

- New delegating actions (notify_gallery_expiring / notify_gallery_expired /
  notify_pre_event) call the EXISTING send functions, so the engine path is
  byte-identical to the legacy hourly checker/pass (same templates, recipients,
  variables, dedup, per-event overrides, sent_at idempotency).
- Cutover built-ins (invoice_dunning, gallery_expiring, gallery_expired,
  pre_event_email) now ship ENABLED; booking flows stay disabled (stubs).
- The legacy paths stand down via existence-based isBuiltinFlowPresent guards:
  once a built-in is seeded (flag on) the engine is the single switch — flow
  enabled = it sends, flow disabled = off — so no double-send and reminders/
  expiry emails can still be fully turned off.
- emitDueEventReminders now honours the per-event reminder controls
  (disabled / offset / sent_at) so pre-event timing is faithful; fixed a
  Number(null)===0 offset bug.

Settings UI cutover is gated on the `workflows` flag (default off): when the
engine is live, the dunning reminder schedule (CRM settings) and the pre-event
global toggle (Reminder emails) are replaced with a "now in Workflows" callout;
when it's off, the legacy controls stay so flag-off installs lose nothing. The
late-fee math and installment-trigger defaults stay (fee math / scheduler-owned).

Split/installment invoices intentionally remain scheduler-driven (no flow).
This commit is contained in:
Luca
2026-06-23 15:58:19 +02:00
parent fa7b1bae95
commit 0b6c33e59a
11 changed files with 602 additions and 205 deletions
+10
View File
@@ -4920,6 +4920,11 @@
"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"
},
@@ -5023,6 +5028,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",
+10
View File
@@ -4918,6 +4918,11 @@
"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"
},
@@ -5021,6 +5026,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",
@@ -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';
@@ -83,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;
@@ -244,7 +249,26 @@ 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')}
{/* 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 the 2nd and 3rd reminder')}
<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>
@@ -252,14 +276,18 @@ export const CrmSettingsPage: React.FC = () => {
</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))} />
{!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')}
@@ -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">