feat(workflows): per-quote booking-workflow picker + quote→invoice (no gallery) built-in

A quote can now choose which flow runs on acceptance instead of every enabled
quote.accepted flow firing. Migration 147 adds quotes.booking_workflow_id; the
editor shows a "Booking workflow (on acceptance)" dropdown listing the
quote.accepted flows (workflow-engine flag only); emitQuoteEvent passes it as
the new emitWorkflowEvent targetWorkflowId so ONLY the picked flow runs (still
gated on enabled + trigger match → a disabled/None selection runs nothing).

Adds the booking_invoice_only built-in (quote.accepted → prepare invoice →
review gate → send; no event/gallery, no wait), the variant requested for
shoots billed without an online gallery. Disabled stub like the other booking
flows until the prepare_*/send_document cutover.

Tests: targetWorkflowId runs only the selected flow; invoice-only built-in has
no wait/prepare_event.
This commit is contained in:
Luca
2026-06-23 23:15:31 +02:00
parent eec262b0a7
commit d14f1d850c
10 changed files with 170 additions and 2 deletions
+4
View File
@@ -5236,6 +5236,10 @@
"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",
+4
View File
@@ -5234,6 +5234,10 @@
"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",
@@ -29,6 +29,8 @@ 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';
@@ -49,6 +51,7 @@ interface FormState {
eventName: string;
eventDate: string;
eventType: string;
bookingWorkflowId: number | null;
eventTimeStart: string;
eventTimeEnd: string;
expectedDurationHours: string;
@@ -86,6 +89,7 @@ const empty: FormState = {
eventName: '',
eventDate: '',
eventType: '',
bookingWorkflowId: null,
eventTimeStart: '',
eventTimeEnd: '',
expectedDurationHours: '',
@@ -119,6 +123,7 @@ function buildPayload(f: FormState): QuoteCreatePayload {
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,
@@ -204,6 +209,19 @@ export const QuoteEditorPage: React.FC = () => {
// 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;
@@ -239,6 +257,7 @@ export const QuoteEditorPage: React.FC = () => {
eventName: q.eventName || '',
eventDate: q.eventDate || '',
eventType: q.eventType || '',
bookingWorkflowId: q.bookingWorkflowId ?? null,
eventTimeStart: q.eventTimeStart || '',
eventTimeEnd: q.eventTimeEnd || '',
expectedDurationHours: q.expectedDurationHours?.toString() || '',
@@ -557,6 +576,28 @@ export const QuoteEditorPage: React.FC = () => {
{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}
+2
View File
@@ -64,6 +64,7 @@ export interface QuoteSummary {
eventName: string | null;
eventDate: string | null;
eventType: string | null;
bookingWorkflowId: number | null;
totalAmountMinor: number;
sentAt: string | null;
acceptedAt: string | null;
@@ -180,6 +181,7 @@ export interface QuoteCreatePayload {
eventName?: string;
eventDate?: string;
eventType?: string | null;
bookingWorkflowId?: number | null;
eventTimeStart?: string;
eventTimeEnd?: string;
expectedDurationHours?: number;