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 (
Company
Project
Export PDF
Rows
{stats.total}
Not Invoiced
{stats.notInvoiced}
Invoiced
{stats.invoiced}
Paid
{stats.paid}
{loading ? (
Loading report…
) : error ? (
{error}
) : sortedRows.length === 0 ? (
No report rows for the selected filters.
) : ( CompanyProjectTask NameR#TypeVersion StatusClient BillingSub Billing {sortedRows.map((row) => ( ))}
{row.company_name} {row.project_name} {row.task_title} {row.version_label}
)}
); }