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:
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user