From e457656b9d06bb420c9d0985fe15c30d6c88aed9 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:25:29 +0200 Subject: [PATCH] feat(invoices): surface monthly/manual accumulator drafts in the Bills list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manual/monthly-cadence customers accumulate logged hours into one running draft invoice (is_monthly_draft, migration 128). That draft gets a real invoice number and stamps the hours ("Billed: R-2026-0026"), but listInvoices hid is_monthly_draft rows from the main list — so the invoice looked lost even though it existed on the customer's monthly-queue card. It also carried status 'scheduled' despite never auto-sending on manual cadence, reading misleadingly as "Scheduled". - Bills list now opts into drafts via a new `includeDrafts` query param (GET /admin/invoices → listInvoices includeMonthlyDrafts). Pickers/sub-lists that reuse billsService.list leave it off, so they're unaffected. - Draft rows render a distinct "Draft" badge instead of "Scheduled" (transformInvoice already exposes isMonthlyDraft). - The hours "Billed: R-…" chip now links straight to its invoice. - i18n: bills.status.draft (de "Entwurf", en "Draft"). --- backend/src/routes/adminInvoices.js | 4 +++ .../src/components/admin/HoursSection.tsx | 24 ++++++++++++---- frontend/src/i18n/locales/de.json | 1 + frontend/src/i18n/locales/en.json | 1 + .../src/pages/admin/bills/BillsListPage.tsx | 28 +++++++++++++------ frontend/src/services/bills.service.ts | 8 ++++++ 6 files changed, 52 insertions(+), 14 deletions(-) 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: {