feat(crm): event-type dropdown on quotes; quote→event uses it (no more hardcoded 'wedding')
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.
This commit is contained in:
@@ -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'));
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -86,6 +86,7 @@ function transformQuote(q) {
|
|||||||
validUntil: q.valid_until,
|
validUntil: q.valid_until,
|
||||||
eventName: q.event_name,
|
eventName: q.event_name,
|
||||||
eventDate: q.event_date,
|
eventDate: q.event_date,
|
||||||
|
eventType: q.event_type ?? 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),
|
||||||
@@ -212,7 +213,7 @@ function mapPayloadToService(body) {
|
|||||||
customerAccountId: 'customerAccountId',
|
customerAccountId: 'customerAccountId',
|
||||||
language: 'language', currency: 'currency',
|
language: 'language', currency: 'currency',
|
||||||
issueDate: 'issueDate', validUntil: 'validUntil',
|
issueDate: 'issueDate', validUntil: 'validUntil',
|
||||||
eventName: 'eventName', eventDate: 'eventDate',
|
eventName: 'eventName', eventDate: 'eventDate', eventType: 'eventType',
|
||||||
eventTimeStart: 'eventTimeStart', eventTimeEnd: 'eventTimeEnd',
|
eventTimeStart: 'eventTimeStart', eventTimeEnd: 'eventTimeEnd',
|
||||||
expectedDurationHours: 'expectedDurationHours',
|
expectedDurationHours: 'expectedDurationHours',
|
||||||
paymentTermTemplateId: 'paymentTermTemplateId',
|
paymentTermTemplateId: 'paymentTermTemplateId',
|
||||||
|
|||||||
@@ -563,6 +563,11 @@ async function createQuote(payload, adminId) {
|
|||||||
if (payload.vatCode !== undefined && await hasColumnCached('quotes', 'vat_code')) {
|
if (payload.vatCode !== undefined && await hasColumnCached('quotes', 'vat_code')) {
|
||||||
row.vat_code = payload.vatCode ? String(payload.vatCode).slice(0, 16) : null;
|
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 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];
|
||||||
|
|
||||||
@@ -691,6 +696,10 @@ async function updateQuote(id, payload, adminId) {
|
|||||||
if (Object.prototype.hasOwnProperty.call(payload, 'vatCode') && await hasColumnCached('quotes', 'vat_code')) {
|
if (Object.prototype.hasOwnProperty.call(payload, 'vatCode') && await hasColumnCached('quotes', 'vat_code')) {
|
||||||
updates.vat_code = payload.vatCode ? String(payload.vatCode).slice(0, 16) : null;
|
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);
|
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
|
||||||
@@ -1482,6 +1491,13 @@ async function convertToEvent(quoteId, adminId, options = {}) {
|
|||||||
const customerEmail = customer.email || `${quote.quote_number.toLowerCase()}@picpeak.local`;
|
const customerEmail = customer.email || `${quote.quote_number.toLowerCase()}@picpeak.local`;
|
||||||
const adminEmail = adminRow?.email || customer.email || '[email protected]';
|
const adminEmail = adminRow?.email || customer.email || '[email protected]';
|
||||||
|
|
||||||
|
// 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
|
// Each candidate column is paired with the value we'd write. We
|
||||||
// ask the DB which columns exist and only keep the matching pairs
|
// ask the DB which columns exist and only keep the matching pairs
|
||||||
// — bullet-proof against schema drift in either direction.
|
// — bullet-proof against schema drift in either direction.
|
||||||
@@ -1496,7 +1512,7 @@ async function convertToEvent(quoteId, adminId, options = {}) {
|
|||||||
customer_email: customerEmail,
|
customer_email: customerEmail,
|
||||||
customer_phone: customer.phone,
|
customer_phone: customer.phone,
|
||||||
admin_email: adminEmail,
|
admin_email: adminEmail,
|
||||||
event_type: 'wedding',
|
event_type: eventType,
|
||||||
password_hash: placeholder,
|
password_hash: placeholder,
|
||||||
share_link: shareLink,
|
share_link: shareLink,
|
||||||
share_token: shareLink,
|
share_token: shareLink,
|
||||||
|
|||||||
@@ -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.",
|
"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",
|
"eventName": "Anlassname",
|
||||||
"eventNamePlaceholder": "z. B. Hochzeit Doe / Müller",
|
"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)",
|
"eventSection": "Anlass (optional)",
|
||||||
"eventTimeEnd": "Ende",
|
"eventTimeEnd": "Ende",
|
||||||
"eventTimeStart": "Beginn",
|
"eventTimeStart": "Beginn",
|
||||||
|
|||||||
@@ -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.",
|
"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",
|
"eventName": "Event name",
|
||||||
"eventNamePlaceholder": "e.g. Wedding Doe / Müller",
|
"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)",
|
"eventSection": "Event (optional)",
|
||||||
"eventTimeEnd": "End",
|
"eventTimeEnd": "End",
|
||||||
"eventTimeStart": "Start",
|
"eventTimeStart": "Start",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { ProjectSelect } from '../../../components/admin/ProjectSelect';
|
|||||||
import { VatRateSelect } from '../../../components/admin/VatRateSelect';
|
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 { 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';
|
||||||
@@ -47,6 +48,7 @@ interface FormState {
|
|||||||
validUntil: string;
|
validUntil: string;
|
||||||
eventName: string;
|
eventName: string;
|
||||||
eventDate: string;
|
eventDate: string;
|
||||||
|
eventType: string;
|
||||||
eventTimeStart: string;
|
eventTimeStart: string;
|
||||||
eventTimeEnd: string;
|
eventTimeEnd: string;
|
||||||
expectedDurationHours: string;
|
expectedDurationHours: string;
|
||||||
@@ -83,6 +85,7 @@ const empty: FormState = {
|
|||||||
validUntil: '',
|
validUntil: '',
|
||||||
eventName: '',
|
eventName: '',
|
||||||
eventDate: '',
|
eventDate: '',
|
||||||
|
eventType: '',
|
||||||
eventTimeStart: '',
|
eventTimeStart: '',
|
||||||
eventTimeEnd: '',
|
eventTimeEnd: '',
|
||||||
expectedDurationHours: '',
|
expectedDurationHours: '',
|
||||||
@@ -115,6 +118,7 @@ function buildPayload(f: FormState): QuoteCreatePayload {
|
|||||||
validUntil: f.validUntil || undefined,
|
validUntil: f.validUntil || undefined,
|
||||||
eventName: f.eventName || undefined,
|
eventName: f.eventName || undefined,
|
||||||
eventDate: f.eventDate || undefined,
|
eventDate: f.eventDate || undefined,
|
||||||
|
eventType: f.eventType || null,
|
||||||
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,
|
||||||
@@ -197,6 +201,9 @@ export const QuoteEditorPage: React.FC = () => {
|
|||||||
// convert to) don't silently start at 0%. Never clobbers a touched value.
|
// 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: acctSettings } = useQuery({ queryKey: ['accounting-settings'], queryFn: () => accountingService.getSettings() });
|
||||||
const { data: outputVatCodes } = useQuery({ queryKey: ['vat-codes', 'output'], queryFn: () => vatCodesService.listOutput() });
|
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);
|
const didSeedVatRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isEdit || didSeedVatRef.current) return;
|
if (isEdit || didSeedVatRef.current) return;
|
||||||
@@ -231,6 +238,7 @@ export const QuoteEditorPage: React.FC = () => {
|
|||||||
validUntil: q.validUntil || '',
|
validUntil: q.validUntil || '',
|
||||||
eventName: q.eventName || '',
|
eventName: q.eventName || '',
|
||||||
eventDate: q.eventDate || '',
|
eventDate: q.eventDate || '',
|
||||||
|
eventType: q.eventType || '',
|
||||||
eventTimeStart: q.eventTimeStart || '',
|
eventTimeStart: q.eventTimeStart || '',
|
||||||
eventTimeEnd: q.eventTimeEnd || '',
|
eventTimeEnd: q.eventTimeEnd || '',
|
||||||
expectedDurationHours: q.expectedDurationHours?.toString() || '',
|
expectedDurationHours: q.expectedDurationHours?.toString() || '',
|
||||||
@@ -531,6 +539,24 @@ export const QuoteEditorPage: React.FC = () => {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<Input label={t('quotes.field.eventName', 'Event name') as string} value={form.eventName}
|
<Input label={t('quotes.field.eventName', 'Event name') as string} value={form.eventName}
|
||||||
onChange={(e) => setForm((f) => ({ ...f, eventName: e.target.value }))} />
|
onChange={(e) => setForm((f) => ({ ...f, eventName: e.target.value }))} />
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||||
|
{t('quotes.field.eventType', 'Event type')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={form.eventType}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, eventType: e.target.value }))}
|
||||||
|
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.eventTypeNone', '— Use default —')}</option>
|
||||||
|
{eventTypes.map((et) => (
|
||||||
|
<option key={et.id} value={et.slug_prefix}>{et.emoji ? `${et.emoji} ` : ''}{et.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{t('quotes.field.eventTypeHint', 'Used for the event created when this quote is accepted.')}
|
||||||
|
</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}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export interface QuoteSummary {
|
|||||||
validUntil: string | null;
|
validUntil: string | null;
|
||||||
eventName: string | null;
|
eventName: string | null;
|
||||||
eventDate: string | null;
|
eventDate: string | null;
|
||||||
|
eventType: string | null;
|
||||||
totalAmountMinor: number;
|
totalAmountMinor: number;
|
||||||
sentAt: string | null;
|
sentAt: string | null;
|
||||||
acceptedAt: string | null;
|
acceptedAt: string | null;
|
||||||
@@ -178,6 +179,7 @@ export interface QuoteCreatePayload {
|
|||||||
validUntil?: string;
|
validUntil?: string;
|
||||||
eventName?: string;
|
eventName?: string;
|
||||||
eventDate?: string;
|
eventDate?: string;
|
||||||
|
eventType?: string | null;
|
||||||
eventTimeStart?: string;
|
eventTimeStart?: string;
|
||||||
eventTimeEnd?: string;
|
eventTimeEnd?: string;
|
||||||
expectedDurationHours?: number;
|
expectedDurationHours?: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user