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:
@@ -25,7 +25,10 @@
|
|||||||
"Bash(sed -n '88,100p' src/pages/Tasks.jsx)",
|
"Bash(sed -n '88,100p' src/pages/Tasks.jsx)",
|
||||||
"Bash(sed -n '570,600p' src/pages/Tasks.jsx)",
|
"Bash(sed -n '570,600p' src/pages/Tasks.jsx)",
|
||||||
"Bash(git commit -q -m 'fix: hide counter on All tab in projects table *)",
|
"Bash(git commit -q -m 'fix: hide counter on All tab in projects table *)",
|
||||||
"Bash(git commit -q -m 'fix: guard task status updates on invoice lifecycle transitions *)"
|
"Bash(git commit -q -m 'fix: guard task status updates on invoice lifecycle transitions *)",
|
||||||
|
"Bash(vercel ls *)",
|
||||||
|
"Bash(vercel --prod)",
|
||||||
|
"Bash(git commit -q -m 'feat: add Sub Billing column to billing report \\(sub invoiced/paid per version\\) *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import LoadingButton from '../../components/LoadingButton';
|
|||||||
import { useSortable } from '../../hooks/useSortable';
|
import { useSortable } from '../../hooks/useSortable';
|
||||||
import { supabase } from '../../lib/supabase';
|
import { supabase } from '../../lib/supabase';
|
||||||
import { isReviewShadowDescription } from '../../lib/taskVersions';
|
import { isReviewShadowDescription } from '../../lib/taskVersions';
|
||||||
|
import { parseVersionFromItemDescription } from '../../lib/invoiceVersionRules';
|
||||||
|
|
||||||
function fmtVersion(versionNumber) {
|
function fmtVersion(versionNumber) {
|
||||||
return `R${String(Number(versionNumber || 0)).padStart(2, '0')}`;
|
return `R${String(Number(versionNumber || 0)).padStart(2, '0')}`;
|
||||||
@@ -19,6 +20,13 @@ function billingStatusFromInvoices(items = []) {
|
|||||||
return 'invoiced';
|
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) {
|
function billingLabel(status) {
|
||||||
if (status === 'paid') return 'Paid';
|
if (status === 'paid') return 'Paid';
|
||||||
if (status === 'invoiced') return 'Invoiced';
|
if (status === 'invoiced') return 'Invoiced';
|
||||||
@@ -72,6 +80,7 @@ export default function TeamReports() {
|
|||||||
{ data: taskRows, error: tasksError },
|
{ data: taskRows, error: tasksError },
|
||||||
{ data: submissionRows, error: submissionsError },
|
{ data: submissionRows, error: submissionsError },
|
||||||
{ data: invoiceItemRows, error: invoiceItemsError },
|
{ data: invoiceItemRows, error: invoiceItemsError },
|
||||||
|
{ data: subInvoiceRows, error: subInvoicesError },
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
supabase
|
supabase
|
||||||
.from('tasks')
|
.from('tasks')
|
||||||
@@ -84,12 +93,17 @@ export default function TeamReports() {
|
|||||||
.order('submitted_at', { ascending: true }),
|
.order('submitted_at', { ascending: true }),
|
||||||
supabase
|
supabase
|
||||||
.from('invoice_items')
|
.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 (tasksError) throw tasksError;
|
||||||
if (submissionsError) throw submissionsError;
|
if (submissionsError) throw submissionsError;
|
||||||
if (invoiceItemsError) throw invoiceItemsError;
|
if (invoiceItemsError) throw invoiceItemsError;
|
||||||
|
if (subInvoicesError) throw subInvoicesError;
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
const taskMap = new Map((taskRows || []).map((task) => [task.id, task]));
|
const taskMap = new Map((taskRows || []).map((task) => [task.id, task]));
|
||||||
@@ -131,6 +145,18 @@ export default function TeamReports() {
|
|||||||
invoiceItemGroups.get(key).push(item);
|
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 = [];
|
const rows = [];
|
||||||
for (const task of (taskRows || [])) {
|
for (const task of (taskRows || [])) {
|
||||||
const company = task.project?.company;
|
const company = task.project?.company;
|
||||||
@@ -159,6 +185,7 @@ export default function TeamReports() {
|
|||||||
const key = `${task.id}:${Number(versionEntry.version_number || 0)}`;
|
const key = `${task.id}:${Number(versionEntry.version_number || 0)}`;
|
||||||
const invoiceItems = invoiceItemGroups.get(key) || [];
|
const invoiceItems = invoiceItemGroups.get(key) || [];
|
||||||
const billingStatus = billingStatusFromInvoices(invoiceItems);
|
const billingStatus = billingStatusFromInvoices(invoiceItems);
|
||||||
|
const subBillingStatus = subBillingStatusFromInvoices(subBillingGroups.get(key) || []);
|
||||||
rows.push({
|
rows.push({
|
||||||
key,
|
key,
|
||||||
company_id: company?.id || '',
|
company_id: company?.id || '',
|
||||||
@@ -173,6 +200,8 @@ export default function TeamReports() {
|
|||||||
version_state: versionStateFor(task, versionEntry.version_number),
|
version_state: versionStateFor(task, versionEntry.version_number),
|
||||||
billing_status: billingStatus,
|
billing_status: billingStatus,
|
||||||
billing_label: billingLabel(billingStatus),
|
billing_label: billingLabel(billingStatus),
|
||||||
|
sub_billing_status: subBillingStatus,
|
||||||
|
sub_billing_label: billingLabel(subBillingStatus),
|
||||||
first_submitted_at: versionEntry.first_submitted_at,
|
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_type') return row.version_type || '';
|
||||||
if (key === 'version_state') return row.version_state || '';
|
if (key === 'version_state') return row.version_state || '';
|
||||||
if (key === 'billing_label') return row.billing_label || '';
|
if (key === 'billing_label') return row.billing_label || '';
|
||||||
|
if (key === 'sub_billing_label') return row.sub_billing_label || '';
|
||||||
return '';
|
return '';
|
||||||
}), [visibleRows, sort]);
|
}), [visibleRows, sort]);
|
||||||
|
|
||||||
@@ -243,7 +273,7 @@ export default function TeamReports() {
|
|||||||
|
|
||||||
autoTable(doc, {
|
autoTable(doc, {
|
||||||
startY: 78,
|
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) => [
|
body: sortedRows.map((row) => [
|
||||||
row.company_name,
|
row.company_name,
|
||||||
row.project_name,
|
row.project_name,
|
||||||
@@ -252,6 +282,7 @@ export default function TeamReports() {
|
|||||||
row.version_type,
|
row.version_type,
|
||||||
row.version_state === 'client_review' ? 'In Review' : row.version_state === 'client_approved' ? 'Approved' : row.version_state.replace(/_/g, ' '),
|
row.version_state === 'client_review' ? 'In Review' : row.version_state === 'client_approved' ? 'Approved' : row.version_state.replace(/_/g, ' '),
|
||||||
row.billing_label,
|
row.billing_label,
|
||||||
|
row.sub_billing_label,
|
||||||
]),
|
]),
|
||||||
styles: {
|
styles: {
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
@@ -337,13 +368,14 @@ export default function TeamReports() {
|
|||||||
) : (
|
) : (
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col style={{ width: '16%' }} />
|
<col style={{ width: '15%' }} />
|
||||||
<col style={{ width: '16%' }} />
|
<col style={{ width: '15%' }} />
|
||||||
<col style={{ width: '24%' }} />
|
<col style={{ width: '20%' }} />
|
||||||
<col style={{ width: '8%' }} />
|
<col style={{ width: '7%' }} />
|
||||||
<col style={{ width: '10%' }} />
|
<col style={{ width: '9%' }} />
|
||||||
<col style={{ width: '13%' }} />
|
<col style={{ width: '12%' }} />
|
||||||
<col style={{ width: '13%' }} />
|
<col style={{ width: '11%' }} />
|
||||||
|
<col style={{ width: '11%' }} />
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -353,7 +385,8 @@ export default function TeamReports() {
|
|||||||
<SortTh col="version_number" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>R#</SortTh>
|
<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_type" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Type</SortTh>
|
||||||
<SortTh col="version_state" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Version Status</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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_number === 0 ? 'initial' : 'revision'} label={row.version_type} /></td>
|
||||||
<td><StatusBadge status={row.version_state} /></td>
|
<td><StatusBadge status={row.version_state} /></td>
|
||||||
<td><StatusBadge status={row.billing_status} label={row.billing_label} /></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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
Reference in New Issue
Block a user