diff --git a/backend/src/routes/adminInvoices.js b/backend/src/routes/adminInvoices.js index 997d7366..fac20bd9 100644 --- a/backend/src/routes/adminInvoices.js +++ b/backend/src/routes/adminInvoices.js @@ -342,6 +342,7 @@ router.get( query('customerAccountId').optional({ values: 'falsy' }).isInt({ min: 1 }), query('sourceQuoteId').optional({ values: 'falsy' }).isInt({ min: 1 }), query('unpaidOnly').optional({ values: 'falsy' }).isBoolean(), + query('includeDrafts').optional({ values: 'falsy' }).isBoolean(), query('q').optional({ values: 'falsy' }).isString().isLength({ max: 255 }), query('sort').optional({ values: 'falsy' }).isIn(['newest', 'oldest', 'issue_asc', 'issue_desc', 'due_asc', 'due_desc', 'value_asc', 'value_desc', 'customer_asc', 'customer_desc']), query('page').optional({ values: 'falsy' }).isInt({ min: 1 }), @@ -358,6 +359,9 @@ router.get( customerAccountId: req.query.customerAccountId ? parseInt(req.query.customerAccountId, 10) : null, sourceQuoteId: req.query.sourceQuoteId ? parseInt(req.query.sourceQuoteId, 10) : null, unpaidOnly: req.query.unpaidOnly === 'true' || req.query.unpaidOnly === true, + // Surface running monthly/manual accumulator drafts (hidden by + // default per migration 128) when the Bills list explicitly asks. + includeMonthlyDrafts: req.query.includeDrafts === 'true' || req.query.includeDrafts === true, q: req.query.q, }, sort: req.query.sort || 'issue_desc', diff --git a/frontend/src/components/admin/HoursSection.tsx b/frontend/src/components/admin/HoursSection.tsx index b871bf0e..fb0dd906 100644 --- a/frontend/src/components/admin/HoursSection.tsx +++ b/frontend/src/components/admin/HoursSection.tsx @@ -448,12 +448,24 @@ export const HoursSection: React.FC = ({ {e.status === 'billed' ? ( - - {e.invoiceNumber - ? t('customers.hours.status.billedOn', - 'Billed: {{number}}', { number: e.invoiceNumber }) - : t('customers.hours.status.billed', 'Billed')} - + e.invoiceId ? ( + // Link straight to the invoice so a "Billed: R-…" entry + // is one click from its (possibly draft) invoice. + + {e.invoiceNumber + ? t('customers.hours.status.billedOn', 'Billed: {{number}}', { number: e.invoiceNumber }) + : t('customers.hours.status.billed', 'Billed')} + + ) : ( + + {e.invoiceNumber + ? t('customers.hours.status.billedOn', 'Billed: {{number}}', { number: e.invoiceNumber }) + : t('customers.hours.status.billed', 'Billed')} + + ) ) : ( {t('customers.hours.status.unbilled', 'Unbilled')} diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 7f7c8a7d..dc4f4549 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -4666,6 +4666,7 @@ "bills": { "status": { "scheduled": "Geplant", + "draft": "Entwurf", "pending_delivery": "Wartet auf Lieferung", "sent": "Gesendet", "paid": "Bezahlt", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 45d9e8a4..99ba2526 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -4810,6 +4810,7 @@ }, "status": { "scheduled": "Scheduled", + "draft": "Draft", "pending_delivery": "Awaiting delivery", "sent": "Sent", "paid": "Paid", diff --git a/frontend/src/pages/admin/bills/BillsListPage.tsx b/frontend/src/pages/admin/bills/BillsListPage.tsx index 346b010f..793d1b12 100644 --- a/frontend/src/pages/admin/bills/BillsListPage.tsx +++ b/frontend/src/pages/admin/bills/BillsListPage.tsx @@ -47,6 +47,9 @@ export const BillsListPage: React.FC = () => { q: search || undefined, status: statusFilter.length ? statusFilter : undefined, unpaidOnly, + // Surface the running monthly/manual accumulator drafts here (they're + // badged "Draft"); they're hidden from pickers/sub-lists by default. + includeDrafts: true, sort, page, pageSize: 25, }), }); @@ -186,14 +189,23 @@ export const BillsListPage: React.FC = () => { {formatMoney(Number(inv.totalAmountMinor) / 100, inv.currency)} - {t(`bills.status.${inv.status}`, inv.status)} + {inv.isMonthlyDraft ? ( + // Running accumulator draft (manual/monthly). It carries + // status 'scheduled' but never auto-sends on manual cadence, + // so badge it honestly as "Draft" rather than "Scheduled". + + {t('bills.status.draft', 'Draft')} + + ) : ( + {t(`bills.status.${inv.status}`, inv.status)} + )} ))} diff --git a/frontend/src/services/bills.service.ts b/frontend/src/services/bills.service.ts index ca5f0a9c..a4d0fffe 100644 --- a/frontend/src/services/bills.service.ts +++ b/frontend/src/services/bills.service.ts @@ -92,6 +92,10 @@ export interface InvoiceSummary { * (migration 111). Hide line-item editing on these rows; the * uploaded PDF is the source of truth. */ isImported?: boolean; + /** True for the running monthly/manual accumulator draft + * (migration 128). Carries status 'scheduled' but never auto-sends + * (manual) — shown with a "Draft" badge in the list. */ + isMonthlyDraft?: boolean; } export interface InvoiceDetail extends InvoiceSummary { @@ -225,6 +229,10 @@ export const billsService = { sort?: InvoiceSort; page?: number; pageSize?: number; + /** Include the running monthly/manual accumulator drafts that the + * main list hides by default (migration 128). Only the Bills list + * opts in; pickers/sub-lists leave it off. */ + includeDrafts?: boolean; } = {}): Promise { const { data } = await api.get('/admin/invoices', { params: {