From b9d91385b43de7ede508884f7cf78b5cf785f853 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:19:54 +0200 Subject: [PATCH 1/6] fix(reminders): wrap is_active/is_archived wheres in formatBoolean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit eventReminderService used bare boolean literals in its knex .where() calls (events.is_active/is_archived/event_reminder_disabled and the assigned- customer c.is_active), instead of the codebase's formatBoolean() convention (utils/dbCompat). On SQLite, booleans are stored as 0/1, so a bare `true` relies on knex's coercion rather than the explicit helper every other service uses — the maintainer flagged this twice (#674, #679). Wrap all four. --- backend/src/services/eventReminderService.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/backend/src/services/eventReminderService.js b/backend/src/services/eventReminderService.js index 6d46d769..ed6c7891 100644 --- a/backend/src/services/eventReminderService.js +++ b/backend/src/services/eventReminderService.js @@ -55,6 +55,7 @@ const { db } = require('../database/db'); const emailProcessor = require('./emailProcessor'); +const { formatBoolean } = require('../utils/dbCompat'); const { getAppSetting } = require('../utils/appSettings'); const { hasColumnCached } = require('../utils/schemaCache'); const logger = require('../utils/logger'); @@ -176,9 +177,9 @@ async function runEventReminderPass() { const now = new Date(); const rows = await db('events') .whereNotNull('events.event_date') - .where('events.is_active', true) - .where('events.is_archived', false) - .where('events.event_reminder_disabled', false) + .where('events.is_active', formatBoolean(true)) + .where('events.is_archived', formatBoolean(false)) + .where('events.event_reminder_disabled', formatBoolean(false)) .whereNull('events.event_reminder_sent_at') .where('events.event_date', '>=', now.toISOString().slice(0, 10)) .select('events.*'); @@ -328,7 +329,7 @@ async function resolveReminderRecipients(eventRow) { const assigned = await db('event_customer_assignments as a') .join('customer_accounts as c', 'c.id', 'a.customer_account_id') .where('a.event_id', eventRow.id) - .where('c.is_active', true) + .where('c.is_active', formatBoolean(true)) .whereNotNull('c.email') .select('c.email'); // De-dup emails defensively (a customer assigned twice, etc.). 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 2/6] 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: { From ca0944293f66b6465a577340e63d592598915092 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:38:21 +0200 Subject: [PATCH 3/6] fix(invoices): show "Draft" on the invoice detail page for accumulator drafts Follow-up to the Bills-list change: the invoice detail header still printed "Scheduled" for a running monthly/manual draft (is_monthly_draft). It already had a separate monthly-draft badge, but the status pill itself now reads "Draft" too, matching the list and the Billed-chip link target. --- frontend/src/pages/admin/bills/BillDetailPage.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/admin/bills/BillDetailPage.tsx b/frontend/src/pages/admin/bills/BillDetailPage.tsx index 0e60ec73..252e4d41 100644 --- a/frontend/src/pages/admin/bills/BillDetailPage.tsx +++ b/frontend/src/pages/admin/bills/BillDetailPage.tsx @@ -264,7 +264,10 @@ export const BillDetailPage: React.FC = () => { )} - {t(`bills.status.${inv.status}`, inv.status)} + {/* A running monthly/manual accumulator carries status + 'scheduled' but never auto-sends on manual cadence — + read it as "Draft", matching the Bills list. */} + {inv.isMonthlyDraft ? t('bills.status.draft', 'Draft') : t(`bills.status.${inv.status}`, inv.status)}

From e96ef4c5a35bc9e575bc3419fb318a3ee9df1bd6 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:55:36 +0200 Subject: [PATCH 4/6] fix(invoices): add bank transfer to the mark-paid method list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mark-paid dialog offered Cash / Card / PayPal / TWINT but not bank transfer — the default method for the QR-bill / IBAN invoices picpeak issues (createInvoice even falls back to 'bank_transfer'). Added it as the first option. Backend already accepts paymentMethod as a free string, so no API change; i18n bills.payment.methods.bankTransfer (de "Überweisung"). --- frontend/src/i18n/locales/de.json | 1 + frontend/src/i18n/locales/en.json | 1 + frontend/src/pages/admin/bills/BillDetailPage.tsx | 1 + 3 files changed, 3 insertions(+) diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index dc4f4549..0a91dd84 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -4801,6 +4801,7 @@ "notes": "Notizen", "methodPlaceholder": "Methode wählen…", "methods": { + "bankTransfer": "Überweisung", "card": "Karte", "cash": "Bar", "paypal": "PayPal", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 99ba2526..95422e5c 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -4789,6 +4789,7 @@ "notes": "Notes", "methodPlaceholder": "Select method…", "methods": { + "bankTransfer": "Bank transfer", "card": "Card", "cash": "Cash", "paypal": "PayPal", diff --git a/frontend/src/pages/admin/bills/BillDetailPage.tsx b/frontend/src/pages/admin/bills/BillDetailPage.tsx index 252e4d41..24305457 100644 --- a/frontend/src/pages/admin/bills/BillDetailPage.tsx +++ b/frontend/src/pages/admin/bills/BillDetailPage.tsx @@ -526,6 +526,7 @@ export const BillDetailPage: React.FC = () => { className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-accent-dark" > + From d1c9e02bcf50b6c08eebc85acdbfba29bfee84ac Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:55:45 +0200 Subject: [PATCH 5/6] =?UTF-8?q?feat(dashboard):=20revenue=20"year"=20tile?= =?UTF-8?q?=20toggles=20365=20days=20=E2=86=94=20calendar=20YTD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per request, keep the dashboard to four tiles rather than adding a fifth: the "Revenue · last 365 days" tile is now clickable and toggles in place between the trailing-365-day window and calendar year-to-date (since Jan 1). - adminDashboard: new calendar-year cutoff + revenue.calendarYearMinor (same cash-basis paid_at window logic as the existing trio). - StatCard gains an optional onClick (renders as a button); the year tile uses it, with a "Tap to switch window" hint for discoverability. - bills.service CrmOverviewStats.revenue gains calendarYearMinor. --- backend/src/routes/adminDashboard.js | 19 ++++++++----- .../components/admin/CrmOverviewSection.tsx | 27 ++++++++++++++++--- frontend/src/services/bills.service.ts | 3 +++ 3 files changed, 40 insertions(+), 9 deletions(-) diff --git a/backend/src/routes/adminDashboard.js b/backend/src/routes/adminDashboard.js index bc5aa379..6a67b771 100644 --- a/backend/src/routes/adminDashboard.js +++ b/backend/src/routes/adminDashboard.js @@ -439,6 +439,10 @@ router.get('/crm-stats', adminAuth, async (req, res) => { const monthCutoff = new Date(now - 30 * DAY); const quarterCutoff = new Date(now - 90 * DAY); const yearCutoff = new Date(now - 365 * DAY); + // Calendar year-to-date (Jan 1 of the current year, local time) — + // the dashboard's revenue "year" tile can toggle between this and + // the trailing-365-day window. + const calendarYearCutoff = new Date(new Date(now).getFullYear(), 0, 1); // ---- quotes: counts by status --------------------------------- let quoteCounts = { draft: 0, sent: 0, accepted: 0, declined: 0, expired: 0, converted: 0 }; @@ -459,6 +463,7 @@ router.get('/crm-stats', adminAuth, async (req, res) => { let revenueMonthMinor = 0; let revenueQuarterMinor = 0; let revenueYearMinor = 0; + let revenueCalendarYearMinor = 0; let outstandingTotalMinor = 0; let outstandingCount = 0; @@ -504,9 +509,10 @@ router.get('/crm-stats', adminAuth, async (req, res) => { .first(); return Number(row?.total || 0); }; - revenueMonthMinor = await winSum(monthCutoff); - revenueQuarterMinor = await winSum(quarterCutoff); - revenueYearMinor = await winSum(yearCutoff); + revenueMonthMinor = await winSum(monthCutoff); + revenueQuarterMinor = await winSum(quarterCutoff); + revenueYearMinor = await winSum(yearCutoff); + revenueCalendarYearMinor = await winSum(calendarYearCutoff); // Outstanding: every invoice that's been sent but not fully // paid (sent + overdue). Outstanding = total - paid. We sum @@ -568,9 +574,10 @@ router.get('/crm-stats', adminAuth, async (req, res) => { quotes: quoteCounts, invoices: invoiceCounts, revenue: { - monthMinor: revenueMonthMinor, - quarterMinor: revenueQuarterMinor, - yearMinor: revenueYearMinor, + monthMinor: revenueMonthMinor, + quarterMinor: revenueQuarterMinor, + yearMinor: revenueYearMinor, + calendarYearMinor: revenueCalendarYearMinor, }, outstanding: { totalMinor: outstandingTotalMinor, diff --git a/frontend/src/components/admin/CrmOverviewSection.tsx b/frontend/src/components/admin/CrmOverviewSection.tsx index 88f7ea7d..34a17978 100644 --- a/frontend/src/components/admin/CrmOverviewSection.tsx +++ b/frontend/src/components/admin/CrmOverviewSection.tsx @@ -50,6 +50,10 @@ export const CrmOverviewSection: React.FC = () => { // publicSettings finishes loading — shows everything, then settles. const showRevenue = publicSettings?.crm_overview_show_revenue !== false; const showOutstanding = publicSettings?.crm_overview_show_outstanding !== false; + // Revenue "year" tile toggles in place between the trailing-365-day + // window and calendar year-to-date — keeps the dashboard to four + // tiles instead of adding a fifth. + const [revYearMode, setRevYearMode] = React.useState<'rolling' | 'calendar'>('rolling'); const showQuotes = publicSettings?.crm_overview_show_quotes !== false; const showInvoices = publicSettings?.crm_overview_show_invoices !== false; // Compute which sub-sections actually render so we can skip the @@ -121,8 +125,15 @@ export const CrmOverviewSection: React.FC = () => { /> } - label={t('crmOverview.revenue.year', 'Revenue · last 365 days')} - value={formatMoney(d.revenue.yearMinor, cur)} + label={revYearMode === 'calendar' + ? t('crmOverview.revenue.yearCalendar', 'Revenue · this year') + : t('crmOverview.revenue.year', 'Revenue · last 365 days')} + value={formatMoney( + revYearMode === 'calendar' ? d.revenue.calendarYearMinor : d.revenue.yearMinor, + cur, + )} + sub={t('crmOverview.revenue.toggleHint', 'Tap to switch window')} + onClick={() => setRevYearMode((m) => (m === 'rolling' ? 'calendar' : 'rolling'))} /> )} @@ -249,8 +260,11 @@ interface StatCardProps { value: string | number; sub?: string; to?: string; + /** Makes the whole tile a button (mutually exclusive with `to`). + * Used by the revenue tile to toggle its window in place. */ + onClick?: () => void; } -const StatCard: React.FC = ({ icon, label, value, sub, to }) => { +const StatCard: React.FC = ({ icon, label, value, sub, to, onClick }) => { const inner = (

@@ -266,6 +280,13 @@ const StatCard: React.FC = ({ icon, label, value, sub, to }) => { if (to) { return {inner}; } + if (onClick) { + return ( + + ); + } return inner; }; diff --git a/frontend/src/services/bills.service.ts b/frontend/src/services/bills.service.ts index a4d0fffe..d64cb3c8 100644 --- a/frontend/src/services/bills.service.ts +++ b/frontend/src/services/bills.service.ts @@ -386,6 +386,9 @@ export interface CrmOverviewStats { monthMinor: number; quarterMinor: number; yearMinor: number; + /** Revenue since Jan 1 of the current year (calendar YTD). The + * dashboard's "year" tile toggles between this and yearMinor. */ + calendarYearMinor: number; }; outstanding: { totalMinor: number; From e4367e028a5228ef50c4bbd522d0777bc7340b52 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:24:25 +0200 Subject: [PATCH 6/6] fix(invoices): badge held (unsent, no send date) invoices as "Draft" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier change only relabeled is_monthly_draft rows. But a per-event invoice created from hours is status 'scheduled' with scheduled_send_at = NULL and is_monthly_draft = false — it never auto-ships (the scheduler only picks rows with scheduled_send_at <= now), yet it still read "Scheduled" on the customer panel + lists. Add a shared isDraftInvoice() helper (scheduled && no send date, or a monthly/manual accumulator) and use it for the badge in the Bills list, the invoice detail header, and the customer profile's invoice panel. A scheduled invoice WITH a future send date keeps "Scheduled". --- .../components/admin/CustomerCrmPanels.tsx | 24 ++++++++++++------- .../src/pages/admin/bills/BillDetailPage.tsx | 10 ++++---- .../src/pages/admin/bills/BillsListPage.tsx | 10 ++++---- frontend/src/services/bills.service.ts | 14 +++++++++++ 4 files changed, 39 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/admin/CustomerCrmPanels.tsx b/frontend/src/components/admin/CustomerCrmPanels.tsx index bc5c3109..6be7b409 100644 --- a/frontend/src/components/admin/CustomerCrmPanels.tsx +++ b/frontend/src/components/admin/CustomerCrmPanels.tsx @@ -19,7 +19,7 @@ import { FileText, Plus, Receipt, ScrollText } from 'lucide-react'; import { Card, Button, Loading } from '../common'; import { useFeatureFlags } from '../../contexts/FeatureFlagsContext'; import { quotesService } from '../../services/quotes.service'; -import { billsService } from '../../services/bills.service'; +import { billsService, isDraftInvoice } from '../../services/bills.service'; import { contractsService } from '../../services/contracts.service'; import { formatMoney } from './LineItemsTable'; import { useLocalizedDate } from '../../hooks/useLocalizedDate'; @@ -204,14 +204,20 @@ const InvoicesPanel: React.FC = ({ customerAccountId }) => {
{formatMoney(Number(inv.totalAmountMinor) / 100, inv.currency)} - {t(`bills.status.${inv.status}`, inv.status)} + {isDraftInvoice(inv) ? ( + + {t('bills.status.draft', 'Draft')} + + ) : ( + {t(`bills.status.${inv.status}`, inv.status)} + )} ))} diff --git a/frontend/src/pages/admin/bills/BillDetailPage.tsx b/frontend/src/pages/admin/bills/BillDetailPage.tsx index 24305457..d937c816 100644 --- a/frontend/src/pages/admin/bills/BillDetailPage.tsx +++ b/frontend/src/pages/admin/bills/BillDetailPage.tsx @@ -10,7 +10,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { ArrowLeft, Eye, Send, CheckCircle, BellRing, XCircle, Truck, Edit2, RefreshCw } from 'lucide-react'; import { Button, Card, Loading, Input, LocalizedDateInput } from '../../../components/common'; import { DocumentLineageCard } from '../../../components/admin/DocumentLineageCard'; -import { billsService } from '../../../services/bills.service'; +import { billsService, isDraftInvoice } from '../../../services/bills.service'; import { formatMoney } from '../../../components/admin/LineItemsTable'; import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; import { toast } from 'react-toastify'; @@ -264,10 +264,10 @@ export const BillDetailPage: React.FC = () => { )} - {/* A running monthly/manual accumulator carries status - 'scheduled' but never auto-sends on manual cadence — - read it as "Draft", matching the Bills list. */} - {inv.isMonthlyDraft ? t('bills.status.draft', 'Draft') : t(`bills.status.${inv.status}`, inv.status)} + {/* Held invoice ('scheduled' with no send date, incl. the + monthly/manual accumulator) never auto-ships — read it as + "Draft", matching the Bills list. */} + {isDraftInvoice(inv) ? t('bills.status.draft', 'Draft') : t(`bills.status.${inv.status}`, inv.status)}

diff --git a/frontend/src/pages/admin/bills/BillsListPage.tsx b/frontend/src/pages/admin/bills/BillsListPage.tsx index 793d1b12..192886c8 100644 --- a/frontend/src/pages/admin/bills/BillsListPage.tsx +++ b/frontend/src/pages/admin/bills/BillsListPage.tsx @@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next'; import { Link, useNavigate } from 'react-router-dom'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Plus, Search, Upload, X } from 'lucide-react'; -import { billsService, type InvoiceStatus, type InvoiceSort } from '../../../services/bills.service'; +import { billsService, isDraftInvoice, type InvoiceStatus, type InvoiceSort } from '../../../services/bills.service'; import { Button, Card, Input, Loading, LocalizedDateInput, SortableHeader, useColumnSort, type SortColumnMap } from '../../../components/common'; import { formatMoney } from '../../../components/admin/LineItemsTable'; import { customerAdminService } from '../../../services/customerAdmin.service'; @@ -189,10 +189,10 @@ export const BillsListPage: React.FC = () => { {formatMoney(Number(inv.totalAmountMinor) / 100, inv.currency)} - {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". + {isDraftInvoice(inv) ? ( + // Held invoice: 'scheduled' with no send date (incl. the + // monthly/manual accumulator) never auto-ships, so badge it + // honestly as "Draft" rather than "Scheduled". {t('bills.status.draft', 'Draft')} diff --git a/frontend/src/services/bills.service.ts b/frontend/src/services/bills.service.ts index d64cb3c8..7716f57c 100644 --- a/frontend/src/services/bills.service.ts +++ b/frontend/src/services/bills.service.ts @@ -98,6 +98,20 @@ export interface InvoiceSummary { isMonthlyDraft?: boolean; } +/** + * A "scheduled" invoice with no send date is HELD — the scheduler only + * picks up rows whose `scheduled_send_at <= now`, so a null send date + * means it never auto-ships and is waiting on the admin (Send now / + * Trigger invoice now). Those, plus monthly/manual accumulators, read as + * "Draft" everywhere instead of the misleading "Scheduled". A scheduled + * invoice WITH a future send date is genuinely scheduled and keeps that + * label. + */ +export function isDraftInvoice(inv: Pick): boolean { + if (inv.isMonthlyDraft) return true; + return inv.status === 'scheduled' && !inv.scheduledSendAt; +} + export interface InvoiceDetail extends InvoiceSummary { netAmountMinor: number; vatRate: number | null;