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 && ( + + )} )} 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,