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(),
|
body('skontoDisabled').optional().isBoolean(),
|
||||||
// Inline event snapshot (migration 123). Mirrors quotes — kept
|
// Inline event snapshot (migration 123). Mirrors quotes — kept
|
||||||
// optional because standalone invoices may not have an event yet.
|
// 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('eventName').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
|
||||||
body('eventDate').optional({ values: 'falsy' }).isISO8601(),
|
body('eventDate').optional({ values: 'falsy' }).isISO8601(),
|
||||||
body('eventTimeStart').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
|
body('eventTimeStart').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
|
||||||
|
|||||||
@@ -840,6 +840,7 @@
|
|||||||
"copyLink": "Link kopieren",
|
"copyLink": "Link kopieren",
|
||||||
"linkCopied": "Link kopiert!",
|
"linkCopied": "Link kopiert!",
|
||||||
"viewGallery": "Galerie ansehen",
|
"viewGallery": "Galerie ansehen",
|
||||||
|
"createInvoice": "Rechnung erstellen",
|
||||||
"uploadPhotos": "Fotos hochladen",
|
"uploadPhotos": "Fotos hochladen",
|
||||||
"archiveEvent": "Veranstaltung archivieren",
|
"archiveEvent": "Veranstaltung archivieren",
|
||||||
"archiveConfirm": "Sind Sie sicher, dass Sie diese Veranstaltung archivieren möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
"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",
|
"copyLink": "Copy Link",
|
||||||
"linkCopied": "Link copied!",
|
"linkCopied": "Link copied!",
|
||||||
"viewGallery": "View Gallery",
|
"viewGallery": "View Gallery",
|
||||||
|
"createInvoice": "Create invoice",
|
||||||
"uploadPhotos": "Upload Photos",
|
"uploadPhotos": "Upload Photos",
|
||||||
"archiveEvent": "Archive Event",
|
"archiveEvent": "Archive Event",
|
||||||
"archiveConfirm": "Are you sure you want to archive this event? This action cannot be undone.",
|
"archiveConfirm": "Are you sure you want to archive this event? This action cannot be undone.",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Key,
|
Key,
|
||||||
Mail,
|
Mail,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
|
Receipt,
|
||||||
Lock,
|
Lock,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
@@ -980,6 +981,26 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
{t('feedback.manage', 'Manage Feedback')}
|
{t('feedback.manage', 'Manage Feedback')}
|
||||||
</Button>
|
</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
|
// event section — admin can type a free-text label without needing
|
||||||
// an actual events row, and it carries through to the customer
|
// an actual events row, and it carries through to the customer
|
||||||
// portal + tax report + email templates.
|
// portal + tax report + email templates.
|
||||||
|
const [eventId, setEventId] = useState<number | null>(null);
|
||||||
const [eventName, setEventName] = useState('');
|
const [eventName, setEventName] = useState('');
|
||||||
const [eventDate, setEventDate] = useState('');
|
const [eventDate, setEventDate] = useState('');
|
||||||
const [eventTimeStart, setEventTimeStart] = useState('');
|
const [eventTimeStart, setEventTimeStart] = useState('');
|
||||||
@@ -143,6 +144,7 @@ export const BillEditorPage: React.FC = () => {
|
|||||||
setPaymentTimingTemplateId(inv.paymentTimingTemplateId ?? null);
|
setPaymentTimingTemplateId(inv.paymentTimingTemplateId ?? null);
|
||||||
setBusinessBankAccountId(inv.businessBankAccountId ?? null);
|
setBusinessBankAccountId(inv.businessBankAccountId ?? null);
|
||||||
setSkontoDisabled(Boolean(inv.skontoDisabled));
|
setSkontoDisabled(Boolean(inv.skontoDisabled));
|
||||||
|
setEventId(inv.eventId ?? null);
|
||||||
setEventName(inv.eventName || '');
|
setEventName(inv.eventName || '');
|
||||||
setEventDate(inv.eventDate || '');
|
setEventDate(inv.eventDate || '');
|
||||||
setEventTimeStart(inv.eventTimeStart || '');
|
setEventTimeStart(inv.eventTimeStart || '');
|
||||||
@@ -216,6 +218,23 @@ export const BillEditorPage: React.FC = () => {
|
|||||||
})();
|
})();
|
||||||
}, [isEdit, searchParams, customerId]);
|
}, [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
|
// Pre-fill from a fully-signed contract when the editor is opened
|
||||||
// via `?fromContractId=<id>` (the "New invoice" link on
|
// via `?fromContractId=<id>` (the "New invoice" link on
|
||||||
// ContractDetailPage's header, used after the contract has been
|
// 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
|
// so the backend can distinguish "not provided" from a deliberate
|
||||||
// clear (which the route's `optional({ values: 'falsy' })` already
|
// clear (which the route's `optional({ values: 'falsy' })` already
|
||||||
// treats identically — falsy values bypass validation entirely).
|
// treats identically — falsy values bypass validation entirely).
|
||||||
|
eventId: eventId ?? undefined,
|
||||||
eventName: eventName || undefined,
|
eventName: eventName || undefined,
|
||||||
eventDate: eventDate || undefined,
|
eventDate: eventDate || undefined,
|
||||||
eventTimeStart: eventTimeStart || undefined,
|
eventTimeStart: eventTimeStart || undefined,
|
||||||
|
|||||||
Reference in New Issue
Block a user