From def4b3477be1271118b0e03e6fa12d9a12599310 Mon Sep 17 00:00:00 2001 From: Devin Wilson Date: Mon, 18 May 2026 16:01:33 -0700 Subject: [PATCH] fix: separate invoices from work orders + bottom page padding --- web/src/App.tsx | 2 +- web/src/lib/finance.test.ts | 7 +++- web/src/lib/finance.ts | 18 ++++---- web/src/lib/workItem.test.ts | 55 +++++++++++++++++++++++++ web/src/lib/workItem.ts | 24 +++++++++++ web/src/routes/contractor/Dashboard.tsx | 5 ++- web/src/routes/contractor/WorkItems.tsx | 43 +++---------------- 7 files changed, 106 insertions(+), 48 deletions(-) create mode 100644 web/src/lib/workItem.test.ts create mode 100644 web/src/lib/workItem.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index 3fd701f..c257367 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -303,7 +303,7 @@ function ContractorLayout() { onNotificationsClick={handleNotifToggle} onOpenSearch={() => setSearchOpen(true)} /> -
+
}> diff --git a/web/src/lib/finance.test.ts b/web/src/lib/finance.test.ts index 5290bd1..6dfa1ae 100644 --- a/web/src/lib/finance.test.ts +++ b/web/src/lib/finance.test.ts @@ -170,13 +170,18 @@ describe('getRevenueByType', () => { }); describe('getInvoiceStatusCounts', () => { - it('counts invoices by status', () => { + it('counts invoices by status, excluding never-sent work orders', () => { const items = [ makeWorkItem({ isBillable: true, invoiceStatus: 'sent' }), makeWorkItem({ isBillable: true, invoiceStatus: 'sent' }), makeWorkItem({ isBillable: true, invoiceStatus: 'paid' }), + // Never sent (no invoiceSentDate, no sent/paid/overdue status) → a + // work order, not an invoice — excluded by the shared isInvoice rule. makeWorkItem({ isBillable: true }), + makeWorkItem({ isBillable: true, invoiceStatus: 'draft' }), makeWorkItem({ isBillable: false }), + // Draft status but already sent out → counts as a draft invoice. + makeWorkItem({ isBillable: true, invoiceStatus: 'draft', invoiceSentDate: new Date('2026-03-01') }), ]; const counts = getInvoiceStatusCounts(items); expect(counts.all).toBe(4); diff --git a/web/src/lib/finance.ts b/web/src/lib/finance.ts index bb30de2..580226b 100644 --- a/web/src/lib/finance.ts +++ b/web/src/lib/finance.ts @@ -1,4 +1,5 @@ import type { WorkItem, Client, WorkItemType } from './types'; +import { isInvoice } from './workItem'; // ── Exported Types ────────────────────────────────────────────────────────── @@ -288,14 +289,17 @@ export function getRevenueByType(items: readonly WorkItem[], range: DateRange): } /** - * Filters billable work items by optional invoiceStatus. - * If no status is provided, returns all billable items. + * Filters items down to invoices (sent-out work items — see `isInvoice`), + * billable and non-archived, optionally narrowed by invoiceStatus. + * Single-sourced membership: never-sent items are work orders, not invoices. */ export function getInvoicesByStatus( items: readonly WorkItem[], status?: WorkItem['invoiceStatus'], ): WorkItem[] { - const active = items.filter(item => item.isBillable && item.status !== 'archived'); + const active = items.filter( + item => isInvoice(item) && item.isBillable && item.status !== 'archived', + ); if (status === undefined) { return active; } @@ -303,15 +307,15 @@ export function getInvoicesByStatus( } /** - * Returns counts of billable work items by invoiceStatus. - * Items with no invoiceStatus are counted as 'draft'. - * `all` is the total count of billable items. + * Returns counts of invoices (sent-out billable work items) by invoiceStatus. + * Single-sourced via `isInvoice` so it matches `getInvoicesByStatus`. + * `all` is the total count of invoices. */ export function getInvoiceStatusCounts(items: readonly WorkItem[]): Record { const counts: Record = { all: 0, draft: 0, sent: 0, paid: 0, overdue: 0 }; for (const item of items) { - if (!item.isBillable || item.status === 'archived') continue; + if (!isInvoice(item) || !item.isBillable || item.status === 'archived') continue; counts.all += 1; const status = item.invoiceStatus ?? 'draft'; counts[status] = (counts[status] ?? 0) + 1; diff --git a/web/src/lib/workItem.test.ts b/web/src/lib/workItem.test.ts new file mode 100644 index 0000000..0b7cda2 --- /dev/null +++ b/web/src/lib/workItem.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { isInvoice, isWorkOrder } from './workItem'; +import type { WorkItem } from './types'; + +function make(partial: Partial): WorkItem { + return { + id: 'wi-1', + title: 'Test', + status: 'draft', + type: 'changeRequest', + clientId: 'c-1', + lineItems: [], + totalCost: 0, + isBillable: true, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + ...partial, + } as WorkItem; +} + +describe('isInvoice / isWorkOrder', () => { + it('treats an item with no invoice fields as a work order', () => { + const item = make({}); + expect(isInvoice(item)).toBe(false); + expect(isWorkOrder(item)).toBe(true); + }); + + it('treats invoiceStatus "draft" (not yet sent) as a work order', () => { + const item = make({ invoiceStatus: 'draft' }); + expect(isInvoice(item)).toBe(false); + expect(isWorkOrder(item)).toBe(true); + }); + + it('treats a set invoiceSentDate as an invoice', () => { + const item = make({ invoiceSentDate: new Date('2026-02-01') }); + expect(isInvoice(item)).toBe(true); + expect(isWorkOrder(item)).toBe(false); + }); + + it.each(['sent', 'paid', 'overdue'] as const)( + 'treats invoiceStatus "%s" as an invoice even without invoiceSentDate', + (status) => { + const item = make({ invoiceStatus: status }); + expect(isInvoice(item)).toBe(true); + expect(isWorkOrder(item)).toBe(false); + }, + ); + + it('is mutually exclusive — an item is exactly one of the two', () => { + const sent = make({ invoiceStatus: 'paid' }); + const notSent = make({ invoiceStatus: 'draft' }); + expect(isInvoice(sent)).toBe(!isWorkOrder(sent)); + expect(isInvoice(notSent)).toBe(!isWorkOrder(notSent)); + }); +}); diff --git a/web/src/lib/workItem.ts b/web/src/lib/workItem.ts new file mode 100644 index 0000000..b7321d2 --- /dev/null +++ b/web/src/lib/workItem.ts @@ -0,0 +1,24 @@ +import type { WorkItem } from './types'; + +/** + * Domain rule: a WorkItem that has been SENT OUT is an INVOICE, not a work + * order. "Sent out" means `invoiceSentDate` is set, OR `invoiceStatus` is one + * of 'sent' | 'paid' | 'overdue' (those statuses imply it was sent). + * + * Single source of truth — every work-order-facing list and every + * invoice-facing list must derive membership from this pair so the two never + * overlap. + */ +export function isInvoice(item: WorkItem): boolean { + return ( + Boolean(item.invoiceSentDate) || + item.invoiceStatus === 'sent' || + item.invoiceStatus === 'paid' || + item.invoiceStatus === 'overdue' + ); +} + +/** A WorkItem that has NOT been sent out is a work order. */ +export function isWorkOrder(item: WorkItem): boolean { + return !isInvoice(item); +} diff --git a/web/src/routes/contractor/Dashboard.tsx b/web/src/routes/contractor/Dashboard.tsx index f53365d..2bb51fe 100644 --- a/web/src/routes/contractor/Dashboard.tsx +++ b/web/src/routes/contractor/Dashboard.tsx @@ -5,6 +5,7 @@ import { WorkItemCard } from '../../components/workitems/WorkItemCard'; import { Onboarding } from '../../components/onboarding/Onboarding'; import { useAuth } from '../../hooks/useAuth'; import { useSettings, useTimeEntries, useInsights } from '../../hooks/useFirestore'; +import { isWorkOrder } from '../../lib/workItem'; import { UtilizationGauge } from '../../components/insights/UtilizationGauge'; import { InsightShimmer } from '../../components/insights/InsightShimmer'; import { @@ -107,7 +108,7 @@ export default function Dashboard({ workItems, clients, apps }: DashboardProps) user != null; const pending = workItems.filter( - (i) => i.status === 'draft' || i.status === 'inReview' + (i) => isWorkOrder(i) && (i.status === 'draft' || i.status === 'inReview') ); const now = new Date(); @@ -234,7 +235,7 @@ export default function Dashboard({ workItems, clients, apps }: DashboardProps) const pipelineStages = useMemo(() => { const counts: Record = { draft: 0, inReview: 0, approved: 0, completed: 0 }; workItems - .filter((i) => i.status !== 'archived') + .filter((i) => isWorkOrder(i) && i.status !== 'archived') .forEach((i) => { counts[i.status] = (counts[i.status] ?? 0) + 1; }); return [ { label: 'Draft', count: counts.draft, color: 'var(--text-secondary)' }, diff --git a/web/src/routes/contractor/WorkItems.tsx b/web/src/routes/contractor/WorkItems.tsx index d4cefb4..0ccaef7 100644 --- a/web/src/routes/contractor/WorkItems.tsx +++ b/web/src/routes/contractor/WorkItems.tsx @@ -6,6 +6,7 @@ import { NewWorkOrderModal } from '../../components/workitems/NewWorkOrderModal' import type { WorkItem, Client, AppSettings, App } from '../../lib/types'; import { WORK_ITEM_TYPE_LABELS, WORK_ITEM_STATUS_LABELS } from '../../lib/types'; import { formatDate, exportToCsv } from '../../lib/utils'; +import { isWorkOrder } from '../../lib/workItem'; import { bulkUpdateStatus } from '../../services/firestore'; import { IconDocument } from '../../components/icons'; import { useInsights } from '../../hooks/useFirestore'; @@ -20,8 +21,6 @@ interface WorkItemsProps { const typeTabs = ['All', 'Change Requests', 'Feature Requests', 'Maintenance']; const statusTabs = ['All', 'Draft', 'In Review', 'Approved', 'Completed']; -const invoiceStatusOptions = ['draft', 'sent', 'paid', 'overdue']; -const invoiceStatusLabels: Record = { draft: 'Draft', sent: 'Sent', paid: 'Paid', overdue: 'Overdue' }; export default function WorkItems({ workItems, clients, apps, settings }: WorkItemsProps) { const [searchParams, setSearchParams] = useSearchParams(); @@ -43,10 +42,6 @@ export default function WorkItems({ workItems, clients, apps, settings }: WorkIt const val = searchParams.get('apps'); return val ? val.split(',') : []; }); - const [selectedInvoiceStatus, setSelectedInvoiceStatus] = useState(() => { - const val = searchParams.get('invoice'); - return val ? val.split(',') : []; - }); const [dateRange, setDateRange] = useState<{ start: string; end: string }>(() => ({ start: searchParams.get('start') || '', end: searchParams.get('end') || '', @@ -59,11 +54,10 @@ export default function WorkItems({ workItems, clients, apps, settings }: WorkIt const params = new URLSearchParams(); if (selectedClients.length) params.set('clients', selectedClients.join(',')); if (selectedApps.length) params.set('apps', selectedApps.join(',')); - if (selectedInvoiceStatus.length) params.set('invoice', selectedInvoiceStatus.join(',')); if (dateRange.start) params.set('start', dateRange.start); if (dateRange.end) params.set('end', dateRange.end); setSearchParams(params, { replace: true }); - }, [selectedClients, selectedApps, selectedInvoiceStatus, dateRange, setSearchParams]); + }, [selectedClients, selectedApps, dateRange, setSearchParams]); useEffect(() => { if (isInitialMount.current) { @@ -95,12 +89,13 @@ export default function WorkItems({ workItems, clients, apps, settings }: WorkIt const hasActiveFilters = selectedClients.length > 0 || selectedApps.length > 0 || - selectedInvoiceStatus.length > 0 || !!dateRange.start || !!dateRange.end; const filtered = useMemo(() => { return workItems + // Membership: a sent-out item is an invoice, not a work order — exclude. + .filter(isWorkOrder) .filter((i) => i.status !== 'archived') .filter((i) => { if (selectedType === 'Change Requests') return i.type === 'changeRequest'; @@ -128,15 +123,13 @@ export default function WorkItems({ workItems, clients, apps, settings }: WorkIt .filter((i) => selectedClients.length === 0 || selectedClients.includes(i.clientId)) // app filter .filter((i) => selectedApps.length === 0 || (i.appId ? selectedApps.includes(i.appId) : false)) - // invoice status filter - .filter((i) => selectedInvoiceStatus.length === 0 || (i.invoiceStatus ? selectedInvoiceStatus.includes(i.invoiceStatus) : false)) // date range filter .filter((i) => { if (dateRange.start && i.createdAt < new Date(dateRange.start)) return false; if (dateRange.end && i.createdAt > new Date(dateRange.end + 'T23:59:59')) return false; return true; }); - }, [workItems, selectedType, selectedStatus, search, clientMap, selectedClients, selectedApps, selectedInvoiceStatus, dateRange]); + }, [workItems, selectedType, selectedStatus, search, clientMap, selectedClients, selectedApps, dateRange]); function toggleSelect(id: string) { const next = new Set(selectedIds); @@ -186,12 +179,11 @@ export default function WorkItems({ workItems, clients, apps, settings }: WorkIt function clearAllFilters() { setSelectedClients([]); setSelectedApps([]); - setSelectedInvoiceStatus([]); setDateRange({ start: '', end: '' }); } const [showAdvanced, setShowAdvanced] = useState(hasActiveFilters); - const activeFilterCount = selectedClients.length + selectedApps.length + selectedInvoiceStatus.length + (dateRange.start ? 1 : 0) + (dateRange.end ? 1 : 0); + const activeFilterCount = selectedClients.length + selectedApps.length + (dateRange.start ? 1 : 0) + (dateRange.end ? 1 : 0); return (
@@ -313,29 +305,6 @@ export default function WorkItems({ workItems, clients, apps, settings }: WorkIt ))} )} - - {/* Invoice status filter chips + dropdown */} - {selectedInvoiceStatus.map(status => ( - - {invoiceStatusLabels[status] ?? status} - - - ))} - {invoiceStatusOptions.filter(s => !selectedInvoiceStatus.includes(s)).length > 0 && ( - - )}
{/* Date range row */}