From f78671fc6c8d234be4dee87b45aae9d280a3a6f7 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:52:50 +0200 Subject: [PATCH] =?UTF-8?q?feat(crm):=20event-type=20dropdown=20on=20quote?= =?UTF-8?q?s;=20quote=E2=86=92event=20uses=20it=20(no=20more=20hardcoded?= =?UTF-8?q?=20'wedding')?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quotes now carry an event type (migration 146: quotes.event_type, the event_types.slug_prefix), chosen from the active event-types catalog in the quote editor's Event section. convertToEvent reads it instead of the unconditional hardcoded 'wedding': quote.event_type → crm_default_event_type setting → 'wedding' as last-resort seeded fallback. When the booking flow's prepare_event is wired, it reads the same field. Backend: createQuote/updateQuote persist event_type (hasColumn-guarded); adminQuotes route accepts + returns eventType. Frontend: FormState + payload + load + a catalog-sourced dropdown ("— Use default —"); EN/DE strings. --- .../core/146_add_quote_event_type.js | 25 ++++++++++++++++++ backend/src/routes/adminQuotes.js | 3 ++- backend/src/services/quoteService.js | 18 ++++++++++++- frontend/src/i18n/locales/de.json | 3 +++ frontend/src/i18n/locales/en.json | 3 +++ .../pages/admin/quotes/QuoteEditorPage.tsx | 26 +++++++++++++++++++ frontend/src/services/quotes.service.ts | 2 ++ 7 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 backend/migrations/core/146_add_quote_event_type.js diff --git a/backend/migrations/core/146_add_quote_event_type.js b/backend/migrations/core/146_add_quote_event_type.js new file mode 100644 index 00000000..bb4b7637 --- /dev/null +++ b/backend/migrations/core/146_add_quote_event_type.js @@ -0,0 +1,25 @@ +/** + * Migration 146: carry an event type on the quote. + * + * Quotes already snapshot event_name + event_date, but not the TYPE. Without it + * the quote→event conversion (convertToEvent) had to hardcode 'wedding'. This + * column lets the admin pick the type on the quote (from the event_types + * catalog, stored as its slug_prefix — same shape as events.event_type), so the + * conversion / booking flow's prepare_event can carry it through. Nullable: old + * quotes and the "didn't pick one" case fall back to a configurable default. + */ +exports.up = async function (knex) { + if (!(await knex.schema.hasTable('quotes'))) return; + if (!(await knex.schema.hasColumn('quotes', 'event_type'))) { + await knex.schema.alterTable('quotes', (t) => { + t.string('event_type', 64); + }); + } +}; + +exports.down = async function (knex) { + if (!(await knex.schema.hasTable('quotes'))) return; + if (await knex.schema.hasColumn('quotes', 'event_type')) { + await knex.schema.alterTable('quotes', (t) => t.dropColumn('event_type')); + } +}; diff --git a/backend/src/routes/adminQuotes.js b/backend/src/routes/adminQuotes.js index a130f4c1..70d454e7 100644 --- a/backend/src/routes/adminQuotes.js +++ b/backend/src/routes/adminQuotes.js @@ -86,6 +86,7 @@ function transformQuote(q) { validUntil: q.valid_until, eventName: q.event_name, eventDate: q.event_date, + eventType: q.event_type ?? null, eventTimeStart: q.event_time_start, eventTimeEnd: q.event_time_end, expectedDurationHours: q.expected_duration_hours == null ? null : Number(q.expected_duration_hours), @@ -212,7 +213,7 @@ function mapPayloadToService(body) { customerAccountId: 'customerAccountId', language: 'language', currency: 'currency', issueDate: 'issueDate', validUntil: 'validUntil', - eventName: 'eventName', eventDate: 'eventDate', + eventName: 'eventName', eventDate: 'eventDate', eventType: 'eventType', eventTimeStart: 'eventTimeStart', eventTimeEnd: 'eventTimeEnd', expectedDurationHours: 'expectedDurationHours', paymentTermTemplateId: 'paymentTermTemplateId', diff --git a/backend/src/services/quoteService.js b/backend/src/services/quoteService.js index 8f1a1db2..99a9a510 100644 --- a/backend/src/services/quoteService.js +++ b/backend/src/services/quoteService.js @@ -563,6 +563,11 @@ async function createQuote(payload, adminId) { if (payload.vatCode !== undefined && await hasColumnCached('quotes', 'vat_code')) { row.vat_code = payload.vatCode ? String(payload.vatCode).slice(0, 16) : null; } + // Migration 146 — event type (event_types.slug_prefix). Drives the type of + // the event the quote converts into, instead of the old hardcoded 'wedding'. + if (payload.eventType !== undefined && await hasColumnCached('quotes', 'event_type')) { + row.event_type = payload.eventType ? String(payload.eventType).slice(0, 64) : null; + } const inserted = await trx('quotes').insert(row).returning('id'); const quoteId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; @@ -691,6 +696,10 @@ async function updateQuote(id, payload, adminId) { if (Object.prototype.hasOwnProperty.call(payload, 'vatCode') && await hasColumnCached('quotes', 'vat_code')) { updates.vat_code = payload.vatCode ? String(payload.vatCode).slice(0, 16) : null; } + // Migration 146 — 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; + } await trx('quotes').where({ id }).update(updates); // When linked to a project, cascade across the deal lineage so the linked @@ -1482,6 +1491,13 @@ async function convertToEvent(quoteId, adminId, options = {}) { const customerEmail = customer.email || `${quote.quote_number.toLowerCase()}@picpeak.local`; const adminEmail = adminRow?.email || customer.email || 'admin@picpeak.local'; + // Event type for the new event: the type chosen on the quote (migration 146), + // else a configurable org default, else 'wedding' as the last-resort seeded + // type. Replaces the old unconditional hardcoded 'wedding'. + const eventType = (quote.event_type && String(quote.event_type).trim()) + || (await getAppSetting('crm_default_event_type')) + || 'wedding'; + // Each candidate column is paired with the value we'd write. We // ask the DB which columns exist and only keep the matching pairs // — bullet-proof against schema drift in either direction. @@ -1496,7 +1512,7 @@ async function convertToEvent(quoteId, adminId, options = {}) { customer_email: customerEmail, customer_phone: customer.phone, admin_email: adminEmail, - event_type: 'wedding', + event_type: eventType, password_hash: placeholder, share_link: shareLink, share_token: shareLink, diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index d642b2a2..cdfe49c4 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -5233,6 +5233,9 @@ "eventHelp": "Wird auf den Vertrag übernommen und an jeden daraus erzeugten Anlass / jede Rechnung weitergegeben. Setzen Sie dies, damit Kundenportal und Mahn-E-Mails die richtige Bezeichnung \"Hochzeit Doe / Müller\" anzeigen.", "eventName": "Anlassname", "eventNamePlaceholder": "z. B. Hochzeit Doe / Müller", + "eventType": "Anlasstyp", + "eventTypeNone": "— Standard verwenden —", + "eventTypeHint": "Wird für den Anlass verwendet, der bei Annahme dieses Angebots erstellt wird.", "eventSection": "Anlass (optional)", "eventTimeEnd": "Ende", "eventTimeStart": "Beginn", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 17137338..7640e057 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -5231,6 +5231,9 @@ "eventHelp": "Snapshotted onto the contract and propagated to any event / invoice generated from it. Set this so the customer portal and dunning emails show the right \"Wedding Doe / Müller\" label.", "eventName": "Event name", "eventNamePlaceholder": "e.g. Wedding Doe / Müller", + "eventType": "Event type", + "eventTypeNone": "— Use default —", + "eventTypeHint": "Used for the event created when this quote is accepted.", "eventSection": "Event (optional)", "eventTimeEnd": "End", "eventTimeStart": "Start", diff --git a/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx b/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx index 4e3ac177..eb290eca 100644 --- a/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx +++ b/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx @@ -28,6 +28,7 @@ import { ProjectSelect } from '../../../components/admin/ProjectSelect'; 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 { InstallmentsPanel } from '../../../components/admin/InstallmentsPanel'; import { customerAdminService } from '../../../services/customerAdmin.service'; import { userManagementService } from '../../../services/userManagement.service'; @@ -47,6 +48,7 @@ interface FormState { validUntil: string; eventName: string; eventDate: string; + eventType: string; eventTimeStart: string; eventTimeEnd: string; expectedDurationHours: string; @@ -83,6 +85,7 @@ const empty: FormState = { validUntil: '', eventName: '', eventDate: '', + eventType: '', eventTimeStart: '', eventTimeEnd: '', expectedDurationHours: '', @@ -115,6 +118,7 @@ function buildPayload(f: FormState): QuoteCreatePayload { validUntil: f.validUntil || undefined, eventName: f.eventName || undefined, eventDate: f.eventDate || undefined, + eventType: f.eventType || null, eventTimeStart: f.eventTimeStart || undefined, eventTimeEnd: f.eventTimeEnd || undefined, expectedDurationHours: f.expectedDurationHours ? Number(f.expectedDurationHours) : undefined, @@ -197,6 +201,9 @@ export const QuoteEditorPage: React.FC = () => { // convert to) don't silently start at 0%. Never clobbers a touched value. const { data: acctSettings } = useQuery({ queryKey: ['accounting-settings'], queryFn: () => accountingService.getSettings() }); const { data: outputVatCodes } = useQuery({ queryKey: ['vat-codes', 'output'], queryFn: () => vatCodesService.listOutput() }); + // 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() }); const didSeedVatRef = useRef(false); useEffect(() => { if (isEdit || didSeedVatRef.current) return; @@ -231,6 +238,7 @@ export const QuoteEditorPage: React.FC = () => { validUntil: q.validUntil || '', eventName: q.eventName || '', eventDate: q.eventDate || '', + eventType: q.eventType || '', eventTimeStart: q.eventTimeStart || '', eventTimeEnd: q.eventTimeEnd || '', expectedDurationHours: q.expectedDurationHours?.toString() || '', @@ -531,6 +539,24 @@ export const QuoteEditorPage: React.FC = () => {
+ {t('quotes.field.eventTypeHint', 'Used for the event created when this quote is accepted.')} +
+