}>
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 */}