0d6ac3666e
Rename in 91045a1 was only partially reverted by later commits, leaving
~20 UI spots calling the project entity "Client" while table headers
already said "Project". Reverted all of them plus the /clients/:id
route (now /projects/:id canonical again, nothing was live yet).
Also: stop tracking .env.backfill (had a live admin password/token
committed - rotate those credentials), remove stale pre-redesign
layout.md superseded by REDESIGN-LAYOUT2.md.
417 lines
19 KiB
React
417 lines
19 KiB
React
import { useEffect, useMemo, useState } from 'react';
|
|
import jsPDF from 'jspdf';
|
|
import autoTable from 'jspdf-autotable';
|
|
import Layout from '../../components/Layout';
|
|
import SortTh from '../../components/SortTh';
|
|
import StatusBadge from '../../components/StatusBadge';
|
|
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')}`;
|
|
}
|
|
|
|
function billingStatusFromInvoices(items = []) {
|
|
// Only a sent or paid invoice counts as billed. Draft invoices and orphaned
|
|
// line items (invoice deleted → invoice null) are treated as Not Invoiced,
|
|
// matching what the Finances page actually shows as issued.
|
|
const issued = items.filter((item) => item.invoice?.status === 'sent' || item.invoice?.status === 'paid');
|
|
if (!issued.length) return 'not_started';
|
|
if (issued.some((item) => item.invoice?.status === 'paid')) return 'paid';
|
|
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';
|
|
return 'Not Invoiced';
|
|
}
|
|
|
|
function versionTypeLabel(versionNumber) {
|
|
return Number(versionNumber || 0) === 0 ? 'New' : 'Revision';
|
|
}
|
|
|
|
function versionStateFor(task, versionNumber) {
|
|
const currentVersion = Number(task?.current_version || 0);
|
|
const version = Number(versionNumber || 0);
|
|
if (version < currentVersion) return 'completed';
|
|
const status = task?.status || 'not_started';
|
|
// Approved work = done. client_approved (and legacy invoiced/paid billing leak)
|
|
// all collapse to Completed. Billing is shown separately in the Billing columns.
|
|
if (status === 'client_approved' || status === 'invoiced' || status === 'paid') return 'completed';
|
|
return status;
|
|
}
|
|
|
|
function rowSort(a, b) {
|
|
const companyCmp = (a.company_name || '').localeCompare(b.company_name || '');
|
|
if (companyCmp !== 0) return companyCmp;
|
|
const projectCmp = (a.project_name || '').localeCompare(b.project_name || '');
|
|
if (projectCmp !== 0) return projectCmp;
|
|
const taskCmp = (a.task_title || '').localeCompare(b.task_title || '');
|
|
if (taskCmp !== 0) return taskCmp;
|
|
return Number(a.version_number || 0) - Number(b.version_number || 0);
|
|
}
|
|
|
|
export default function TeamReports() {
|
|
const [loading, setLoading] = useState(true);
|
|
const [exporting, setExporting] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [companies, setCompanies] = useState([]);
|
|
const [projects, setProjects] = useState([]);
|
|
const [reportRows, setReportRows] = useState([]);
|
|
const [companyFilter, setCompanyFilter] = useState('all');
|
|
const [projectFilter, setProjectFilter] = useState('all');
|
|
const { sortKey, sortDir, toggle, sort } = useSortable('company_name');
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
async function load() {
|
|
setLoading(true);
|
|
setError('');
|
|
try {
|
|
const [
|
|
{ data: taskRows, error: tasksError },
|
|
{ data: submissionRows, error: submissionsError },
|
|
{ data: invoiceItemRows, error: invoiceItemsError },
|
|
{ data: subInvoiceRows, error: subInvoicesError },
|
|
] = await Promise.all([
|
|
supabase
|
|
.from('tasks')
|
|
.select('id, title, status, current_version, project:projects(id, name, company:companies(id, name))')
|
|
.order('title'),
|
|
supabase
|
|
.from('submissions')
|
|
.select('id, task_id, type, version_number, submitted_at, invoiced, description')
|
|
.in('type', ['initial', 'revision'])
|
|
.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)'),
|
|
supabase
|
|
.from('subcontractor_invoices')
|
|
.select('status, items:subcontractor_invoice_items(task_id, version_number, 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]));
|
|
const companiesMap = new Map();
|
|
const projectsMap = new Map();
|
|
(taskRows || []).forEach((task) => {
|
|
const company = task.project?.company;
|
|
if (company?.id && !companiesMap.has(company.id)) companiesMap.set(company.id, company);
|
|
if (task.project?.id && !projectsMap.has(task.project.id)) projectsMap.set(task.project.id, task.project);
|
|
});
|
|
|
|
const versionMap = new Map();
|
|
for (const submission of (submissionRows || [])) {
|
|
if (isReviewShadowDescription(submission.description)) continue;
|
|
const versionNumber = Number(submission.version_number || 0);
|
|
const key = `${submission.task_id}:${versionNumber}`;
|
|
if (!versionMap.has(key)) {
|
|
versionMap.set(key, {
|
|
task_id: submission.task_id,
|
|
version_number: versionNumber,
|
|
submission_ids: [],
|
|
first_submitted_at: submission.submitted_at || null,
|
|
});
|
|
}
|
|
const entry = versionMap.get(key);
|
|
entry.submission_ids.push(submission.id);
|
|
if (!entry.first_submitted_at || (submission.submitted_at && submission.submitted_at < entry.first_submitted_at)) {
|
|
entry.first_submitted_at = submission.submitted_at;
|
|
}
|
|
}
|
|
|
|
const invoiceItemGroups = new Map();
|
|
for (const item of (invoiceItemRows || [])) {
|
|
const versionNumber = item.submission?.version_number ?? (item.submission_id ? null : 0);
|
|
const taskId = item.submission?.task_id || item.task_id;
|
|
if (!taskId || versionNumber === null) continue;
|
|
const key = `${taskId}:${Number(versionNumber || 0)}`;
|
|
if (!invoiceItemGroups.has(key)) invoiceItemGroups.set(key, []);
|
|
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;
|
|
// Stored version preferred; legacy rows (null) fall back to description parsing
|
|
const version = item.version_number != null ? Number(item.version_number) : 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;
|
|
const project = task.project;
|
|
const taskVersions = [];
|
|
|
|
const initialKey = `${task.id}:0`;
|
|
if (versionMap.has(initialKey) || task) {
|
|
taskVersions.push(versionMap.get(initialKey) || {
|
|
task_id: task.id,
|
|
version_number: 0,
|
|
submission_ids: [],
|
|
first_submitted_at: null,
|
|
});
|
|
}
|
|
|
|
for (const versionEntry of versionMap.values()) {
|
|
if (versionEntry.task_id !== task.id) continue;
|
|
if (Number(versionEntry.version_number || 0) === 0) continue;
|
|
taskVersions.push(versionEntry);
|
|
}
|
|
|
|
taskVersions.sort((a, b) => Number(a.version_number || 0) - Number(b.version_number || 0));
|
|
|
|
for (const versionEntry of taskVersions) {
|
|
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 || '',
|
|
company_name: company?.name || '—',
|
|
project_id: project?.id || '',
|
|
project_name: project?.name || '—',
|
|
task_id: task.id,
|
|
task_title: task.title || 'Untitled',
|
|
version_number: Number(versionEntry.version_number || 0),
|
|
version_label: fmtVersion(versionEntry.version_number),
|
|
version_type: versionTypeLabel(versionEntry.version_number),
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
|
|
rows.sort(rowSort);
|
|
|
|
setCompanies([...companiesMap.values()].sort((a, b) => (a.name || '').localeCompare(b.name || '')));
|
|
setProjects([...projectsMap.values()].sort((a, b) => (a.name || '').localeCompare(b.name || '')));
|
|
setReportRows(rows);
|
|
} catch (err) {
|
|
if (!cancelled) setError(err.message || 'Failed to load reports.');
|
|
} finally {
|
|
if (!cancelled) setLoading(false);
|
|
}
|
|
}
|
|
|
|
load();
|
|
return () => { cancelled = true; };
|
|
}, []);
|
|
|
|
const filteredProjects = useMemo(() => {
|
|
if (companyFilter === 'all') return projects;
|
|
return projects.filter((project) => project.company?.id === companyFilter);
|
|
}, [projects, companyFilter]);
|
|
|
|
const visibleRows = useMemo(() => {
|
|
return reportRows.filter((row) => {
|
|
if (companyFilter !== 'all' && row.company_id !== companyFilter) return false;
|
|
if (projectFilter !== 'all' && row.project_id !== projectFilter) return false;
|
|
return true;
|
|
});
|
|
}, [reportRows, companyFilter, projectFilter]);
|
|
|
|
const sortedRows = useMemo(() => sort(visibleRows, (row, key) => {
|
|
if (key === 'company_name') return row.company_name || '';
|
|
if (key === 'project_name') return row.project_name || '';
|
|
if (key === 'task_title') return row.task_title || '';
|
|
if (key === 'version_number') return Number(row.version_number || 0);
|
|
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]);
|
|
|
|
const stats = useMemo(() => ({
|
|
total: visibleRows.length,
|
|
notInvoiced: visibleRows.filter((row) => row.billing_status === 'not_started').length,
|
|
invoiced: visibleRows.filter((row) => row.billing_status === 'invoiced').length,
|
|
paid: visibleRows.filter((row) => row.billing_status === 'paid').length,
|
|
}), [visibleRows]);
|
|
|
|
const handleExportPdf = async () => {
|
|
setExporting(true);
|
|
try {
|
|
const doc = new jsPDF({ orientation: 'landscape', unit: 'pt', format: 'letter', compress: true });
|
|
const title = 'Task Billing Report';
|
|
const companyName = companyFilter === 'all' ? 'All Companies' : (companies.find((company) => company.id === companyFilter)?.name || 'Selected Company');
|
|
const projectName = projectFilter === 'all' ? 'All Projects' : (projects.find((project) => project.id === projectFilter)?.name || 'Selected Project');
|
|
const generatedOn = new Date().toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' });
|
|
|
|
doc.setFontSize(18);
|
|
doc.text(title, 40, 40);
|
|
doc.setFontSize(10);
|
|
doc.text(`Company: ${companyName}`, 40, 60);
|
|
doc.text(`Project: ${projectName}`, 240, 60);
|
|
doc.text(`Generated: ${generatedOn}`, 440, 60);
|
|
|
|
autoTable(doc, {
|
|
startY: 78,
|
|
head: [['Company', 'Project', 'Task', 'Version', 'Type', 'Version Status', 'Client Billing', 'Sub Billing']],
|
|
body: sortedRows.map((row) => [
|
|
row.company_name,
|
|
row.project_name,
|
|
row.task_title,
|
|
row.version_label,
|
|
row.version_type,
|
|
row.version_state === 'client_review' ? 'In Review' : row.version_state === 'completed' ? 'Completed' : row.version_state.replace(/_/g, ' '),
|
|
row.billing_label,
|
|
row.sub_billing_label,
|
|
]),
|
|
styles: {
|
|
fontSize: 9,
|
|
cellPadding: 6,
|
|
lineColor: [225, 225, 225],
|
|
lineWidth: 0.25,
|
|
},
|
|
headStyles: {
|
|
fillColor: [245, 165, 35],
|
|
textColor: [17, 17, 17],
|
|
fontStyle: 'bold',
|
|
},
|
|
alternateRowStyles: {
|
|
fillColor: [250, 250, 250],
|
|
},
|
|
margin: { left: 40, right: 40, bottom: 30 },
|
|
didDrawPage: ({ pageNumber }) => {
|
|
doc.setFontSize(9);
|
|
doc.text(`Page ${pageNumber}`, doc.internal.pageSize.width - 70, doc.internal.pageSize.height - 14);
|
|
},
|
|
});
|
|
|
|
doc.save(`task-billing-report-${new Date().toISOString().slice(0, 10)}.pdf`);
|
|
} finally {
|
|
setExporting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Layout loading={loading}>
|
|
<div className="page-toolbar-grid" style={{ alignItems: 'center', marginBottom: 24 }}>
|
|
<div>
|
|
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 4 }}>Company</div>
|
|
<select value={companyFilter} onChange={(event) => { setCompanyFilter(event.target.value); setProjectFilter('all'); }}>
|
|
<option value="all">All Companies</option>
|
|
{companies.map((company) => (
|
|
<option key={company.id} value={company.id}>{company.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 4 }}>Project</div>
|
|
<select value={projectFilter} onChange={(event) => setProjectFilter(event.target.value)}>
|
|
<option value="all">All Projects</option>
|
|
{filteredProjects.map((project) => (
|
|
<option key={project.id} value={project.id}>{project.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'end' }}>
|
|
<LoadingButton className="btn btn-primary" loading={exporting} loadingText="Generating..." onClick={handleExportPdf} disabled={loading || sortedRows.length === 0}>
|
|
Export PDF
|
|
</LoadingButton>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="dash-stat-grid" style={{ gridTemplateColumns: '1fr 1fr 1fr 1fr', marginBottom: 24 }}>
|
|
<div className="stat-card">
|
|
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 6 }}>Rows</div>
|
|
<div style={{ fontSize: 28, lineHeight: 1.1 }}>{stats.total}</div>
|
|
</div>
|
|
<div className="stat-card">
|
|
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 6 }}>Not Invoiced</div>
|
|
<div style={{ fontSize: 28, lineHeight: 1.1 }}>{stats.notInvoiced}</div>
|
|
</div>
|
|
<div className="stat-card">
|
|
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 6 }}>Invoiced</div>
|
|
<div style={{ fontSize: 28, lineHeight: 1.1 }}>{stats.invoiced}</div>
|
|
</div>
|
|
<div className="stat-card">
|
|
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 6 }}>Paid</div>
|
|
<div style={{ fontSize: 28, lineHeight: 1.1 }}>{stats.paid}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="table-wrapper" style={{ minHeight: 0 }}>
|
|
{loading ? (
|
|
<div style={{ padding: 24, fontSize: 13, color: 'var(--text-muted)', textAlign: 'center' }}>Loading report…</div>
|
|
) : error ? (
|
|
<div style={{ padding: 24, fontSize: 13, color: 'var(--danger)', textAlign: 'center' }}>{error}</div>
|
|
) : sortedRows.length === 0 ? (
|
|
<div style={{ padding: 24, fontSize: 13, color: 'var(--text-muted)', textAlign: 'center' }}>No report rows for the selected filters.</div>
|
|
) : (
|
|
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
|
<colgroup>
|
|
<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>
|
|
<SortTh col="company_name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Company</SortTh>
|
|
<SortTh col="project_name" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Project</SortTh>
|
|
<SortTh col="task_title" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Task Name</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_state" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Version Status</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>
|
|
{sortedRows.map((row) => (
|
|
<tr key={row.key}>
|
|
<td>{row.company_name}</td>
|
|
<td>{row.project_name}</td>
|
|
<td>{row.task_title}</td>
|
|
<td>{row.version_label}</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.billing_status} label={row.billing_label} /></td>
|
|
<td><StatusBadge status={row.sub_billing_status} label={row.sub_billing_label} /></td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
</Layout>
|
|
);
|
|
}
|