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:
Luca
2026-06-23 17:52:50 +02:00
parent 182e655fcf
commit f78671fc6c
7 changed files with 78 additions and 2 deletions
+3
View File
@@ -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",
+3
View File
@@ -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",
@@ -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 = () => {
<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}
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}
onChange={(iso) => setForm((f) => ({ ...f, eventDate: iso }))} />
<TimeField label={t('quotes.field.eventTimeStart', 'Start time') as string} value={form.eventTimeStart}
+2
View File
@@ -63,6 +63,7 @@ export interface QuoteSummary {
validUntil: string | null;
eventName: string | null;
eventDate: string | null;
eventType: string | null;
totalAmountMinor: number;
sentAt: string | null;
acceptedAt: string | null;
@@ -178,6 +179,7 @@ export interface QuoteCreatePayload {
validUntil?: string;
eventName?: string;
eventDate?: string;
eventType?: string | null;
eventTimeStart?: string;
eventTimeEnd?: string;
expectedDurationHours?: number;