From a03146edc9aefaeff02f73752a3ebfa16809c31b Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Wed, 3 Jun 2026 00:07:00 +0200
Subject: [PATCH] feat(events): "Create invoice" action on the event detail
page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
backend/src/routes/adminInvoices.js | 3 +++
frontend/src/i18n/locales/de.json | 1 +
frontend/src/i18n/locales/en.json | 1 +
frontend/src/pages/admin/EventDetailsPage.tsx | 21 +++++++++++++++++++
.../src/pages/admin/bills/BillEditorPage.tsx | 20 ++++++++++++++++++
5 files changed, 46 insertions(+)
diff --git a/backend/src/routes/adminInvoices.js b/backend/src/routes/adminInvoices.js
index 326501bf..b114bf44 100644
--- a/backend/src/routes/adminInvoices.js
+++ b/backend/src/routes/adminInvoices.js
@@ -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 }),
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index 9777c510..a4064839 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -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.",
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index 0b8d1f1f..9edf0d1b 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -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.",
diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx
index 483327a6..f5835068 100644
--- a/frontend/src/pages/admin/EventDetailsPage.tsx
+++ b/frontend/src/pages/admin/EventDetailsPage.tsx
@@ -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')}
)}
+ {/* 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 && (
+ }
+ 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')}
+
+ )}
>
)}
>
diff --git a/frontend/src/pages/admin/bills/BillEditorPage.tsx b/frontend/src/pages/admin/bills/BillEditorPage.tsx
index 3f2d3c1e..f336b417 100644
--- a/frontend/src/pages/admin/bills/BillEditorPage.tsx
+++ b/frontend/src/pages/admin/bills/BillEditorPage.tsx
@@ -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(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=` (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,