Files
fourge-portal/src/pages/team/TeamReports.jsx
T
Krao Hasanee 0d6ac3666e fix: revert leftover Project→Client mislabels, drop tracked .env.backfill
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.
2026-07-15 15:14:57 -04:00

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>
);
}