feat: add Sub Billing column to billing report (sub invoiced/paid per version)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Krao Hasanee
2026-06-09 23:32:42 -05:00
parent 12c9a1a093
commit c891f53d2a
2 changed files with 48 additions and 11 deletions
+44 -10
View File
@@ -8,6 +8,7 @@ import LoadingButton from '../../components/LoadingButton';
import { useSortable } from '../../hooks/useSortable';
import { supabase } from '../../lib/supabase';
import { isReviewShadowDescription } from '../../lib/taskVersions';
import { parseVersionFromItemDescription } from '../../lib/invoiceVersionRules';
function fmtVersion(versionNumber) {
return `R${String(Number(versionNumber || 0)).padStart(2, '0')}`;
@@ -19,6 +20,13 @@ function billingStatusFromInvoices(items = []) {
return 'invoiced';
}
// Sub billing: did the subcontractor invoice us for this version, and did we pay them?
function subBillingStatusFromInvoices(items = []) {
if (!items.length) return 'not_started';
if (items.some((item) => item.invoice_status === 'paid')) return 'paid';
return 'invoiced';
}
function billingLabel(status) {
if (status === 'paid') return 'Paid';
if (status === 'invoiced') return 'Invoiced';
@@ -72,6 +80,7 @@ export default function TeamReports() {
{ data: taskRows, error: tasksError },
{ data: submissionRows, error: submissionsError },
{ data: invoiceItemRows, error: invoiceItemsError },
{ data: subInvoiceRows, error: subInvoicesError },
] = await Promise.all([
supabase
.from('tasks')
@@ -84,12 +93,17 @@ export default function TeamReports() {
.order('submitted_at', { ascending: true }),
supabase
.from('invoice_items')
.select('id, task_id, submission_id, invoice:invoices(id, status), submission:submissions(id, task_id, version_number)')
.select('id, task_id, submission_id, invoice:invoices(id, status), submission:submissions(id, task_id, version_number)'),
supabase
.from('subcontractor_invoices')
.select('status, items:subcontractor_invoice_items(task_id, description)')
.in('status', ['submitted', 'approved', 'paid'])
]);
if (tasksError) throw tasksError;
if (submissionsError) throw submissionsError;
if (invoiceItemsError) throw invoiceItemsError;
if (subInvoicesError) throw subInvoicesError;
if (cancelled) return;
const taskMap = new Map((taskRows || []).map((task) => [task.id, task]));
@@ -131,6 +145,18 @@ export default function TeamReports() {
invoiceItemGroups.get(key).push(item);
}
const subBillingGroups = new Map();
for (const subInvoice of (subInvoiceRows || [])) {
const status = subInvoice.status;
for (const item of (subInvoice.items || [])) {
if (!item.task_id) continue;
const version = parseVersionFromItemDescription(item.description);
const key = `${item.task_id}:${version}`;
if (!subBillingGroups.has(key)) subBillingGroups.set(key, []);
subBillingGroups.get(key).push({ invoice_status: status });
}
}
const rows = [];
for (const task of (taskRows || [])) {
const company = task.project?.company;
@@ -159,6 +185,7 @@ export default function TeamReports() {
const key = `${task.id}:${Number(versionEntry.version_number || 0)}`;
const invoiceItems = invoiceItemGroups.get(key) || [];
const billingStatus = billingStatusFromInvoices(invoiceItems);
const subBillingStatus = subBillingStatusFromInvoices(subBillingGroups.get(key) || []);
rows.push({
key,
company_id: company?.id || '',
@@ -173,6 +200,8 @@ export default function TeamReports() {
version_state: versionStateFor(task, versionEntry.version_number),
billing_status: billingStatus,
billing_label: billingLabel(billingStatus),
sub_billing_status: subBillingStatus,
sub_billing_label: billingLabel(subBillingStatus),
first_submitted_at: versionEntry.first_submitted_at,
});
}
@@ -215,6 +244,7 @@ export default function TeamReports() {
if (key === 'version_type') return row.version_type || '';
if (key === 'version_state') return row.version_state || '';
if (key === 'billing_label') return row.billing_label || '';
if (key === 'sub_billing_label') return row.sub_billing_label || '';
return '';
}), [visibleRows, sort]);
@@ -243,7 +273,7 @@ export default function TeamReports() {
autoTable(doc, {
startY: 78,
head: [['Company', 'Project', 'Task', 'Version', 'Type', 'Version Status', 'Billing']],
head: [['Company', 'Project', 'Task', 'Version', 'Type', 'Version Status', 'Client Billing', 'Sub Billing']],
body: sortedRows.map((row) => [
row.company_name,
row.project_name,
@@ -252,6 +282,7 @@ export default function TeamReports() {
row.version_type,
row.version_state === 'client_review' ? 'In Review' : row.version_state === 'client_approved' ? 'Approved' : row.version_state.replace(/_/g, ' '),
row.billing_label,
row.sub_billing_label,
]),
styles: {
fontSize: 9,
@@ -337,13 +368,14 @@ export default function TeamReports() {
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '16%' }} />
<col style={{ width: '16%' }} />
<col style={{ width: '24%' }} />
<col style={{ width: '8%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '13%' }} />
<col style={{ width: '13%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '20%' }} />
<col style={{ width: '7%' }} />
<col style={{ width: '9%' }} />
<col style={{ width: '12%' }} />
<col style={{ width: '11%' }} />
<col style={{ width: '11%' }} />
</colgroup>
<thead>
<tr>
@@ -353,7 +385,8 @@ export default function TeamReports() {
<SortTh col="version_number" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>R#</SortTh>
<SortTh col="version_type" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Type</SortTh>
<SortTh col="version_state" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Version Status</SortTh>
<SortTh col="billing_label" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Billing</SortTh>
<SortTh col="billing_label" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Client Billing</SortTh>
<SortTh col="sub_billing_label" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Sub Billing</SortTh>
</tr>
</thead>
<tbody>
@@ -366,6 +399,7 @@ export default function TeamReports() {
<td><StatusBadge status={row.version_number === 0 ? 'initial' : 'revision'} label={row.version_type} /></td>
<td><StatusBadge status={row.version_state} /></td>
<td><StatusBadge status={row.billing_status} label={row.billing_label} /></td>
<td><StatusBadge status={row.sub_billing_status} label={row.sub_billing_label} /></td>
</tr>
))}
</tbody>