fix: crash bugs in TeamInvoices + faster load
Bug fixes: - TeamInvoices: add useAuth import/currentUser (invoice-create crash) - TeamInvoices: setChartYear -> setExportYear (year-dropdown crash) - TeamDashboard: drop redundant setState-in-effect (cascading renders) - Companies: delete dead _UnusedClientCompanies (illegal hook calls) - annotate intentional empty PDF-fallback catches Load speed: - preconnect/dns-prefetch to Supabase origin - lazy-load heic-to in Converters: page chunk 2737KB -> 9KB - split recharts into its own 'charts' vendor chunk Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,373 @@
|
||||
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';
|
||||
|
||||
function fmtVersion(versionNumber) {
|
||||
return `R${String(Number(versionNumber || 0)).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function billingStatusFromInvoices(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';
|
||||
return task?.status || 'not_started';
|
||||
}
|
||||
|
||||
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 },
|
||||
] = 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)')
|
||||
]);
|
||||
|
||||
if (tasksError) throw tasksError;
|
||||
if (submissionsError) throw submissionsError;
|
||||
if (invoiceItemsError) throw invoiceItemsError;
|
||||
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 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);
|
||||
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),
|
||||
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 || '';
|
||||
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', '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 === 'client_approved' ? 'Approved' : row.version_state.replace(/_/g, ' '),
|
||||
row.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 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%' }} />
|
||||
</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}>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>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user