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:
@@ -305,6 +305,15 @@ describe('workflow engine', () => {
|
|||||||
const expiredNodes = await db('workflow_nodes').where({ workflow_id: expired.id, version: expired.version });
|
const expiredNodes = await db('workflow_nodes').where({ workflow_id: expired.id, version: expired.version });
|
||||||
expect(expiredNodes.some((n) => JSON.parse(n.config || '{}').action === 'notify_gallery_expired')).toBe(true);
|
expect(expiredNodes.some((n) => JSON.parse(n.config || '{}').action === 'notify_gallery_expired')).toBe(true);
|
||||||
|
|
||||||
|
// Invoice-only booking variant (quote → invoice, no gallery).
|
||||||
|
const invoiceOnly = await db('workflows').where({ builtin_key: 'booking_invoice_only' }).first();
|
||||||
|
expect(invoiceOnly).toBeTruthy();
|
||||||
|
expect(!!invoiceOnly.enabled).toBe(false);
|
||||||
|
expect(invoiceOnly.trigger_type).toBe('quote.accepted');
|
||||||
|
const ioNodes = await db('workflow_nodes').where({ workflow_id: invoiceOnly.id, version: invoiceOnly.version });
|
||||||
|
expect(ioNodes.some((n) => n.type === 'wait')).toBe(false); // no event wait — sends on approval
|
||||||
|
expect(ioNodes.some((n) => JSON.parse(n.config || '{}').action === 'prepare_event')).toBe(false); // no gallery
|
||||||
|
|
||||||
const bookingFull = await db('workflows').where({ builtin_key: 'booking_full' }).first();
|
const bookingFull = await db('workflows').where({ builtin_key: 'booking_full' }).first();
|
||||||
expect(bookingFull).toBeTruthy();
|
expect(bookingFull).toBeTruthy();
|
||||||
expect(!!bookingFull.enabled).toBe(false); // illustrative/stub — stays disabled
|
expect(!!bookingFull.enabled).toBe(false); // illustrative/stub — stays disabled
|
||||||
@@ -390,6 +399,27 @@ describe('workflow engine', () => {
|
|||||||
expect(res.sent).toBe(0);
|
expect(res.sent).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('targetWorkflowId runs only the selected flow, not every matching one', async () => {
|
||||||
|
// Two enabled flows on the same trigger — the quote picks one.
|
||||||
|
const chosen = await makeWorkflow({
|
||||||
|
trigger: 'pick.event', enabled: true,
|
||||||
|
nodes: [{ key: 'c1', type: 'trigger' }, { key: 'c2', type: 'action', config: { action: 'noop' } }],
|
||||||
|
edges: [{ from: 'c1', to: 'c2' }],
|
||||||
|
});
|
||||||
|
const other = await makeWorkflow({
|
||||||
|
trigger: 'pick.event', enabled: true,
|
||||||
|
nodes: [{ key: 'o1', type: 'trigger' }, { key: 'o2', type: 'action', config: { action: 'noop' } }],
|
||||||
|
edges: [{ from: 'o1', to: 'o2' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const runIds = await engine.emitWorkflowEvent('pick.event', { entityType: 'quote', entityId: 99, targetWorkflowId: chosen });
|
||||||
|
expect(runIds.length).toBe(1);
|
||||||
|
const chosenRuns = await db('workflow_runs').where({ workflow_id: chosen, entity_id: 99 });
|
||||||
|
const otherRuns = await db('workflow_runs').where({ workflow_id: other, entity_id: 99 });
|
||||||
|
expect(chosenRuns.length).toBe(1); // only the selected flow ran
|
||||||
|
expect(otherRuns.length).toBe(0); // the other matching flow did NOT
|
||||||
|
});
|
||||||
|
|
||||||
test('admin confirms a gate early; the following wait holds dispatch until its date', async () => {
|
test('admin confirms a gate early; the following wait holds dispatch until its date', async () => {
|
||||||
// The booking pattern: prepare → REVIEW GATE → WAIT(event date) → send. The
|
// The booking pattern: prepare → REVIEW GATE → WAIT(event date) → send. The
|
||||||
// admin can approve at the gate whenever; the run then parks at the wait and
|
// admin can approve at the gate whenever; the run then parks at the wait and
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Migration 147: let a quote pick the booking workflow it runs on acceptance.
|
||||||
|
*
|
||||||
|
* Today quote.accepted fans out to every enabled flow with that trigger. This
|
||||||
|
* column lets the admin choose ONE workflow per quote (e.g. "with contract" vs
|
||||||
|
* "invoice only, no gallery"); emitQuoteEvent passes it as targetWorkflowId so
|
||||||
|
* only the chosen flow runs. Plain nullable integer (not a hard FK) — the emit
|
||||||
|
* re-checks the workflow exists + is enabled + matches the trigger at fire time,
|
||||||
|
* so a deleted/disabled selection just runs nothing.
|
||||||
|
*/
|
||||||
|
exports.up = async function (knex) {
|
||||||
|
if (!(await knex.schema.hasTable('quotes'))) return;
|
||||||
|
if (!(await knex.schema.hasColumn('quotes', 'booking_workflow_id'))) {
|
||||||
|
await knex.schema.alterTable('quotes', (t) => {
|
||||||
|
t.integer('booking_workflow_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function (knex) {
|
||||||
|
if (!(await knex.schema.hasTable('quotes'))) return;
|
||||||
|
if (await knex.schema.hasColumn('quotes', 'booking_workflow_id')) {
|
||||||
|
await knex.schema.alterTable('quotes', (t) => t.dropColumn('booking_workflow_id'));
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -87,6 +87,7 @@ function transformQuote(q) {
|
|||||||
eventName: q.event_name,
|
eventName: q.event_name,
|
||||||
eventDate: q.event_date,
|
eventDate: q.event_date,
|
||||||
eventType: q.event_type ?? null,
|
eventType: q.event_type ?? null,
|
||||||
|
bookingWorkflowId: q.booking_workflow_id ?? null,
|
||||||
eventTimeStart: q.event_time_start,
|
eventTimeStart: q.event_time_start,
|
||||||
eventTimeEnd: q.event_time_end,
|
eventTimeEnd: q.event_time_end,
|
||||||
expectedDurationHours: q.expected_duration_hours == null ? null : Number(q.expected_duration_hours),
|
expectedDurationHours: q.expected_duration_hours == null ? null : Number(q.expected_duration_hours),
|
||||||
@@ -214,6 +215,7 @@ function mapPayloadToService(body) {
|
|||||||
language: 'language', currency: 'currency',
|
language: 'language', currency: 'currency',
|
||||||
issueDate: 'issueDate', validUntil: 'validUntil',
|
issueDate: 'issueDate', validUntil: 'validUntil',
|
||||||
eventName: 'eventName', eventDate: 'eventDate', eventType: 'eventType',
|
eventName: 'eventName', eventDate: 'eventDate', eventType: 'eventType',
|
||||||
|
bookingWorkflowId: 'bookingWorkflowId',
|
||||||
eventTimeStart: 'eventTimeStart', eventTimeEnd: 'eventTimeEnd',
|
eventTimeStart: 'eventTimeStart', eventTimeEnd: 'eventTimeEnd',
|
||||||
expectedDurationHours: 'expectedDurationHours',
|
expectedDurationHours: 'expectedDurationHours',
|
||||||
paymentTermTemplateId: 'paymentTermTemplateId',
|
paymentTermTemplateId: 'paymentTermTemplateId',
|
||||||
|
|||||||
@@ -130,6 +130,29 @@ function buildBookingSimpleGraph() {
|
|||||||
return { nodes, edges };
|
return { nodes, edges };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Booking — quote accepted → prepare invoice → admin review gate → send. No
|
||||||
|
// event/gallery and no wait: the invoice goes out as soon as the admin approves
|
||||||
|
// it. For shoots billed without a delivered online gallery. Same stub caveat as
|
||||||
|
// the other booking flows (prepare_invoice/send_document not yet wired).
|
||||||
|
function buildBookingInvoiceOnlyGraph() {
|
||||||
|
const nodes = [
|
||||||
|
{ node_key: 't', type: 'trigger', config: {}, pos_x: 320, pos_y: 0 },
|
||||||
|
{ node_key: 'prepInvoice', type: 'action', config: { action: 'prepare_invoice' }, pos_x: 320, pos_y: 110 },
|
||||||
|
{ node_key: 'reviewInvoice', type: 'gate', config: { label: 'Review invoice before sending' }, pos_x: 320, pos_y: 220 },
|
||||||
|
{ node_key: 'sendInvoice', type: 'action', config: { action: 'send_document', document: 'invoice', recipient: 'customer' }, pos_x: 320, pos_y: 330 },
|
||||||
|
{ node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 320, pos_y: 440 },
|
||||||
|
{ node_key: 'cancelInvoice', type: 'action', config: { action: 'noop' }, pos_x: 620, pos_y: 220 },
|
||||||
|
];
|
||||||
|
const edges = [
|
||||||
|
{ from_node: 't', to_node: 'prepInvoice' },
|
||||||
|
{ from_node: 'prepInvoice', to_node: 'reviewInvoice' },
|
||||||
|
{ from_node: 'reviewInvoice', from_handle: 'confirm', to_node: 'sendInvoice' },
|
||||||
|
{ from_node: 'reviewInvoice', from_handle: 'deny', to_node: 'cancelInvoice' },
|
||||||
|
{ from_node: 'sendInvoice', to_node: 'done' },
|
||||||
|
];
|
||||||
|
return { nodes, edges };
|
||||||
|
}
|
||||||
|
|
||||||
// Pre-event reminder — fired by the scheduler at event_date − daysBefore (see
|
// Pre-event reminder — fired by the scheduler at event_date − daysBefore (see
|
||||||
// emitDueEventReminders). The notify_pre_event action DELEGATES to
|
// emitDueEventReminders). The notify_pre_event action DELEGATES to
|
||||||
// eventReminderService.sendReminderForEvent, so the email is byte-identical to
|
// eventReminderService.sendReminderForEvent, so the email is byte-identical to
|
||||||
@@ -290,6 +313,20 @@ const BUILTINS = [
|
|||||||
+ 'full booking flow; disabled by default.',
|
+ 'full booking flow; disabled by default.',
|
||||||
build: async () => buildBookingSimpleGraph(),
|
build: async () => buildBookingSimpleGraph(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'booking_invoice_only',
|
||||||
|
version: 1,
|
||||||
|
enabled: false,
|
||||||
|
name: 'Booking — quote → invoice, no gallery (built-in)',
|
||||||
|
trigger_type: 'quote.accepted',
|
||||||
|
trigger_config: {},
|
||||||
|
description:
|
||||||
|
'For shoots billed without an online gallery: on quote acceptance prepare the invoice, the '
|
||||||
|
+ 'admin reviews + approves it, and it is sent right away (no event/gallery, no wait). Pick '
|
||||||
|
+ 'this flow per quote via the booking-workflow selector. Same review-before-send rule and '
|
||||||
|
+ 'stub caveat as the other booking flows; disabled by default.',
|
||||||
|
build: async () => buildBookingInvoiceOnlyGraph(),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
let booted = false;
|
let booted = false;
|
||||||
|
|||||||
@@ -587,6 +587,10 @@ async function createQuote(payload, adminId) {
|
|||||||
if (payload.eventType !== undefined && await hasColumnCached('quotes', 'event_type')) {
|
if (payload.eventType !== undefined && await hasColumnCached('quotes', 'event_type')) {
|
||||||
row.event_type = payload.eventType ? String(payload.eventType).slice(0, 64) : null;
|
row.event_type = payload.eventType ? String(payload.eventType).slice(0, 64) : null;
|
||||||
}
|
}
|
||||||
|
// Migration 147 — the booking workflow this quote runs on acceptance.
|
||||||
|
if (payload.bookingWorkflowId !== undefined && await hasColumnCached('quotes', 'booking_workflow_id')) {
|
||||||
|
row.booking_workflow_id = payload.bookingWorkflowId || null;
|
||||||
|
}
|
||||||
const inserted = await trx('quotes').insert(row).returning('id');
|
const inserted = await trx('quotes').insert(row).returning('id');
|
||||||
const quoteId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
const quoteId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
|
||||||
|
|
||||||
@@ -719,6 +723,10 @@ async function updateQuote(id, payload, adminId) {
|
|||||||
if (Object.prototype.hasOwnProperty.call(payload, 'eventType') && await hasColumnCached('quotes', 'event_type')) {
|
if (Object.prototype.hasOwnProperty.call(payload, 'eventType') && await hasColumnCached('quotes', 'event_type')) {
|
||||||
updates.event_type = payload.eventType ? String(payload.eventType).slice(0, 64) : null;
|
updates.event_type = payload.eventType ? String(payload.eventType).slice(0, 64) : null;
|
||||||
}
|
}
|
||||||
|
// Migration 147 — selected booking workflow.
|
||||||
|
if (Object.prototype.hasOwnProperty.call(payload, 'bookingWorkflowId') && await hasColumnCached('quotes', 'booking_workflow_id')) {
|
||||||
|
updates.booking_workflow_id = payload.bookingWorkflowId || null;
|
||||||
|
}
|
||||||
await trx('quotes').where({ id }).update(updates);
|
await trx('quotes').where({ id }).update(updates);
|
||||||
|
|
||||||
// When linked to a project, cascade across the deal lineage so the linked
|
// When linked to a project, cascade across the deal lineage so the linked
|
||||||
@@ -1067,9 +1075,16 @@ async function emitQuoteEvent(quote, status) {
|
|||||||
const c = await db('customer_accounts').where({ id: quote.customer_account_id }).first();
|
const c = await db('customer_accounts').where({ id: quote.customer_account_id }).first();
|
||||||
customerEmail = c?.email || null;
|
customerEmail = c?.email || null;
|
||||||
}
|
}
|
||||||
|
// On acceptance, if the admin picked a booking workflow on the quote, run
|
||||||
|
// ONLY that flow (instead of fanning out to every enabled quote.accepted
|
||||||
|
// flow). Other statuses keep the normal fan-out.
|
||||||
|
const targetWorkflowId = (status === 'accepted' && quote.booking_workflow_id)
|
||||||
|
? quote.booking_workflow_id
|
||||||
|
: null;
|
||||||
await require('./workflows').emitWorkflowEvent(`quote.${status}`, {
|
await require('./workflows').emitWorkflowEvent(`quote.${status}`, {
|
||||||
entityType: 'quote',
|
entityType: 'quote',
|
||||||
entityId: quote.id,
|
entityId: quote.id,
|
||||||
|
targetWorkflowId,
|
||||||
payload: {
|
payload: {
|
||||||
quoteId: quote.id,
|
quoteId: quote.id,
|
||||||
quoteNumber: quote.quote_number,
|
quoteNumber: quote.quote_number,
|
||||||
@@ -1077,7 +1092,9 @@ async function emitQuoteEvent(quote, status) {
|
|||||||
customerEmail,
|
customerEmail,
|
||||||
eventName: quote.event_name || null,
|
eventName: quote.event_name || null,
|
||||||
eventDate: quote.event_date || null,
|
eventDate: quote.event_date || null,
|
||||||
|
eventType: quote.event_type || null,
|
||||||
totalMinor: quote.total_amount_minor ?? null,
|
totalMinor: quote.total_amount_minor ?? null,
|
||||||
|
bookingWorkflowId: quote.booking_workflow_id || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (_) { /* best-effort */ }
|
} catch (_) { /* best-effort */ }
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ async function resumeRun(runId, { decisionHandle = null } = {}) {
|
|||||||
* workflow (idempotent via dedup_key) and starts it. Never throws — safe to
|
* workflow (idempotent via dedup_key) and starts it. Never throws — safe to
|
||||||
* call after a caller's commit. Fails CLOSED if the flag system is unavailable.
|
* call after a caller's commit. Fails CLOSED if the flag system is unavailable.
|
||||||
*/
|
*/
|
||||||
async function emitWorkflowEvent(triggerType, { entityType = null, entityId = null, payload = {} } = {}) {
|
async function emitWorkflowEvent(triggerType, { entityType = null, entityId = null, payload = {}, targetWorkflowId = null } = {}) {
|
||||||
try {
|
try {
|
||||||
const { isFeatureEnabled } = require('../../middleware/requireFeatureFlag');
|
const { isFeatureEnabled } = require('../../middleware/requireFeatureFlag');
|
||||||
let enabled = false;
|
let enabled = false;
|
||||||
@@ -250,7 +250,13 @@ async function emitWorkflowEvent(triggerType, { entityType = null, entityId = nu
|
|||||||
}
|
}
|
||||||
if (!enabled) return [];
|
if (!enabled) return [];
|
||||||
|
|
||||||
const workflows = await db('workflows').where({ enabled: true, trigger_type: triggerType });
|
// targetWorkflowId restricts the fan-out to a SINGLE chosen flow — used when
|
||||||
|
// the entity explicitly selected which flow to run (e.g. a quote picks its
|
||||||
|
// booking workflow). Still gated on enabled + matching trigger_type, so a
|
||||||
|
// disabled/mismatched selection simply runs nothing.
|
||||||
|
const q = db('workflows').where({ enabled: true, trigger_type: triggerType });
|
||||||
|
if (targetWorkflowId != null) q.where({ id: targetWorkflowId });
|
||||||
|
const workflows = await q;
|
||||||
const runIds = [];
|
const runIds = [];
|
||||||
for (const wf of workflows) {
|
for (const wf of workflows) {
|
||||||
const tcfg = parseJson(wf.trigger_config, {});
|
const tcfg = parseJson(wf.trigger_config, {});
|
||||||
|
|||||||
@@ -5236,6 +5236,10 @@
|
|||||||
"eventType": "Anlasstyp",
|
"eventType": "Anlasstyp",
|
||||||
"eventTypeNone": "— Standard verwenden —",
|
"eventTypeNone": "— Standard verwenden —",
|
||||||
"eventTypeHint": "Wird für den Anlass verwendet, der bei Annahme dieses Angebots erstellt wird.",
|
"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)",
|
"eventSection": "Anlass (optional)",
|
||||||
"eventTimeEnd": "Ende",
|
"eventTimeEnd": "Ende",
|
||||||
"eventTimeStart": "Beginn",
|
"eventTimeStart": "Beginn",
|
||||||
|
|||||||
@@ -5234,6 +5234,10 @@
|
|||||||
"eventType": "Event type",
|
"eventType": "Event type",
|
||||||
"eventTypeNone": "— Use default —",
|
"eventTypeNone": "— Use default —",
|
||||||
"eventTypeHint": "Used for the event created when this quote is accepted.",
|
"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)",
|
"eventSection": "Event (optional)",
|
||||||
"eventTimeEnd": "End",
|
"eventTimeEnd": "End",
|
||||||
"eventTimeStart": "Start",
|
"eventTimeStart": "Start",
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ import { VatRateSelect } from '../../../components/admin/VatRateSelect';
|
|||||||
import { accountingService } from '../../../services/accounting.service';
|
import { accountingService } from '../../../services/accounting.service';
|
||||||
import { vatCodesService } from '../../../services/vatCodes.service';
|
import { vatCodesService } from '../../../services/vatCodes.service';
|
||||||
import { eventTypesService } from '../../../services/eventTypes.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 { InstallmentsPanel } from '../../../components/admin/InstallmentsPanel';
|
||||||
import { customerAdminService } from '../../../services/customerAdmin.service';
|
import { customerAdminService } from '../../../services/customerAdmin.service';
|
||||||
import { userManagementService } from '../../../services/userManagement.service';
|
import { userManagementService } from '../../../services/userManagement.service';
|
||||||
@@ -49,6 +51,7 @@ interface FormState {
|
|||||||
eventName: string;
|
eventName: string;
|
||||||
eventDate: string;
|
eventDate: string;
|
||||||
eventType: string;
|
eventType: string;
|
||||||
|
bookingWorkflowId: number | null;
|
||||||
eventTimeStart: string;
|
eventTimeStart: string;
|
||||||
eventTimeEnd: string;
|
eventTimeEnd: string;
|
||||||
expectedDurationHours: string;
|
expectedDurationHours: string;
|
||||||
@@ -86,6 +89,7 @@ const empty: FormState = {
|
|||||||
eventName: '',
|
eventName: '',
|
||||||
eventDate: '',
|
eventDate: '',
|
||||||
eventType: '',
|
eventType: '',
|
||||||
|
bookingWorkflowId: null,
|
||||||
eventTimeStart: '',
|
eventTimeStart: '',
|
||||||
eventTimeEnd: '',
|
eventTimeEnd: '',
|
||||||
expectedDurationHours: '',
|
expectedDurationHours: '',
|
||||||
@@ -119,6 +123,7 @@ function buildPayload(f: FormState): QuoteCreatePayload {
|
|||||||
eventName: f.eventName || undefined,
|
eventName: f.eventName || undefined,
|
||||||
eventDate: f.eventDate || undefined,
|
eventDate: f.eventDate || undefined,
|
||||||
eventType: f.eventType || null,
|
eventType: f.eventType || null,
|
||||||
|
bookingWorkflowId: f.bookingWorkflowId,
|
||||||
eventTimeStart: f.eventTimeStart || undefined,
|
eventTimeStart: f.eventTimeStart || undefined,
|
||||||
eventTimeEnd: f.eventTimeEnd || undefined,
|
eventTimeEnd: f.eventTimeEnd || undefined,
|
||||||
expectedDurationHours: f.expectedDurationHours ? Number(f.expectedDurationHours) : 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
|
// Active event types — drives the event-type dropdown (and the type of the
|
||||||
// event this quote converts into).
|
// event this quote converts into).
|
||||||
const { data: eventTypes = [] } = useQuery({ queryKey: ['event-types-active'], queryFn: () => eventTypesService.getActiveEventTypes() });
|
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);
|
const didSeedVatRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isEdit || didSeedVatRef.current) return;
|
if (isEdit || didSeedVatRef.current) return;
|
||||||
@@ -239,6 +257,7 @@ export const QuoteEditorPage: React.FC = () => {
|
|||||||
eventName: q.eventName || '',
|
eventName: q.eventName || '',
|
||||||
eventDate: q.eventDate || '',
|
eventDate: q.eventDate || '',
|
||||||
eventType: q.eventType || '',
|
eventType: q.eventType || '',
|
||||||
|
bookingWorkflowId: q.bookingWorkflowId ?? null,
|
||||||
eventTimeStart: q.eventTimeStart || '',
|
eventTimeStart: q.eventTimeStart || '',
|
||||||
eventTimeEnd: q.eventTimeEnd || '',
|
eventTimeEnd: q.eventTimeEnd || '',
|
||||||
expectedDurationHours: q.expectedDurationHours?.toString() || '',
|
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.')}
|
{t('quotes.field.eventTypeHint', 'Used for the event created when this quote is accepted.')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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}
|
<LocalizedDateInput label={t('quotes.field.eventDate', 'Event date') as string} value={form.eventDate}
|
||||||
onChange={(iso) => setForm((f) => ({ ...f, eventDate: iso }))} />
|
onChange={(iso) => setForm((f) => ({ ...f, eventDate: iso }))} />
|
||||||
<TimeField label={t('quotes.field.eventTimeStart', 'Start time') as string} value={form.eventTimeStart}
|
<TimeField label={t('quotes.field.eventTimeStart', 'Start time') as string} value={form.eventTimeStart}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export interface QuoteSummary {
|
|||||||
eventName: string | null;
|
eventName: string | null;
|
||||||
eventDate: string | null;
|
eventDate: string | null;
|
||||||
eventType: string | null;
|
eventType: string | null;
|
||||||
|
bookingWorkflowId: number | null;
|
||||||
totalAmountMinor: number;
|
totalAmountMinor: number;
|
||||||
sentAt: string | null;
|
sentAt: string | null;
|
||||||
acceptedAt: string | null;
|
acceptedAt: string | null;
|
||||||
@@ -180,6 +181,7 @@ export interface QuoteCreatePayload {
|
|||||||
eventName?: string;
|
eventName?: string;
|
||||||
eventDate?: string;
|
eventDate?: string;
|
||||||
eventType?: string | null;
|
eventType?: string | null;
|
||||||
|
bookingWorkflowId?: number | null;
|
||||||
eventTimeStart?: string;
|
eventTimeStart?: string;
|
||||||
eventTimeEnd?: string;
|
eventTimeEnd?: string;
|
||||||
expectedDurationHours?: number;
|
expectedDurationHours?: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user