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:
@@ -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",
|
||||
|
||||
@@ -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 event’s 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 event’s 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">
|
||||
|
||||
Reference in New Issue
Block a user