Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ function ContractorLayout() {
onNotificationsClick={handleNotifToggle}
onOpenSearch={() => setSearchOpen(true)}
/>
<div className="flex-1 min-h-0 overflow-y-auto pb-24 md:pb-0">
<div className="flex-1 min-h-0 overflow-y-auto pb-24 md:pb-12">
<div className="p-4 md:p-8 flex flex-col h-full min-h-full">
<Suspense fallback={<Loading />}>
<Outlet />
Expand Down
7 changes: 6 additions & 1 deletion web/src/lib/finance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
18 changes: 11 additions & 7 deletions web/src/lib/finance.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { WorkItem, Client, WorkItemType } from './types';
import { isInvoice } from './workItem';

// ── Exported Types ──────────────────────────────────────────────────────────

Expand Down Expand Up @@ -288,30 +289,33 @@ 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;
}
return active.filter(item => item.invoiceStatus === status);
}

/**
* 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<string, number> {
const counts: Record<string, number> = { 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;
Expand Down
55 changes: 55 additions & 0 deletions web/src/lib/workItem.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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));
});
});
24 changes: 24 additions & 0 deletions web/src/lib/workItem.ts
Original file line number Diff line number Diff line change
@@ -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);
}
5 changes: 3 additions & 2 deletions web/src/routes/contractor/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
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 {
Expand Down Expand Up @@ -107,7 +108,7 @@
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();
Expand Down Expand Up @@ -154,7 +155,7 @@
});
}
return days;
}, [workItems, now.toDateString()]);

Check warning on line 158 in web/src/routes/contractor/Dashboard.tsx

View workflow job for this annotation

GitHub Actions / Web (lint + build)

React Hook useMemo has a missing dependency: 'now'. Either include it or remove the dependency array

/* ── Desktop: Monthly revenue for 6 months ─────── */
const monthlyRevenueData = useMemo(() => {
Expand Down Expand Up @@ -234,7 +235,7 @@
const pipelineStages = useMemo(() => {
const counts: Record<string, number> = { 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)' },
Expand Down
43 changes: 6 additions & 37 deletions web/src/routes/contractor/WorkItems.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<string, string> = { draft: 'Draft', sent: 'Sent', paid: 'Paid', overdue: 'Overdue' };

export default function WorkItems({ workItems, clients, apps, settings }: WorkItemsProps) {
const [searchParams, setSearchParams] = useSearchParams();
Expand All @@ -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<string[]>(() => {
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') || '',
Expand All @@ -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) {
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 (
<div className="animate-fade-in-up">
Expand Down Expand Up @@ -313,29 +305,6 @@ export default function WorkItems({ workItems, clients, apps, settings }: WorkIt
))}
</select>
)}

{/* Invoice status filter chips + dropdown */}
{selectedInvoiceStatus.map(status => (
<span key={status} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] bg-[var(--accent)]/10 text-[var(--accent)] border border-[var(--accent)]/20">
{invoiceStatusLabels[status] ?? status}
<button onClick={() => setSelectedInvoiceStatus(prev => prev.filter(s => s !== status))} className="hover:text-[var(--color-red)] leading-none">&times;</button>
</span>
))}
{invoiceStatusOptions.filter(s => !selectedInvoiceStatus.includes(s)).length > 0 && (
<select
value=""
onChange={(e) => {
const val = e.target.value;
if (val && !selectedInvoiceStatus.includes(val)) setSelectedInvoiceStatus(prev => [...prev, val]);
}}
className="h-7 px-2 rounded-md border border-[var(--border)] bg-[var(--bg-input)] text-[var(--text-primary)] text-[11px] min-w-0"
>
<option value="">+ Invoice</option>
{invoiceStatusOptions.filter(s => !selectedInvoiceStatus.includes(s)).map(s => (
<option key={s} value={s}>{invoiceStatusLabels[s]}</option>
))}
</select>
)}
</div>

{/* Date range row */}
Expand Down
Loading