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:
@@ -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 }),
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user