feat(events): "Create invoice" action on the event detail page

Add a bills-gated button that opens the bill editor pre-filled with the
event (eventId FK + name/date snapshot) and the linked customer (when
exactly one). BillEditorPage gains eventId state + query-param prefill +
sends eventId on create; backend validates eventId (already forwarded +
persisted). Reuses the editor — no empty drafts. Does not auto-pull hours.
This commit is contained in:
Luca
2026-06-03 00:07:00 +02:00
parent b378ad679f
commit a03146edc9
5 changed files with 46 additions and 0 deletions
+3
View File
@@ -240,6 +240,9 @@ const INVOICE_BODY_VALIDATORS = [
body('skontoDisabled').optional().isBoolean(),
// Inline event snapshot (migration 123). Mirrors quotes — kept
// optional because standalone invoices may not have an event yet.
// eventId links the invoice to a gallery event (FK) when created from
// the event detail page; persisted by createInvoice (event_id).
body('eventId').optional({ values: 'falsy' }).isInt({ min: 1 }),
body('eventName').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
body('eventDate').optional({ values: 'falsy' }).isISO8601(),
body('eventTimeStart').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
+1
View File
@@ -840,6 +840,7 @@
"copyLink": "Link kopieren",
"linkCopied": "Link kopiert!",
"viewGallery": "Galerie ansehen",
"createInvoice": "Rechnung erstellen",
"uploadPhotos": "Fotos hochladen",
"archiveEvent": "Veranstaltung archivieren",
"archiveConfirm": "Sind Sie sicher, dass Sie diese Veranstaltung archivieren möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
+1
View File
@@ -399,6 +399,7 @@
"copyLink": "Copy Link",
"linkCopied": "Link copied!",
"viewGallery": "View Gallery",
"createInvoice": "Create invoice",
"uploadPhotos": "Upload Photos",
"archiveEvent": "Archive Event",
"archiveConfirm": "Are you sure you want to archive this event? This action cannot be undone.",
@@ -18,6 +18,7 @@ import {
Key,
Mail,
MessageSquare,
Receipt,
Lock,
Eye,
EyeOff,
@@ -980,6 +981,26 @@ export const EventDetailsPage: React.FC = () => {
{t('feedback.manage', 'Manage Feedback')}
</Button>
)}
{/* Create a draft invoice for this event pre-fills the
bill editor with the event snapshot + (when exactly
one is linked) the customer. Gated on the bills flag. */}
{flags.bills && (
<Button
variant="outline"
size="sm"
leftIcon={<Receipt className="w-4 h-4" />}
onClick={() => {
const accts = ((event as { customer_accounts?: Array<{ id: number }> }).customer_accounts) || [];
const params = new URLSearchParams({ eventId: String(event.id) });
if (event.event_name) params.set('eventName', event.event_name);
if (event.event_date) params.set('eventDate', String(event.event_date).slice(0, 10));
if (accts.length === 1) params.set('customerAccountId', String(accts[0].id));
navigate(`/admin/clients/bills/new?${params.toString()}`);
}}
>
{t('events.createInvoice', 'Create invoice')}
</Button>
)}
</>
)}
</>
@@ -83,6 +83,7 @@ export const BillEditorPage: React.FC = () => {
// event section — admin can type a free-text label without needing
// an actual events row, and it carries through to the customer
// portal + tax report + email templates.
const [eventId, setEventId] = useState<number | null>(null);
const [eventName, setEventName] = useState('');
const [eventDate, setEventDate] = useState('');
const [eventTimeStart, setEventTimeStart] = useState('');
@@ -143,6 +144,7 @@ export const BillEditorPage: React.FC = () => {
setPaymentTimingTemplateId(inv.paymentTimingTemplateId ?? null);
setBusinessBankAccountId(inv.businessBankAccountId ?? null);
setSkontoDisabled(Boolean(inv.skontoDisabled));
setEventId(inv.eventId ?? null);
setEventName(inv.eventName || '');
setEventDate(inv.eventDate || '');
setEventTimeStart(inv.eventTimeStart || '');
@@ -216,6 +218,23 @@ export const BillEditorPage: React.FC = () => {
})();
}, [isEdit, searchParams, customerId]);
// Pre-fill the event link + snapshot when opened from an event's
// "Create invoice" button (/admin/clients/bills/new?eventId=&eventName=&eventDate=).
const didPrefillEventRef = useRef(false);
useEffect(() => {
if (isEdit) return;
if (didPrefillEventRef.current) return;
const eidRaw = searchParams.get('eventId');
const eid = eidRaw ? parseInt(eidRaw, 10) : NaN;
const en = searchParams.get('eventName');
const ed = searchParams.get('eventDate');
if (!(Number.isFinite(eid) && eid > 0) && !en && !ed) return;
didPrefillEventRef.current = true;
if (Number.isFinite(eid) && eid > 0) setEventId(eid);
if (en) setEventName((prev) => prev || en);
if (ed) setEventDate((prev) => prev || ed);
}, [isEdit, searchParams]);
// Pre-fill from a fully-signed contract when the editor is opened
// via `?fromContractId=<id>` (the "New invoice" link on
// ContractDetailPage's header, used after the contract has been
@@ -384,6 +403,7 @@ export const BillEditorPage: React.FC = () => {
// so the backend can distinguish "not provided" from a deliberate
// clear (which the route's `optional({ values: 'falsy' })` already
// treats identically — falsy values bypass validation entirely).
eventId: eventId ?? undefined,
eventName: eventName || undefined,
eventDate: eventDate || undefined,
eventTimeStart: eventTimeStart || undefined,