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/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/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.). 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/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/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..0a91dd84 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", @@ -4800,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 45d9e8a4..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", @@ -4810,6 +4811,7 @@ }, "status": { "scheduled": "Scheduled", + "draft": "Draft", "pending_delivery": "Awaiting delivery", "sent": "Sent", "paid": "Paid", diff --git a/frontend/src/pages/admin/bills/BillDetailPage.tsx b/frontend/src/pages/admin/bills/BillDetailPage.tsx index 0e60ec73..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,7 +264,10 @@ export const BillDetailPage: React.FC = () => { )} - {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)}

@@ -523,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" > + diff --git a/frontend/src/pages/admin/bills/BillsListPage.tsx b/frontend/src/pages/admin/bills/BillsListPage.tsx index 346b010f..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'; @@ -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)} + {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')} + + ) : ( + {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..7716f57c 100644 --- a/frontend/src/services/bills.service.ts +++ b/frontend/src/services/bills.service.ts @@ -92,6 +92,24 @@ 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; +} + +/** + * 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 { @@ -225,6 +243,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: { @@ -378,6 +400,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;