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:
Krao Hasanee
2026-06-08 12:59:11 -04:00
parent 85625f4d95
commit 04e0911e9f
101 changed files with 11786 additions and 7445 deletions
+23 -24
View File
@@ -7,6 +7,8 @@ import { useAuth } from '../../context/AuthContext';
import { generateInvoicePDF } from '../../lib/invoice';
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
import { withTimeout } from '../../lib/withTimeout';
import { isReviewShadowDescription } from '../../lib/taskVersions';
import { getRevisionChargeQuantity, isCompletedVersionEligible, isInitialVersionEligible } from '../../lib/invoiceVersionRules';
// Computed at module load time — stable for the lifetime of the invoice creation session
const INVOICE_TODAY = new Date().toISOString().split('T')[0];
@@ -29,12 +31,6 @@ const buildRevisionItemDescription = (revision) => {
return `${projectName}${taskTitle} • Revision ${versionLabel}`;
};
const getRevisionChargeQuantity = (revision) => {
const version = Number(revision?.version_number || 0);
// Incremental billing: R01 is free, each uninvoiced revision from R02+ is one charge unit.
return version >= 2 ? 1 : 0;
};
export default function CreateInvoice() {
const navigate = useNavigate();
const { currentUser } = useAuth();
@@ -96,30 +92,30 @@ export default function CreateInvoice() {
setInvoiceEmail(recipients[0]?.email || companies.find(c => c.id === selectedCompanyId)?.contact_email || '');
if (projects && projects.length > 0) {
const projectIds = projects.map(p => p.id);
const { data: tasks } = await supabase
const { data: companyTasks } = await supabase
.from('tasks')
.select('*, project:projects(name), submissions(service_type, type, version_number)')
.in('project_id', projectIds)
.eq('invoiced', false)
.eq('status', 'client_approved');
const approvedTaskIds = (tasks || []).map((t) => t.id).filter(Boolean);
const { data: revisions } = approvedTaskIds.length > 0
.in('status', ['not_started', 'in_progress', 'on_hold', 'client_review', 'client_approved', 'invoiced', 'paid']);
const companyTaskIds = (companyTasks || []).map((t) => t.id).filter(Boolean);
const { data: revisions } = companyTaskIds.length > 0
? await supabase
.from('submissions')
.select('*, task:tasks(id, title, project:projects(name), submissions(service_type, type))')
.select('*, task:tasks(id, title, status, current_version, project:projects(name), submissions(service_type, type))')
.eq('type', 'revision')
.or('revision_type.eq.client_revision,revision_type.is.null')
.eq('invoiced', false)
.in('task_id', approvedTaskIds)
.in('task_id', companyTaskIds)
.order('submitted_at', { ascending: false })
: { data: [] };
const tasksWithService = (tasks || []).map(t => {
const tasksWithService = (companyTasks || []).filter(isInitialVersionEligible).map(t => {
const initial = (t.submissions || []).find(s => s.type === 'initial') || (t.submissions || [])[0];
return { ...t, service_type: initial?.service_type || t.title };
});
setUninvoicedTasks(tasksWithService);
// Deduplicate by (task_id, version_number) — multiple submission rows per version (e.g. "Add Files") must not produce multiple invoice charges
const revMap = new Map();
for (const rev of (revisions || [])) {
for (const rev of (revisions || []).filter(rev => !isReviewShadowDescription(rev.description))) {
if (!isCompletedVersionEligible(rev.task, rev.version_number)) continue;
const key = `${rev.task_id}:${rev.version_number}`;
if (!revMap.has(key)) revMap.set(key, rev);
}
@@ -152,7 +148,7 @@ export default function CreateInvoice() {
const serviceLabel = getRevisionServiceType(revision);
const description = buildRevisionItemDescription(revision);
const price = priceList.find(p => p.service_type === serviceLabel && p.price_type === 'revision');
const revisionChargeQty = getRevisionChargeQuantity(revision);
const revisionChargeQty = getRevisionChargeQuantity(revision?.version_number);
const quantity = revisionChargeQty > 0 ? revisionChargeQty : 1;
const unitPrice = revisionChargeQty > 0 ? (price?.price || '') : 0;
setItems(prev => {
@@ -324,7 +320,7 @@ export default function CreateInvoice() {
return (
<Layout>
<button className="back-link" onClick={() => navigate('/invoices')}> Back to Invoices</button>
<button className="back-link" onClick={() => navigate('/finances')}> Back to Invoices</button>
<div className="page-header">
<div>
@@ -403,9 +399,10 @@ export default function CreateInvoice() {
<div key={task.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 400 }}>{buildNewItemDescription(task)}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
{task.service_type || 'Other'} {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', fontSize: 11, color: 'var(--text-muted)' }}>
<span className="badge badge-initial">New</span>
<span>{task.service_type || 'Other'} {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}</span>
</div>
</div>
<button
className={`btn btn-sm ${alreadyAdded ? 'btn-outline' : 'btn-primary'}`}
@@ -441,13 +438,15 @@ export default function CreateInvoice() {
{uninvoicedRevisions.map(rev => {
const revServiceType = getRevisionServiceType(rev);
const price = priceList.find(p => p.service_type === revServiceType && p.price_type === 'revision');
const revisionChargeQty = getRevisionChargeQuantity(rev?.version_number);
const alreadyAdded = items.some(i => i.submission_id === rev.id);
return (
<div key={rev.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 400 }}>{buildRevisionItemDescription(rev)}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
{revServiceType || 'Other'} {price ? `$${Number(price.price).toFixed(2)}` : 'No price set'}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', fontSize: 11, color: 'var(--text-muted)' }}>
<span className="badge badge-client_revision">Revision</span>
<span>{revServiceType || 'Other'} {revisionChargeQty > 0 ? (price ? `$${Number(price.price).toFixed(2)}` : 'No price set') : 'Free'}</span>
</div>
</div>
<button
@@ -537,7 +536,7 @@ export default function CreateInvoice() {
<LoadingButton className="btn btn-primary" onClick={() => handleSave('sent')} loading={saving} loadingText="Finalizing & Sending...">
Finalise & Send
</LoadingButton>
<button className="btn btn-outline" onClick={() => navigate('/invoices')}>Cancel</button>
<button className="btn btn-outline" onClick={() => navigate('/finances')}>Cancel</button>
</div>
</Layout>
);
+3 -27
View File
@@ -3,7 +3,6 @@ import { useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
import { sendEmail } from '../../lib/email';
const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', marginBottom: 4 };
const FIELD_INPUT_STYLE = { minHeight: 42, margin: 0 };
@@ -180,35 +179,12 @@ export default function CreateSubcontractorPO() {
return;
}
if (status === 'sent') {
const subcontractor = externalProfiles.find(profile => profile.id === form.profile_id);
const project = projects.find(row => row.id === form.project_id);
if (subcontractor?.email) {
try {
await sendEmail('subcontractor_po_sent', subcontractor.email, {
poNumber: data.po_number || 'Purchase Order',
subcontractorName: subcontractor.name || 'there',
projectName: project?.name || 'Subcontractor Work',
companyName: project?.company?.name || 'Fourge Branding',
amount: `$${total.toFixed(2)}`,
dueDate: form.due_date ? new Date(form.due_date).toLocaleDateString() : 'Not set',
terms: form.terms.trim() || 'Net 15',
scope: lineItems.map(item => `${item.description} - $${item.amount.toFixed(2)}`).join('\n'),
portalUrl: 'https://portal.fourgebranding.com/my-purchase-orders',
});
} catch (emailError) {
alert(`PO was finalized, but the email failed: ${emailError.message || 'unknown error'}`);
}
} else {
alert('PO was finalized, but this subcontractor has no email on file.');
}
}
navigate('/invoices', { state: { tab: 'subcontractor-po' } });
navigate('/finances', { state: { tab: 'subcontractor-po' } });
};
return (
<Layout>
<button className="back-link" onClick={() => navigate('/invoices', { state: { tab: 'subcontractor-po' } })}> Back to Subcontractor PO</button>
<Layout loading={loading}>
<button className="back-link" onClick={() => navigate('/finances', { state: { tab: 'subcontractor-po' } })}> Back to Subcontractor PO</button>
<div className="page-header">
<div>
+228 -249
View File
@@ -1,15 +1,18 @@
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { useState, useEffect, useMemo, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import PageLoader from '../../components/PageLoader';
import SortTh from '../../components/SortTh';
import ProfileAvatar from '../../components/ProfileAvatar';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
import { readPageCache, writePageCache } from '../../lib/pageCache';
import { withTimeout } from '../../lib/withTimeout';
import { getDeadlineSourceSubmission } from '../../lib/taskDeadlines';
import { useSortable } from '../../hooks/useSortable';
import { useRefetchOnFocus } from '../../hooks/useRefetchOnFocus';
import { useRealtimeSubscription } from '../../hooks/useRealtimeSubscription';
import { useLiveRefresh } from '../../hooks/useLiveRefresh';
import { resolveScopedWorkIds } from '../../lib/workScope';
import { getSubmissionDisplayName, mergeSubmissionDisplayNames } from '../../lib/submissionDisplay';
// ─── Helpers ──────────────────────────────────────────────────────────────
@@ -32,22 +35,6 @@ function fmtMoney(n) {
return `$${Number(n).toFixed(2)}`;
}
function buildClientHighlights(companies, projects, tasks, clientProfiles, companyMemberships = [], invoices = []) {
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
return (companies || []).map(company => {
const companyProjects = (projects || []).filter(p => p.company_id === company.id);
const primaryContact = (clientProfiles || []).find(p =>
p.company_id === company.id ||
(companyMemberships || []).some(m => m.company_id === company.id && m.profile_id === p.id)
);
const openTasks = (tasks || []).filter(t => companyProjects.some(p => p.id === t.project_id) && !doneStatuses.includes(t.status));
const companyInvoices = (invoices || []).filter(i => i.company_id === company.id);
const outstandingTotal = companyInvoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total || 0), 0);
const paidTotal = companyInvoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
return { company, primaryContact, projectCount: companyProjects.length, openTaskCount: openTasks.length, outstandingTotal, paidTotal };
});
}
const ACTION_ICON = {
task_started: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> },
task_resumed: { bg: 'rgba(96,165,250,0.15)', color: '#60a5fa', path: <polygon points="6,4 20,12 6,20" fill="currentColor"/> },
@@ -75,25 +62,6 @@ function ActionIcon({ actionKey, size = 27 }) {
);
}
function Avatar({ name, size = 27 }) {
return (
<div style={{ width: size, height: size, borderRadius: '50%', background: 'rgba(245,165,35,0.15)', flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="#F5A523">
<circle cx="12" cy="8" r="4" />
<path d="M4 20c0-4.4 3.6-7 8-7s8 2.6 8 7" />
</svg>
</div>
);
}
function InitialPortrait({ name }) {
return (
<div style={{ width: 27, height: 27, borderRadius: '50%', background: 'var(--accent)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: '#000', lineHeight: 1 }}>{(name || '?')[0].toUpperCase()}</span>
</div>
);
}
function smoothCurve(pts) {
if (pts.length < 2) return '';
let d = `M${pts[0][0].toFixed(1)},${pts[0][1].toFixed(1)}`;
@@ -373,170 +341,159 @@ function TasksInProgressCard({ tasks = [] }) {
);
}
function HotItemsCard({ submissions, tasks, isClient = false }) {
function HotItemsCard({ submissions, tasks, isClient = false, isExternal = false, currentUserId = null, cardHeight = null }) {
const navigate = useNavigate();
const { sortKey, sortDir, toggle, sort } = useSortable('deadline');
const taskMap = new Map((tasks || []).map(t => [t.id, t]));
const seen = new Set();
const hotItems = (submissions || [])
.filter(s => {
const task = taskMap.get(s.task_id);
if (isClient) return task?.status === 'client_review' && !seen.has(s.task_id) && seen.add(s.task_id);
const activeStatuses = ['not_started', 'in_progress', 'client_review', 'on_hold'];
return s.is_hot && activeStatuses.includes(task?.status) && !seen.has(s.task_id) && seen.add(s.task_id);
})
.map(s => ({ ...s, task: taskMap.get(s.task_id) }))
.filter(s => s.task)
.sort((a, b) => {
if (!a.deadline && !b.deadline) return 0;
if (!a.deadline) return 1;
if (!b.deadline) return -1;
return new Date(a.deadline) - new Date(b.deadline);
})
.slice(0, 7);
const activeStatuses = ['not_started', 'in_progress', 'client_review', 'on_hold'];
const hotItems = isExternal
? (tasks || [])
.filter(task => task?.assigned_to === currentUserId && activeStatuses.includes(task?.status))
.sort((a, b) => {
if (!a.deadline && !b.deadline) return 0;
if (!a.deadline) return 1;
if (!b.deadline) return -1;
return new Date(a.deadline) - new Date(b.deadline);
})
.slice(0, 8)
: (submissions || [])
.filter(s => {
const task = taskMap.get(s.task_id);
if (isClient) return task?.status === 'client_review' && !seen.has(s.task_id) && seen.add(s.task_id);
return s.is_hot && activeStatuses.includes(task?.status) && !seen.has(s.task_id) && seen.add(s.task_id);
})
.map(s => ({ ...s, task: taskMap.get(s.task_id) }))
.filter(s => s.task)
.sort((a, b) => {
if (!a.deadline && !b.deadline) return 0;
if (!a.deadline) return 1;
if (!b.deadline) return -1;
return new Date(a.deadline) - new Date(b.deadline);
})
.slice(0, 7);
const sortedHotItems = sort(hotItems, (s, key) => {
if (isExternal) {
if (key === 'task') return s.title || '';
if (key === 'project') return s.project_name || '';
if (key === 'status') return s.status || '';
if (key === 'deadline') return s.deadline || '';
return '';
}
if (key === 'task') return s.task?.title || '';
if (key === 'requested_by') return s.submitted_by_name || '';
if (key === 'requested_by') return getSubmissionDisplayName(s) || '';
if (key === 'deadline') return s.deadline || '';
return '';
});
return (
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column' }}>
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column', height: cardHeight || 'auto', minHeight: cardHeight ? 0 : 280 }}>
<div style={{ marginBottom: hotItems.length > 0 ? 14 : 0, flexShrink: 0 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>
{isClient ? 'Tasks Ready For Review' : 'Hot Tasks'}
{isClient ? 'Tasks Ready For Review' : isExternal ? 'My Tasks' : 'Hot Tasks'}
</span>
</div>
{hotItems.length === 0 ? (
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>
{isClient ? 'No tasks ready for review' : 'No hot tasks'}
<div style={{ flex: 1, minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>
{isClient ? 'No tasks ready for review' : isExternal ? 'No active tasks assigned to you' : 'No hot tasks'}
</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '10%' }} />
<col style={{ width: '40%' }} />
<col style={{ width: '35%' }} />
<col style={{ width: '15%' }} />
</colgroup>
<thead style={{ background: 'transparent' }}>
<tr style={{ background: 'transparent' }}>
<th style={{ padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top', textAlign: 'center' }} />
<SortTh col="task" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'left', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Task</SortTh>
<SortTh col="requested_by" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Requested By</SortTh>
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Due By</SortTh>
</tr>
</thead>
<tbody>
{sortedHotItems.map(s => (
<tr key={s.task_id} style={{ verticalAlign: 'middle', background: 'transparent' }}>
<td style={{ padding: '3px 5px 7px', border: 'none', background: 'transparent', textAlign: 'center', verticalAlign: 'middle' }}>
{s.task.status === 'client_approved' ? (
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="rgba(74,222,128,0.8)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'block', margin: '0 auto' }}>
<rect x="1.5" y="1.5" width="13" height="13" rx="2"/><polyline points="4,8 6.5,10.5 12,5.5"/>
</svg>
) : (
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" style={{ display: 'block', margin: '0 auto' }}>
<rect x="1.5" y="1.5" width="13" height="13" rx="2"/>
</svg>
)}
</td>
<td style={{ fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'left' }}>
<button type="button" className="dashboard-inline-link" onClick={() => navigate('/tasks/' + s.task_id)}>{s.task.title}</button>
</td>
<td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', padding: '5px', border: 'none', background: 'transparent', textAlign: 'center' }}>
{s.submitted_by ? (
<button type="button" className="dashboard-inline-link" onClick={() => navigate(profilePath(s.submitted_by, s.submitter_role))}>{s.submitted_by_name || 'Profile'}</button>
) : (s.submitted_by_name || '—')}
</td>
<td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', textAlign: 'center', padding: '5px', border: 'none', background: 'transparent' }}>{s.deadline ? new Date(s.deadline).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—'}</td>
</tr>
))}
</tbody>
</table>
<div className="table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
{isExternal ? (
<>
<colgroup>
<col style={{ width: '42%' }} />
<col style={{ width: '24%' }} />
<col style={{ width: '19%' }} />
<col style={{ width: '15%' }} />
</colgroup>
<thead style={{ background: 'transparent' }}>
<tr style={{ background: 'transparent' }}>
<SortTh col="task" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'left', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Task</SortTh>
<SortTh col="project" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'left', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Project</SortTh>
<SortTh col="status" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Status</SortTh>
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Due By</SortTh>
</tr>
</thead>
<tbody>
{sortedHotItems.map((task) => (
<tr key={task.id} style={{ verticalAlign: 'middle', background: 'transparent' }}>
<td style={{ fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'left' }}>
<button type="button" className="dashboard-inline-link" onClick={() => navigate('/tasks/' + task.id)}>{task.title}</button>
</td>
<td style={{ fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'left' }}>{task.project?.name || '—'}</td>
<td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', textAlign: 'center', padding: '5px', border: 'none', background: 'transparent', textTransform: 'capitalize' }}>{String(task.status || '').replace(/_/g, ' ') || '—'}</td>
<td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', textAlign: 'center', padding: '5px', border: 'none', background: 'transparent' }}>{task.deadline ? new Date(task.deadline).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—'}</td>
</tr>
))}
</tbody>
</>
) : (
<>
<colgroup>
<col style={{ width: '10%' }} />
<col style={{ width: '40%' }} />
<col style={{ width: '35%' }} />
<col style={{ width: '15%' }} />
</colgroup>
<thead style={{ background: 'transparent' }}>
<tr style={{ background: 'transparent' }}>
<th style={{ padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top', textAlign: 'center' }} />
<SortTh col="task" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'left', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Task</SortTh>
<SortTh col="requested_by" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Requested By</SortTh>
<SortTh col="deadline" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' }}>Due By</SortTh>
</tr>
</thead>
<tbody>
{sortedHotItems.map(s => (
<tr key={s.task_id} style={{ verticalAlign: 'middle', background: 'transparent' }}>
<td style={{ padding: '3px 5px 7px', border: 'none', background: 'transparent', textAlign: 'center', verticalAlign: 'middle' }}>
{s.task.status === 'client_approved' ? (
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="rgba(74,222,128,0.8)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'block', margin: '0 auto' }}>
<rect x="1.5" y="1.5" width="13" height="13" rx="2"/><polyline points="4,8 6.5,10.5 12,5.5"/>
</svg>
) : (
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" style={{ display: 'block', margin: '0 auto' }}>
<rect x="1.5" y="1.5" width="13" height="13" rx="2"/>
</svg>
)}
</td>
<td style={{ fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', padding: '5px', border: 'none', background: 'transparent', textAlign: 'left' }}>
<button type="button" className="dashboard-inline-link" onClick={() => navigate('/tasks/' + s.task_id)}>{s.task.title}</button>
</td>
<td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', padding: '5px', border: 'none', background: 'transparent', textAlign: 'center' }}>
{s.submitted_by ? (
<button type="button" className="dashboard-inline-link" onClick={() => navigate(profilePath(s.submitted_by, s.submitter_role))}>{getSubmissionDisplayName(s) || 'Profile'}</button>
) : (getSubmissionDisplayName(s) || '—')}
</td>
<td style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap', textAlign: 'center', padding: '5px', border: 'none', background: 'transparent' }}>{s.deadline ? new Date(s.deadline).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—'}</td>
</tr>
))}
</tbody>
</>
)}
</table>
</div>
)}
</div>
);
}
function ClientHighlightCard({ highlights }) {
const navigate = useNavigate();
const { sortKey, sortDir, toggle, sort } = useSortable('company');
const fmt = (n) => `$${Number(n || 0).toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`;
const thStyle = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textAlign: 'center', letterSpacing: 0.6, textTransform: 'uppercase', padding: '0 0 12px 0', border: 'none', background: 'transparent', verticalAlign: 'top' };
const td = () => ({ padding: '5px', border: 'none', background: 'transparent', textAlign: 'center', verticalAlign: 'middle' });
const sortedHighlights = sort(highlights || [], (row, key) => {
if (key === 'company') return row.company?.name || '';
if (key === 'contact') return row.primaryContact?.name || '';
if (key === 'projects') return row.projectCount || 0;
if (key === 'open_tasks') return row.openTaskCount || 0;
if (key === 'outstanding') return Number(row.outstandingTotal || 0);
if (key === 'paid') return Number(row.paidTotal || 0);
return '';
});
return (
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column' }}>
<div style={{ marginBottom: highlights && highlights.length > 0 ? 14 : 0, flexShrink: 0 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Client Highlight</span>
</div>
{!highlights || highlights.length === 0 ? (
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: 'var(--text-muted)' }}>No data</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '5%' }} />
<col style={{ width: '22%' }} />
<col style={{ width: '23%' }} />
<col style={{ width: '13%' }} />
<col style={{ width: '13%' }} />
<col style={{ width: '12%' }} />
<col style={{ width: '12%' }} />
</colgroup>
<thead style={{ background: 'transparent' }}>
<tr style={{ background: 'transparent' }}>
<th style={{ ...thStyle, padding: '0 0 12px 5px' }} />
<SortTh col="company" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ ...thStyle, textAlign: 'left', paddingLeft: 5 }}>Company</SortTh>
<SortTh col="contact" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={thStyle}>Contact</SortTh>
<SortTh col="projects" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={thStyle}>Projects</SortTh>
<SortTh col="open_tasks" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={thStyle}>Open Tasks</SortTh>
<SortTh col="outstanding" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={thStyle}>Outstanding</SortTh>
<SortTh col="paid" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={thStyle}>Paid</SortTh>
</tr>
</thead>
<tbody>
{sortedHighlights.map(({ company, primaryContact, projectCount, openTaskCount, outstandingTotal = 0, paidTotal = 0 }) => (
<tr key={company.id} style={{ background: 'transparent' }}>
<td style={{ ...td(), padding: '3px 5px 7px' }}>
<InitialPortrait name={company.name} />
</td>
<td style={{ ...td(), fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'left' }}>
<button type="button" className="dashboard-inline-link" onClick={() => navigate(`/company/${company.id}`)}>{company.name}</button>
</td>
<td style={{ ...td(), fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{primaryContact?.id ? (
<button type="button" className="dashboard-inline-link" onClick={() => navigate(profilePath(primaryContact.id, primaryContact.role))}>{primaryContact.name || 'Profile'}</button>
) : (primaryContact?.name || '—')}
</td>
<td style={{ ...td(), fontSize: 12, color: 'var(--text-primary)' }}>{projectCount}</td>
<td style={{ ...td(), fontSize: 12, color: 'var(--text-primary)' }}>{openTaskCount || 0}</td>
<td style={{ ...td(), fontSize: 12, color: '#F5A523' }}>{fmt(outstandingTotal)}</td>
<td style={{ ...td(), fontSize: 12, color: '#4ade80' }}>{fmt(paidTotal)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
function TeamPerformanceCard({ tasks, profiles, deliveries }) {
function TeamPerformanceCard({ tasks, profiles, deliveries, cardHeight = null }) {
const navigate = useNavigate();
const profileMap = useMemo(() => {
const m = new Map();
(profiles || []).forEach(p => m.set(p.id, p));
return m;
}, [profiles]);
const profileNameMap = useMemo(() => {
const m = new Map();
(profiles || []).forEach((profile) => {
const key = String(profile?.name || '').trim().toLowerCase();
if (key && !m.has(key)) m.set(key, profile);
});
return m;
}, [profiles]);
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
const toEST = (dateStr) => new Date(new Date(dateStr).toLocaleString('en-US', { timeZone: 'America/New_York' }));
const monthsWithData = useMemo(() => new Set(
@@ -556,9 +513,12 @@ function TeamPerformanceCard({ tasks, profiles, deliveries }) {
}, []);
const monthOpts = allMonthOpts.filter(o => monthsWithData.has(o.value));
const [monthKey, setMonthKey] = useState(() => monthOpts[0]?.value || allMonthOpts[0].value);
const effectiveMonthKey = monthOpts.some(option => option.value === monthKey)
? monthKey
: (monthOpts[0]?.value || allMonthOpts[0]?.value || '');
const { people, totalDone } = useMemo(() => {
const [year, month] = monthKey.split('-').map(Number);
const [year, month] = effectiveMonthKey.split('-').map(Number);
const map = new Map();
(deliveries || []).forEach(d => {
const task = d.submission?.task;
@@ -567,35 +527,50 @@ function TeamPerformanceCard({ tasks, profiles, deliveries }) {
if (dt.getFullYear() !== year || dt.getMonth() + 1 !== month) return;
const name = d.sent_by || task.assigned_name;
if (!name) return;
const entry = map.get(name) || { name, id: task.assigned_to || null, newCount: 0, revCount: 0 };
const matchedProfile = (task.assigned_to && profileMap.get(task.assigned_to)) || profileNameMap.get(String(name).trim().toLowerCase()) || null;
const entry = map.get(name) || {
name,
id: matchedProfile?.id || task.assigned_to || null,
avatar_url: matchedProfile?.avatar_url || '',
newCount: 0,
revCount: 0,
};
if ((d.version_number || 0) === 0) entry.newCount += 1;
else entry.revCount += 1;
map.set(name, entry);
});
const people = [...map.values()].map(p => ({ ...p, done: p.newCount + p.revCount })).sort((a, b) => b.done - a.done);
return { people, totalDone: people.reduce((s, p) => s + p.done, 0) };
}, [deliveries, monthKey]); // eslint-disable-line react-hooks/exhaustive-deps
}, [deliveries, effectiveMonthKey, profileMap, profileNameMap]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column' }}>
<div style={{ background: 'var(--card-bg)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', display: 'flex', flexDirection: 'column', height: cardHeight || 'auto', minHeight: cardHeight ? 0 : 280 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, flexShrink: 0 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Team Performance</span>
<div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center' }}>
<select value={monthKey} onChange={e => setMonthKey(e.target.value)} style={{ fontSize: 11, color: 'var(--text-muted)', background: 'transparent', border: 'none', boxShadow: 'none', cursor: 'pointer', outline: 'none', fontFamily: 'inherit', appearance: 'none', WebkitAppearance: 'none', MozAppearance: 'none', paddingRight: 14 }}>
<select value={effectiveMonthKey} onChange={e => setMonthKey(e.target.value)} style={{ fontSize: 11, color: 'var(--text-muted)', background: 'transparent', border: 'none', boxShadow: 'none', cursor: 'pointer', outline: 'none', fontFamily: 'inherit', appearance: 'none', WebkitAppearance: 'none', MozAppearance: 'none', paddingRight: 14 }}>
{monthOpts.map(o => <option key={o.value} value={o.value} style={{ background: '#1a1a1a' }}>{o.label}</option>)}
</select>
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" style={{ position: 'absolute', right: 0, pointerEvents: 'none' }} stroke="rgba(255,255,255,0.5)" strokeWidth="1.5" strokeLinecap="round"><polyline points="1,2 4,6 7,2"/></svg>
</div>
</div>
{people.length === 0 ? (
<div className="card-empty-center">No completed tasks this month</div>
<div className="card-empty-center" style={{ flex: 1 }}>No completed tasks this month</div>
) : (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<div className="table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
{people.slice(0, 5).map((p, i) => {
const pct = totalDone > 0 ? Math.round((p.done / totalDone) * 100) : 0;
return (
<div key={p.name} style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: i > 0 ? 10 : 0 }}>
{(() => { const prof = p.id ? profileMap.get(p.id) : null; return prof?.avatar_url ? <div style={{ width: 27, height: 27, borderRadius: '50%', flexShrink: 0, overflow: 'hidden' }}><img src={prof.avatar_url} alt={p.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div> : <Avatar name={p.name} />; })()}
<ProfileAvatar
profileId={p.id}
name={p.name}
avatarUrl={p.avatar_url}
profilesById={profileMap}
profilesByName={profileNameMap}
size={27}
fontSize={12}
/>
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
{p.id ? <button type="button" className="dashboard-inline-link" style={{ fontSize: 13, fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} onClick={() => navigate(`/profile/${p.id}`)}>{p.name}</button> : <span style={{ fontSize: 13, color: 'var(--text-primary)', fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>}
@@ -628,26 +603,40 @@ export default function TeamDashboard() {
const isClient = currentUser?.role === 'client';
const isExternal = currentUser?.role === 'external';
const [refreshKey, setRefreshKey] = useState(0);
const refresh = useCallback(() => setRefreshKey(k => k + 1), []);
useRefetchOnFocus(refresh);
useRealtimeSubscription(['tasks', 'projects', 'submissions', 'activity_log', 'profiles'], refresh);
const { refreshKey } = useLiveRefresh(['tasks', 'projects', 'submissions', 'activity_log', 'profiles']);
const cached = isTeam ? readPageCache(CACHE_KEY, 5 * 60_000) : null;
const [tasks, setTasks] = useState(() => cached?.tasks || []);
const [projects, setProjects] = useState(() => cached?.projects || []);
const [submissions, setSubmissions] = useState(() => cached?.submissions || []);
const [allCompanies, setAllCompanies] = useState(() => cached?.companies || []);
const [clientProfiles, setClientProfiles] = useState(() => cached?.clientProfiles || []);
const [companyMemberships, setCompanyMemberships] = useState(() => cached?.companyMemberships || []);
const [activityLog, setActivityLog] = useState(() => cached?.activityLog || []);
const [allProfiles, setAllProfiles] = useState(() => cached?.allProfiles || []);
const [teamInvoices, setTeamInvoices] = useState(() => cached?.teamInvoices || []);
const [teamExpenses, setTeamExpenses] = useState([]);
const [teamSubcontractorPOs, setTeamSubcontractorPOs] = useState([]);
const [teamSubInvoices, setTeamSubInvoices] = useState([]);
const [myInvoices, setMyInvoices] = useState([]);
const [perfDeliveries, setPerfDeliveries] = useState([]);
const [loading, setLoading] = useState(!cached);
const bottomCardsRowRef = useRef(null);
const [bottomCardsHeight, setBottomCardsHeight] = useState(null);
useEffect(() => {
function updateBottomCardsHeight() {
const row = bottomCardsRowRef.current;
if (!row) return;
const rect = row.getBoundingClientRect();
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
const available = Math.floor(viewportHeight - rect.top - 24);
const nextHeight = available > 0 ? `${available}px` : null;
setBottomCardsHeight(prev => (prev === nextHeight ? prev : nextHeight));
}
updateBottomCardsHeight();
window.addEventListener('resize', updateBottomCardsHeight);
return () => window.removeEventListener('resize', updateBottomCardsHeight);
}, [loading, isTeam, isClient, isExternal]);
useEffect(() => {
let cancelled = false;
@@ -658,37 +647,16 @@ export default function TeamDashboard() {
cutoff.setMonth(cutoff.getMonth() - CUTOFF_MONTHS);
const cutoffStr = cutoff.toISOString();
let scopedTaskIds = null;
let scopedProjectIds = null;
let scopedCompanyIds = null;
if (!isTeam && isClient) {
const companyIds = [...new Set([...(currentUser?.companies?.map(c => c.company?.id || c.id) || []), ...(currentUser?.company_id ? [currentUser.company_id] : [])])].filter(Boolean);
scopedCompanyIds = companyIds;
if (companyIds.length > 0) {
const { data: projRows } = await supabase.from('projects').select('id, tasks(id)').in('company_id', companyIds);
scopedProjectIds = (projRows || []).map(p => p.id).filter(Boolean);
scopedTaskIds = (projRows || []).flatMap(p => (p.tasks || []).map(t => t.id)).filter(Boolean);
} else {
scopedProjectIds = [];
scopedTaskIds = [];
}
}
if (!isTeam && isExternal) {
const uid = currentUser?.id;
const { data: memberData } = await supabase.from('project_members').select('project_id').eq('profile_id', uid);
scopedProjectIds = (memberData || []).map(m => m.project_id).filter(Boolean);
if ((scopedProjectIds || []).length > 0) {
const { data: projectTasks } = await supabase.from('tasks').select('id').in('project_id', scopedProjectIds);
scopedTaskIds = (projectTasks || []).map(row => row.id).filter(Boolean);
} else {
scopedTaskIds = [];
}
}
const {
scopedTaskIds,
scopedProjectIds,
scopedCompanyIds,
} = isTeam
? { scopedTaskIds: null, scopedProjectIds: null, scopedCompanyIds: null }
: await resolveScopedWorkIds(currentUser, { isClient, isExternal });
const tasksQuery = supabase.from('tasks')
.select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at, assignee:profiles!assigned_to(avatar_url)')
.select('id, title, status, current_version, project_id, assigned_name, assigned_to, completed_at, project:projects(name), assignee:profiles!assigned_to(avatar_url)')
.gte('submitted_at', cutoffStr)
.order('submitted_at', { ascending: false });
if (isClient) {
@@ -724,10 +692,10 @@ export default function TeamDashboard() {
}
const invoicesPromise = isTeam
? supabase.from('invoices').select('total, status, company_id, created_at').in('status', ['sent', 'paid'])
? supabase.from('invoices').select('total, stripe_fee, status, company_id, created_at').in('status', ['sent', 'paid'])
: isClient
? ((scopedCompanyIds || []).length > 0
? supabase.from('invoices').select('total, status, company_id, created_at').in('company_id', scopedCompanyIds).in('status', ['sent', 'paid'])
? supabase.from('invoices').select('total, stripe_fee, status, company_id, created_at').in('company_id', scopedCompanyIds).in('status', ['sent', 'paid'])
: Promise.resolve({ data: [] }))
: supabase.from('subcontractor_invoices').select('total, status').eq('profile_id', currentUser?.id).in('status', ['submitted', 'approved', 'paid']);
@@ -735,17 +703,25 @@ export default function TeamDashboard() {
? supabase.from('expenses').select('amount')
: Promise.resolve({ data: [] });
const [{ data: t }, { data: p }, { data: subs }, { data: profiles }, { data: activity }, { data: cos }, { data: memRows }, { data: invs }, { data: exps }, { data: delivs }] = await withTimeout(Promise.all([
const subcontractorPOsPromise = isTeam
? supabase.from('subcontractor_payments').select('amount, status, paid_at, date')
: Promise.resolve({ data: [] });
const subcontractorInvoicesPromise = isTeam
? supabase.from('subcontractor_invoices').select('status, paid_at, items:subcontractor_invoice_items(unit_price, quantity)')
: Promise.resolve({ data: [] });
const [{ data: t }, { data: p }, { data: subs }, { data: profiles }, { data: activity }, { data: invs }, { data: exps }, { data: subcontractorPOs }, { data: subcontractorInvoices }, { data: delivs }] = await withTimeout(Promise.all([
tasksQuery,
projectsQuery,
submissionsQuery,
supabase.from('profiles').select('id, role, name, email, company_id, brand_book_rate, avatar_url'),
supabase.from('activity_log').select('id, created_at, actor_id, actor_name, action, task_id, task_title, project_name, project_id').order('created_at', { ascending: false }).limit(isTeam ? 20 : 50),
supabase.from('companies').select('id, name').order('name'),
supabase.from('company_members').select('company_id, profile_id'),
invoicesPromise,
expensesPromise,
supabase.from('deliveries').select('sent_by, sent_at, version_number, submission:submissions!inner(task:tasks!inner(assigned_to, assigned_name))').gte('sent_at', cutoffStr),
subcontractorPOsPromise,
subcontractorInvoicesPromise,
supabase.from('deliveries').select('sent_by, sent_at, version_number, submission:submissions!inner(task_id, task:tasks!inner(assigned_to, assigned_name))').gte('sent_at', cutoffStr),
]), 30000, 'Dashboard load');
if (cancelled) return;
@@ -754,14 +730,14 @@ export default function TeamDashboard() {
const roleById = new Map((profiles || []).map(pr => [pr.id, pr.role]));
const roleByName = new Map((profiles || []).map(pr => [pr.name, pr.role]));
const tasksWithDeadlines = (t || []).map(task => ({ ...task, deadline: getDeadlineSourceSubmission(task, subs)?.deadline || null, assignee_role: roleById.get(task.assigned_to) || null }));
const subsWithRole = (subs || []).map(sub => ({ ...sub, submitter_role: roleById.get(sub.submitted_by) || null, delivery_sender_role: roleByName.get(sub.delivery?.sent_by) || null }));
const subsWithRole = mergeSubmissionDisplayNames(
(subs || []).map(sub => ({ ...sub, submitter_role: roleById.get(sub.submitted_by) || null, delivery_sender_role: roleByName.get(sub.delivery?.sent_by) || null })),
profiles || []
);
setTasks(tasksWithDeadlines);
setProjects(p || []);
setSubmissions(subsWithRole);
setClientProfiles((profiles || []).filter(pr => pr.role === 'client'));
setAllProfiles(profiles || []);
setAllCompanies(cos || []);
setCompanyMemberships(memRows || []);
const scopedActivity = isTeam
? (activity || [])
: (activity || []).filter((entry) => {
@@ -773,16 +749,19 @@ export default function TeamDashboard() {
setTeamInvoices(isTeam ? (invs || []) : []);
setMyInvoices(!isTeam ? (invs || []) : []);
setTeamExpenses(exps || []);
setPerfDeliveries(delivs || []);
setTeamSubcontractorPOs(subcontractorPOs || []);
setTeamSubInvoices(subcontractorInvoices || []);
const scopedDeliveryTaskIds = new Set(scopedTaskIds || []);
const scopedDeliveries = isTeam
? (delivs || [])
: (delivs || []).filter(delivery => scopedDeliveryTaskIds.has(delivery.submission?.task_id));
setPerfDeliveries(scopedDeliveries);
if (isTeam) {
writePageCache(CACHE_KEY, {
tasks: tasksWithDeadlines,
projects: p || [],
submissions: subsWithRole,
clientProfiles: (profiles || []).filter(pr => pr.role === 'client'),
companies: cos || [],
companyMemberships: memRows || [],
activityLog: activity || [],
teamInvoices: invs || [],
allProfiles: profiles || [],
@@ -797,12 +776,6 @@ export default function TeamDashboard() {
return () => { cancelled = true; };
}, [isTeam, isClient, isExternal, currentUser?.id, currentUser?.company_id, currentUser?.companies, refreshKey]); // eslint-disable-line react-hooks/exhaustive-deps
const teamHighlights = useMemo(() =>
isTeam ? buildClientHighlights(allCompanies, projects, tasks, clientProfiles, companyMemberships, teamInvoices)
.sort((a, b) => b.openTaskCount - a.openTaskCount || b.projectCount - a.projectCount || a.company.name.localeCompare(b.company.name))
.slice(0, 5) : [],
[isTeam, allCompanies, projects, tasks, clientProfiles, companyMemberships, teamInvoices]);
const activityEvents = useMemo(() =>
(activityLog || []).map(e => ({
time: new Date(e.created_at),
@@ -817,7 +790,7 @@ export default function TeamDashboard() {
})).filter(e => !isNaN(e.time)).slice(0, 10),
[activityLog]);
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
if (loading) return <Layout><PageLoader /></Layout>;
const doneStatuses = ['client_approved', 'invoiced', 'paid'];
const inProgressTasks = tasks.filter(t => t.status === 'in_progress');
@@ -839,7 +812,18 @@ export default function TeamDashboard() {
});
const dashRevenue = teamInvoices.filter(i => i.status === 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
const dashOutstanding = teamInvoices.filter(i => i.status === 'sent').reduce((s, i) => s + Number(i.total || 0), 0);
const dashExpensesTotal = teamExpenses.reduce((s, e) => s + Number(e.amount || 0), 0);
const dashOpsExpensesTotal = teamExpenses.reduce((s, e) => s + Number(e.amount || 0), 0);
const dashNetReceived = teamInvoices
.filter(i => i.status === 'paid')
.reduce((s, i) => s + Number(i.total || 0) - Number(i.stripe_fee || 0), 0);
const dashPaidSubcontractorPOsTotal = teamSubcontractorPOs
.filter(po => po.status === 'paid')
.reduce((s, po) => s + Number(po.amount || 0), 0);
const dashPaidSubInvoicesTotal = teamSubInvoices
.filter(inv => inv.status === 'paid')
.reduce((s, inv) => s + (inv.items || []).reduce((a, item) => a + Number(item.unit_price || 0) * Number(item.quantity || 1), 0), 0);
const dashSubcontractorExpensesTotal = dashPaidSubcontractorPOsTotal + dashPaidSubInvoicesTotal;
const dashExpensesTotal = dashOpsExpensesTotal + dashSubcontractorExpensesTotal;
const revenueByMonth = Array.from({ length: 4 }, (_, i) => {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth() - (3 - i), 1);
@@ -851,7 +835,7 @@ export default function TeamDashboard() {
const myOutstanding = myInvoices.filter(i => i.status !== 'paid').reduce((s, i) => s + Number(i.total || 0), 0);
const card3 = isTeam
? { label: 'Net Profit', value: fmtMoney(dashRevenue - dashExpensesTotal), sub: `${fmtMoney(dashExpensesTotal)} expenses`, iconBg: 'rgba(74,222,128,0.15)', iconColor: '#4ade80', iconPath: DASH_ICONS.profit }
? { label: 'Net Profit', value: fmtMoney(dashNetReceived - dashExpensesTotal), sub: `${fmtMoney(dashExpensesTotal)} expenses + sub costs`, iconBg: 'rgba(74,222,128,0.15)', iconColor: '#4ade80', iconPath: DASH_ICONS.profit }
: isClient
? { label: 'Outstanding', value: fmtMoney(myOutstanding), sub: 'invoices due', iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: DASH_ICONS.revenue }
: { label: 'Pending', value: fmtMoney(myOutstanding), sub: 'awaiting payment', iconBg: 'rgba(245,165,35,0.15)', iconColor: '#F5A523', iconPath: DASH_ICONS.revenue };
@@ -875,15 +859,10 @@ export default function TeamDashboard() {
<TasksInProgressCard tasks={inProgressTasks} />
<MiniCalendar items={calendarItems} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}>
<HotItemsCard submissions={submissions} tasks={tasks} isClient={isClient} />
<TeamPerformanceCard tasks={tasks} profiles={allProfiles} deliveries={perfDeliveries} />
<div ref={bottomCardsRowRef} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 24 }}>
<HotItemsCard submissions={submissions} tasks={tasks} isClient={isClient} isExternal={isExternal} currentUserId={currentUser?.id || null} cardHeight={bottomCardsHeight} />
<TeamPerformanceCard tasks={tasks} profiles={allProfiles} deliveries={perfDeliveries} cardHeight={bottomCardsHeight} />
</div>
{isTeam && (
<div style={{ marginTop: 24 }}>
<ClientHighlightCard highlights={teamHighlights} />
</div>
)}
</Layout>
);
}
+4 -17
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import Layout from '../../components/Layout';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
import { formatShortDateTime } from '../../lib/dates';
function emptyForm() {
return {
@@ -17,17 +18,6 @@ function sortEntries(entries) {
return [...entries].sort((a, b) => a.service_name.localeCompare(b.service_name, undefined, { sensitivity: 'base' }));
}
function formatDate(value) {
if (!value) return 'Unknown';
return new Date(value).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
function normalizeUrl(value) {
const raw = String(value || '').trim();
if (!raw) return '';
@@ -253,7 +243,7 @@ export default function FourgePasswords() {
}
return (
<Layout>
<Layout loading={loading}>
<div className="page-header">
<div>
<div className="page-title">Fourge Passwords</div>
@@ -294,10 +284,7 @@ export default function FourgePasswords() {
{loading ? (
<div style={{ color: 'var(--text-muted)' }}>Loading passwords...</div>
) : entries.length === 0 ? (
<div className="empty-state" style={{ padding: '28px 20px' }}>
<h3 style={{ marginBottom: 8 }}>No passwords yet</h3>
<p>Add encrypted password entries for internal services and team access.</p>
</div>
<div className="card-empty-center" style={{ minHeight: 120 }}>No passwords</div>
) : (
<div style={{ display: 'grid', gap: 10 }}>
{entries.map(entry => (
@@ -316,7 +303,7 @@ export default function FourgePasswords() {
<div>
<div style={{ fontSize: 15, fontWeight: 400, color: 'var(--text-primary)' }}>{entry.service_name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4 }}>
Updated {formatDate(entry.updated_at || entry.created_at)}
Updated {formatShortDateTime(entry.updated_at || entry.created_at, 'Unknown')}
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
+114 -37
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate, useLocation, Link } from 'react-router-dom';
import Layout from '../../components/Layout';
import PageLoader from '../../components/PageLoader';
import LoadingButton from '../../components/LoadingButton';
import SortTh from '../../components/SortTh';
import StatusBadge from '../../components/StatusBadge';
@@ -9,15 +10,20 @@ import { generateInvoicePDF, generateReceiptPDF } from '../../lib/invoice';
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
import { withTimeout } from '../../lib/withTimeout';
import { useSortable } from '../../hooks/useSortable';
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
export default function InvoiceDetail() {
const { id } = useParams();
export function TeamInvoiceDetailPanel({
invoiceId,
initialInvoice = null,
embedded = false,
onClose,
onInvoiceUpdated,
onInvoiceDeleted,
}) {
const navigate = useNavigate();
const { state } = useLocation();
const [invoice, setInvoice] = useState(state?.invoice || null);
const [invoice, setInvoice] = useState(initialInvoice || null);
const [company, setCompany] = useState(null);
const [companies, setCompanies] = useState([]);
const [items, setItems] = useState([]);
@@ -32,14 +38,14 @@ export default function InvoiceDetail() {
useEffect(() => {
async function load() {
try {
const { data: inv } = await supabase.from('invoices').select('*').eq('id', id).single();
const { data: inv } = await supabase.from('invoices').select('*').eq('id', invoiceId).single();
if (!inv) return;
setInvoice(inv);
const [{ data: co }, { data: companyList }, { data: its }] = await Promise.all([
supabase.from('companies').select('*').eq('id', inv.company_id).single(),
supabase.from('companies').select('*').order('name'),
supabase.from('invoice_items').select('*').eq('invoice_id', id).order('created_at'),
supabase.from('invoice_items').select('*').eq('invoice_id', invoiceId).order('created_at'),
]);
const defaultEmail = inv.invoice_email || await getDefaultInvoiceEmail(inv.company_id, co);
setCompany(co);
@@ -53,23 +59,47 @@ export default function InvoiceDetail() {
}
}
load();
}, [id]);
}, [invoiceId]);
const syncInvoiceState = (updater) => {
setInvoice((current) => {
const next = typeof updater === 'function' ? updater(current) : updater;
onInvoiceUpdated?.(next);
return next;
});
};
const updateStatus = async (status) => {
setSaving(true);
const updates = { status };
if (status === 'paid' && !invoice.paid_at) updates.paid_at = new Date().toISOString();
if (status !== 'paid') updates.paid_at = null;
const { error } = await supabase.from('invoices').update(updates).eq('id', id);
const { error } = await supabase.from('invoices').update(updates).eq('id', invoiceId);
if (!error) {
setInvoice(i => ({ ...i, ...updates }));
syncInvoiceState(i => ({ ...i, ...updates }));
// Sync task statuses along invoice lifecycle
const { data: freshItems } = await supabase.from('invoice_items').select('task_id, submission_id').eq('invoice_id', id);
const { data: freshItems } = await supabase.from('invoice_items').select('task_id, submission_id').eq('invoice_id', invoiceId);
const taskIds = (freshItems || []).filter(i => i.task_id && !i.submission_id).map(i => i.task_id);
if (taskIds.length > 0) {
const newTaskStatus = status === 'paid' ? 'paid' : status === 'sent' ? 'invoiced' : 'client_approved';
await supabase.from('tasks').update({ status: newTaskStatus }).in('id', taskIds);
}
if (status === 'paid') {
try {
const contactEmail = invoice.invoice_email || await getDefaultInvoiceEmail(invoice.company_id, company);
if (contactEmail) {
const paidDate = new Date(updates.paid_at || new Date().toISOString()).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
await sendEmail('receipt_sent', [contactEmail], {
invoiceNumber: invoice.invoice_number,
billTo: invoice.bill_to || company?.name,
total: `$${Number(invoice.total).toFixed(2)}`,
paidDate,
});
}
} catch (emailErr) {
console.error('Failed to send paid invoice email:', emailErr);
}
}
} else {
alert('Failed to update status.');
}
@@ -104,12 +134,12 @@ export default function InvoiceDetail() {
const contactEmail = getEmailRecipient();
if (!contactEmail) throw new Error('Enter an email recipient before sending.');
const { error } = await withTimeout(
supabase.from('invoices').update({ invoice_email: contactEmail }).eq('id', id),
supabase.from('invoices').update({ invoice_email: contactEmail }).eq('id', invoiceId),
12000,
'Saving invoice email'
);
if (error) throw error;
setInvoice(inv => ({ ...inv, invoice_email: contactEmail }));
syncInvoiceState(inv => ({ ...inv, invoice_email: contactEmail }));
return contactEmail;
};
@@ -167,12 +197,12 @@ export default function InvoiceDetail() {
const updates = { status: 'sent' };
const { error } = await withTimeout(
supabase.from('invoices').update(updates).eq('id', id),
supabase.from('invoices').update(updates).eq('id', invoiceId),
12000,
'Updating invoice status'
);
if (error) throw error;
setInvoice(i => ({ ...i, ...updates }));
syncInvoiceState(i => ({ ...i, ...updates }));
alert(`Invoice email sent successfully.${attachmentWarning || ''}`);
} catch (err) {
console.error('Failed to finalize and send invoice:', err);
@@ -202,16 +232,22 @@ export default function InvoiceDetail() {
};
const handleDelete = async () => {
if (invoice?.status === 'paid') {
alert('Paid invoices cannot be deleted.');
return;
}
setSaving(true);
try {
const { data: freshItems } = await supabase.from('invoice_items').select('task_id, submission_id').eq('invoice_id', id);
const { data: freshItems } = await supabase.from('invoice_items').select('task_id, submission_id').eq('invoice_id', invoiceId);
const taskIds = (freshItems || []).filter(i => i.task_id && !i.submission_id).map(i => i.task_id);
if (taskIds.length > 0) await supabase.from('tasks').update({ invoiced: false, status: 'client_approved' }).in('id', taskIds);
const submissionIds = (freshItems || []).filter(i => i.submission_id).map(i => i.submission_id);
if (submissionIds.length > 0) await supabase.from('submissions').update({ invoiced: false }).in('id', submissionIds);
const { error } = await supabase.from('invoices').delete().eq('id', id);
const { error } = await supabase.from('invoices').delete().eq('id', invoiceId);
if (error) throw error;
navigate('/invoices');
onInvoiceDeleted?.(invoiceId);
if (embedded) onClose?.();
else navigate('/finances');
} catch {
alert('Failed to delete invoice. Please try again.');
setSaving(false);
@@ -279,8 +315,8 @@ export default function InvoiceDetail() {
await supabase.from('invoices').update({
invoice_date: dateForm.invoice_date,
due_date: dateForm.due_date,
}).eq('id', id);
setInvoice(i => ({ ...i, invoice_date: dateForm.invoice_date, due_date: dateForm.due_date }));
}).eq('id', invoiceId);
syncInvoiceState(i => ({ ...i, invoice_date: dateForm.invoice_date, due_date: dateForm.due_date }));
setEditingDates(false);
setSaving(false);
};
@@ -288,8 +324,8 @@ export default function InvoiceDetail() {
const handleEmailBlur = async () => {
const nextEmail = getEmailRecipient();
if ((invoice.invoice_email || '') === nextEmail) return;
const { error } = await supabase.from('invoices').update({ invoice_email: nextEmail || null }).eq('id', id);
if (!error) setInvoice(inv => ({ ...inv, invoice_email: nextEmail || null }));
const { error } = await supabase.from('invoices').update({ invoice_email: nextEmail || null }).eq('id', invoiceId);
if (!error) syncInvoiceState(inv => ({ ...inv, invoice_email: nextEmail || null }));
};
const handleCompanyChange = async (companyId) => {
@@ -302,19 +338,37 @@ export default function InvoiceDetail() {
company_id: companyId,
bill_to: nextCompany.name,
invoice_email: defaultEmail,
}).eq('id', id);
}).eq('id', invoiceId);
setSaving(false);
if (error) {
alert('Failed to update invoice company. Please try again.');
return;
}
setInvoice(inv => ({ ...inv, company_id: companyId, bill_to: nextCompany.name, invoice_email: defaultEmail }));
syncInvoiceState(inv => ({ ...inv, company_id: companyId, bill_to: nextCompany.name, invoice_email: defaultEmail }));
setCompany(nextCompany);
setEmailRecipient(defaultEmail);
};
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
if (!invoice) return <Layout><p>Invoice not found.</p></Layout>;
if (loading) {
if (embedded) {
return (
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<PageLoader />
</div>
);
}
return <Layout><PageLoader /></Layout>;
}
if (!invoice) {
if (embedded) {
return (
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-muted)' }}>
Invoice not found.
</div>
);
}
return <Layout><p>Invoice not found.</p></Layout>;
}
const sortedItems = sort(items, (item, key) => {
if (key === 'type') return item.submission_id ? 'Revision' : 'New';
if (key === 'description') return item.description || '';
@@ -325,19 +379,18 @@ export default function InvoiceDetail() {
});
const isOverdue = invoice.status !== 'paid' && new Date(invoice.due_date) < new Date();
const detailContent = (
<>
{!embedded && <button className="back-link" onClick={() => navigate('/finances')}> Back to Invoices</button>}
return (
<Layout>
<button className="back-link" onClick={() => navigate('/invoices')}> Back to Invoices</button>
<div className="page-header">
<div className={embedded ? '' : 'page-header'} style={embedded ? { display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, paddingBottom: 18, borderBottom: '1px solid var(--border)' } : undefined}>
<div>
<div className="page-title">{invoice.invoice_number}</div>
<div className="page-subtitle">
<div className={embedded ? '' : 'page-title'} style={embedded ? { fontSize: 18, fontWeight: 500, color: 'var(--text-primary)', letterSpacing: 0.2, lineHeight: 1.1 } : undefined}>{invoice.invoice_number}</div>
<div className={embedded ? '' : 'page-subtitle'} style={embedded ? { marginTop: 6, fontSize: 13, color: 'var(--text-secondary)' } : undefined}>
<Link to={`/company/${company?.id}`} style={{ color: 'var(--accent)' }}>{company?.name}</Link>
</div>
</div>
<div className="action-buttons">
<div className="action-buttons" style={embedded ? { justifyContent: 'flex-end' } : undefined}>
<StatusBadge
status={statusColor[invoice.status] || 'not_started'}
label={`${invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'}${isOverdue ? ' · Overdue' : ''}`}
@@ -355,7 +408,7 @@ export default function InvoiceDetail() {
</div>
</div>
<div className="grid-2" style={{ marginBottom: 24 }}>
<div className="grid-2" style={{ marginTop: embedded ? 18 : 0, marginBottom: 24 }}>
<div className="card">
<div className="card-title">Bill To</div>
<div style={{ fontSize: 15, fontWeight: 400 }}>{invoice.bill_to || company?.name}</div>
@@ -510,9 +563,33 @@ export default function InvoiceDetail() {
</LoadingButton>
</>
)}
<button className="btn-icon btn-icon-danger" title="Delete Invoice" onClick={handleDelete} disabled={saving}></button>
{invoice.status !== 'paid' && (
<button className="btn-icon btn-icon-danger" title="Delete Invoice" onClick={handleDelete} disabled={saving}></button>
)}
</div>
</div>
</Layout>
</>
);
if (embedded) {
return (
<div style={popupOverlayStyle} onClick={() => { if (!saving && !generating) onClose?.(); }}>
<div
style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0, overflowY: 'auto' }}
onClick={(e) => e.stopPropagation()}
>
{detailContent}
</div>
</div>
);
}
return <Layout>{detailContent}</Layout>;
}
export default function InvoiceDetail() {
const { id } = useParams();
const { state } = useLocation();
return <TeamInvoiceDetailPanel invoiceId={id} initialInvoice={state?.invoice || null} />;
}
+462 -85
View File
@@ -7,6 +7,7 @@ import SortTh from '../../components/SortTh';
import StatusBadge from '../../components/StatusBadge';
import { useSortable } from '../../hooks/useSortable';
import { supabase } from '../../lib/supabase';
import { useAuth } from '../../context/AuthContext';
import { readPageCache, writePageCache } from '../../lib/pageCache';
import { exportCPAPackage, generateSubcontractorPOPDF, generateInvoicePDF } from '../../lib/invoice';
import { withTimeout } from '../../lib/withTimeout';
@@ -14,6 +15,9 @@ import LoadingButton from '../../components/LoadingButton';
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
import FileAttachment from '../../components/FileAttachment';
import { isReviewShadowDescription } from '../../lib/taskVersions';
import { getRevisionChargeQuantity, isCompletedVersionEligible, isInitialVersionEligible } from '../../lib/invoiceVersionRules';
import { TeamInvoiceDetailPanel } from './TeamInvoiceDetail';
const CATEGORIES = ['Software', 'Contractor', 'Advertising', 'Office', 'Travel', 'Meals', 'Equipment', 'Other'];
const statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
@@ -40,12 +44,156 @@ const invoiceStatusBadgeLabel = (status) => {
const RECEIPT_BUCKET = 'expense-receipts';
const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 4 };
const FIELD_INPUT_STYLE = { minHeight: 42, margin: 0 };
const FINANCE_MODAL_INPUT_STYLE = {
...FIELD_INPUT_STYLE,
width: '100%',
padding: '0 10px',
fontSize: 13,
color: 'var(--text-primary)',
background: 'var(--card-bg-2)',
border: '1px solid var(--border)',
borderRadius: 6,
fontFamily: 'inherit',
boxSizing: 'border-box',
};
const FINANCE_MODAL_TEXTAREA_STYLE = {
...FINANCE_MODAL_INPUT_STYLE,
minHeight: 72,
padding: '10px',
resize: 'vertical',
};
const FINANCE_MODAL_LINE_ITEM_INPUT_STYLE = {
...FINANCE_MODAL_INPUT_STYLE,
minHeight: 32,
padding: '0 8px',
};
const FINANCE_MODAL_NUMBER_INPUT_STYLE = {
...FINANCE_MODAL_LINE_ITEM_INPUT_STYLE,
fontVariantNumeric: 'tabular-nums',
};
const FINANCE_MODAL_AMOUNT_FIELD_STYLE = {
display: 'flex',
alignItems: 'center',
gap: 8,
minHeight: 42,
width: '100%',
padding: '0 12px',
background: 'var(--card-bg-2)',
border: '1px solid var(--border)',
borderRadius: 6,
boxSizing: 'border-box',
};
const FINANCE_MODAL_AMOUNT_PREFIX_STYLE = {
flexShrink: 0,
fontSize: 16,
fontWeight: 500,
lineHeight: 1,
color: 'var(--text-secondary)',
};
const FINANCE_MODAL_AMOUNT_INPUT_STYLE = {
flex: 1,
minWidth: 0,
margin: 0,
padding: 0,
border: 'none',
outline: 'none',
background: 'transparent',
boxShadow: 'none',
fontFamily: 'inherit',
fontSize: 22,
fontWeight: 500,
lineHeight: 1.1,
textAlign: 'right',
color: 'var(--text-primary)',
fontVariantNumeric: 'tabular-nums',
appearance: 'textfield',
};
const FINANCE_MODAL_TOTAL_VALUE_STYLE = {
fontSize: 24,
fontWeight: 500,
lineHeight: 1.1,
color: 'var(--accent)',
fontVariantNumeric: 'tabular-nums',
};
function parseSubInvoiceItemVersionNumber(item) {
if (Number.isFinite(Number(item?.version_number))) return Number(item.version_number);
const match = String(item?.description || '').match(/\bR(\d{2})\b/i);
return match ? Number(match[1]) : 0;
}
function subInvoiceItemVersionLabel(item) {
return `R${String(parseSubInvoiceItemVersionNumber(item)).padStart(2, '0')}`;
}
function subInvoiceItemWorkLabel(item) {
return parseSubInvoiceItemVersionNumber(item) > 0 ? 'Revision' : 'New';
}
function cleanSubInvoiceItemDescription(item) {
return String(item?.description || '—').replace(/\s*[-]\s*R\d{2}\b/i, '').trim() || '—';
}
function asArray(value) {
if (Array.isArray(value)) return value;
if (value == null) return [];
return typeof value === 'object' ? [value] : [];
}
async function fetchSubInvoiceTaskTypeMap(taskIds) {
const uniqueTaskIds = [...new Set((taskIds || []).filter(Boolean))];
if (uniqueTaskIds.length === 0) return {};
const { data, error } = await supabase
.from('tasks')
.select('id, title, submissions(service_type, type)')
.in('id', uniqueTaskIds);
if (error) throw error;
const next = {};
for (const task of (data || [])) {
const submissions = asArray(task.submissions);
const initial = submissions.find((submission) => submission?.type === 'initial' && submission?.service_type);
const fallback = submissions.find((submission) => submission?.service_type);
next[task.id] = initial?.service_type || fallback?.service_type || task.title || 'Other';
}
return next;
}
function CurrencyInput({ value, onChange, placeholder = '0.00', required = false, min = '0', step = '0.01' }) {
return (
<div style={FINANCE_MODAL_AMOUNT_FIELD_STYLE}>
<span style={FINANCE_MODAL_AMOUNT_PREFIX_STYLE}>$</span>
<input
type="number"
min={min}
step={step}
required={required}
placeholder={placeholder}
value={value}
onChange={onChange}
style={FINANCE_MODAL_AMOUNT_INPUT_STYLE}
/>
</div>
);
}
const INV_TODAY = new Date().toISOString().split('T')[0];
const INV_NET30 = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const invNewItem = (description = '', unit_price = '', quantity = 1, task_id = null, submission_id = null) =>
({ id: crypto.randomUUID(), description, unit_price, quantity, task_id, submission_id });
const invBuildNewItemDescription = (task) => {
const projectName = task.project?.name || 'No Project';
const taskTitle = task.title || task.service_type || 'Untitled';
return `${projectName}${taskTitle}`;
};
const invBuildRevisionItemDescription = (revision) => {
const projectName = revision.task?.project?.name || 'No Project';
const taskTitle = revision.task?.title || revision.service_type || 'Revision';
const versionLabel = 'R' + String(revision.version_number || 0).padStart(2, '0');
return `${projectName}${taskTitle} • Revision ${versionLabel}`;
};
const blankExpense = () => ({
date: new Date().toISOString().slice(0, 10),
description: '',
@@ -78,6 +226,7 @@ function FinancesChartTooltip({ active, payload, label, year }) {
}
export default function Invoices() {
const { currentUser } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const cached = readPageCache('team_invoices');
@@ -126,6 +275,9 @@ export default function Invoices() {
const [subInvoicesLoading, setSubInvoicesLoading] = useState(true);
const [subInvoicesError, setSubInvoicesError] = useState('');
const [markingPaid, setMarkingPaid] = useState('');
const [viewingSubInvoice, setViewingSubInvoice] = useState(null);
const [viewingSubInvoiceTaskTypeMap, setViewingSubInvoiceTaskTypeMap] = useState({});
const [viewingInvoice, setViewingInvoice] = useState(null);
const [expandedSubInvoiceId, setExpandedSubInvoiceId] = useState(null);
// ── New Invoice form ──────────────────────────────────────────────────────
@@ -143,11 +295,30 @@ export default function Invoices() {
const [invSaving, setInvSaving] = useState(false);
const [invLoadingTasks, setInvLoadingTasks] = useState(false);
const invDragItem = useRef(null);
const pageLoading = loading || expensesLoading || subcontractorLoading || subInvoicesLoading;
useEffect(() => {
supabase.from('companies').select('id, name, contact_email').order('name').then(({ data }) => setInvCompanies(data || []));
}, []);
useEffect(() => {
let cancelled = false;
async function loadSubInvoiceTaskTypes() {
if (!viewingSubInvoice?.items?.length) {
setViewingSubInvoiceTaskTypeMap({});
return;
}
try {
const map = await fetchSubInvoiceTaskTypeMap(asArray(viewingSubInvoice.items).map((item) => item.task_id));
if (!cancelled) setViewingSubInvoiceTaskTypeMap(map);
} catch {
if (!cancelled) setViewingSubInvoiceTaskTypeMap({});
}
}
loadSubInvoiceTaskTypes();
return () => { cancelled = true; };
}, [viewingSubInvoice]);
useEffect(() => {
if (!invCompanyId) { setInvUnbilledTasks([]); setInvUnbilledRevisions([]); setInvPriceList([]); setInvItems([invNewItem()]); setInvBillTo(''); setInvEmail(''); setInvRecipients([]); return; }
const co = invCompanies.find(c => c.id === invCompanyId);
@@ -163,12 +334,32 @@ export default function Invoices() {
setInvEmail((users || [])[0]?.email || co?.contact_email || '');
if (projects?.length > 0) {
const pids = projects.map(p => p.id);
const { data: tasks } = await supabase.from('tasks').select('*, project:projects(name), submissions(service_type, type, version_number)').in('project_id', pids).eq('invoiced', false).eq('status', 'client_approved');
const tids = (tasks || []).map(t => t.id);
const { data: revisions } = tids.length > 0 ? await supabase.from('submissions').select('*, task:tasks(id, title, project:projects(name), submissions(service_type, type))').eq('type', 'revision').or('revision_type.eq.client_revision,revision_type.is.null').eq('invoiced', false).in('task_id', tids) : { data: [] };
setInvUnbilledTasks((tasks || []).map(t => { const ini = (t.submissions || []).find(s => s.type === 'initial') || t.submissions?.[0]; return { ...t, service_type: ini?.service_type || t.title }; }));
const { data: companyTasks } = await supabase
.from('tasks')
.select('*, project:projects(name), submissions(service_type, type, version_number)')
.in('project_id', pids)
.in('status', ['not_started', 'in_progress', 'on_hold', 'client_review', 'client_approved', 'invoiced', 'paid']);
const revisionTaskIds = (companyTasks || []).map(t => t.id).filter(Boolean);
const { data: revisions } = revisionTaskIds.length > 0
? await supabase
.from('submissions')
.select('*, task:tasks(id, title, status, current_version, project:projects(name), submissions(service_type, type))')
.eq('type', 'revision')
.eq('invoiced', false)
.in('task_id', revisionTaskIds)
.order('submitted_at', { ascending: false })
: { data: [] };
const uninvoicedTasks = (companyTasks || []).filter(isInitialVersionEligible);
setInvUnbilledTasks(uninvoicedTasks.map(t => {
const ini = (t.submissions || []).find(s => s.type === 'initial') || t.submissions?.[0];
return { ...t, service_type: ini?.service_type || t.title };
}));
const revMap = new Map();
for (const r of (revisions || [])) { const k = `${r.task_id}:${r.version_number}`; if (!revMap.has(k)) revMap.set(k, r); }
for (const r of (revisions || []).filter(r => !isReviewShadowDescription(r.description))) {
if (!isCompletedVersionEligible(r.task, r.version_number)) continue;
const k = `${r.task_id}:${r.version_number}`;
if (!revMap.has(k)) revMap.set(k, r);
}
setInvUnbilledRevisions([...revMap.values()]);
} else { setInvUnbilledTasks([]); setInvUnbilledRevisions([]); }
setInvLoadingTasks(false);
@@ -177,18 +368,17 @@ export default function Invoices() {
const invAddTask = (task) => {
const price = invPriceList.find(p => p.service_type === task.service_type && p.price_type === 'new');
const desc = `${task.project?.name || 'No Project'}${task.title || task.service_type || 'Untitled'}`;
const desc = invBuildNewItemDescription(task);
setInvItems(prev => prev.length === 1 && !prev[0].description && !prev[0].unit_price ? [invNewItem(desc, price?.price || '', 1, task.id)] : [...prev, invNewItem(desc, price?.price || '', 1, task.id)]);
};
const invAddRevision = (rev) => {
const ini = (rev.task?.submissions || []).find(s => s.type === 'initial') || rev.task?.submissions?.[0];
const svcType = ini?.service_type || rev.service_type || rev.task?.title || 'Revision';
const price = invPriceList.find(p => p.service_type === svcType && p.price_type === 'revision');
const version = Number(rev.version_number || 0);
const qty = version >= 2 ? 1 : 1;
const unitPrice = version >= 2 ? (price?.price || '') : 0;
const vLabel = 'R' + String(version).padStart(2, '0');
const desc = `${rev.task?.project?.name || 'No Project'}${rev.task?.title || 'Revision'} • Revision ${vLabel}`;
const revisionChargeQty = getRevisionChargeQuantity(rev?.version_number);
const qty = revisionChargeQty > 0 ? revisionChargeQty : 1;
const unitPrice = revisionChargeQty > 0 ? (price?.price || '') : 0;
const desc = invBuildRevisionItemDescription(rev);
setInvItems(prev => prev.length === 1 && !prev[0].description && !prev[0].unit_price ? [invNewItem(desc, unitPrice, qty, rev.task_id, rev.id)] : [...prev, invNewItem(desc, unitPrice, qty, rev.task_id, rev.id)]);
};
const invUpdateItem = (id, field, val) => setInvItems(prev => prev.map(it => it.id === id ? { ...it, [field]: val } : it));
@@ -226,14 +416,18 @@ export default function Invoices() {
const payUrl = `https://portal.fourgebranding.com/pay/${encodeURIComponent(invoiceNumber)}`;
const pdfItems = validItems.map(it => ({ description: it.description, quantity: Number(it.quantity) || 1, unit_price: Number(it.unit_price) || 0 }));
let attachments = [];
try { const pdf = await withTimeout(generateInvoicePDF({ ...invoice, status: 'sent' }, invSelectedCompany, pdfItems, { save: false }), 8000, 'PDF'); attachments = [await withTimeout(blobToEmailAttachment(pdf, `${invoiceNumber}.pdf`), 5000, 'Attachment')]; } catch {}
try { const pdf = await withTimeout(generateInvoicePDF({ ...invoice, status: 'sent' }, invSelectedCompany, pdfItems, { save: false }), 8000, 'PDF'); attachments = [await withTimeout(blobToEmailAttachment(pdf, `${invoiceNumber}.pdf`), 5000, 'Attachment')]; } catch { /* PDF attach failed — send email without attachment */ }
await withTimeout(sendEmail('invoice_sent', invEmail.trim(), { invoiceNumber, billTo: invBillTo || invSelectedCompany?.name, total: `$${invTotal.toFixed(2)}`, dueDate, payUrl, notes: invNotes || '' }, attachments), 12000, 'Email');
await supabase.from('invoices').update({ status: 'sent' }).eq('id', invoice.id);
} catch (e) { alert(`Invoice saved as draft — email failed: ${e.message}`); }
}
setInvoices(prev => [{ ...invoice, company: { name: invSelectedCompany?.name || '' } }, ...prev]);
const createdInvoice = {
...invoice,
company: invSelectedCompany ? { id: invSelectedCompany.id, name: invSelectedCompany.name } : null,
};
setInvoices(prev => [createdInvoice, ...prev]);
invClose();
navigate(`/invoices/${invoice.id}`);
setViewingInvoice(createdInvoice);
} catch (e) { alert(`Failed to save invoice: ${e.message || 'Unknown error'}`); }
setInvSaving(false);
};
@@ -409,26 +603,7 @@ export default function Invoices() {
};
const handleSendSubcontractorPO = async (po) => {
const sentPO = await updateSubcontractorPO(po, { status: 'sent', sent_at: new Date().toISOString() }, 'Failed to send PO');
if (!sentPO?.profile?.email) return;
try {
await sendEmail('subcontractor_po_sent', sentPO.profile.email, {
poNumber: sentPO.po_number || 'Purchase Order',
subcontractorName: sentPO.profile?.name || 'there',
projectName: sentPO.project?.name || 'Subcontractor Work',
companyName: sentPO.project?.company?.name || 'Fourge Branding',
amount: `$${Number(sentPO.amount).toFixed(2)}`,
dueDate: sentPO.due_date ? new Date(sentPO.due_date).toLocaleDateString() : 'Not set',
terms: sentPO.terms || 'Net 15',
scope: sentPO.items?.length
? sentPO.items.map(item => `${item.description}$${Number(item.amount).toFixed(2)}`).join('\n')
: sentPO.description,
portalUrl: 'https://portal.fourgebranding.com/my-purchase-orders',
});
} catch (emailError) {
console.error('Failed to email subcontractor PO:', emailError);
alert(`PO was marked sent, but the email failed: ${emailError.message || 'unknown error'}`);
}
await updateSubcontractorPO(po, { status: 'sent', sent_at: new Date().toISOString() }, 'Failed to send PO');
};
const handleReadyToPaySubcontractorPO = (po) => {
@@ -490,6 +665,56 @@ export default function Invoices() {
setMarkingPaid('');
};
const handleDownloadSubInvoiceReceipt = async (invoice) => {
const items = invoice.items || [];
const total = items.reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
try {
await generateSubcontractorPOPDF({
po_number: invoice.invoice_number,
status: 'paid',
profile: invoice.profile,
project: { name: 'Subcontractor Invoice', company: { name: 'Fourge Branding' } },
date: invoice.created_at?.split('T')[0],
due_date: (invoice.paid_at || new Date().toISOString()).split('T')[0],
amount: total,
description: 'Payment for completed subcontractor work.',
notes: invoice.notes,
items: items.map((item, idx) => ({
description: item.description,
amount: Number(item.unit_price) * Number(item.quantity || 1),
sort_order: item.sort_order ?? idx,
})),
});
} catch (err) {
alert(`Failed to download receipt: ${err.message || 'unknown error'}`);
}
};
const handleDownloadSubInvoice = async (invoice) => {
const items = invoice.items || [];
const total = items.reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
try {
await generateSubcontractorPOPDF({
po_number: invoice.invoice_number,
status: invoice.status || 'draft',
profile: invoice.profile,
project: { name: 'Subcontractor Invoice', company: { name: 'Fourge Branding' } },
date: invoice.created_at?.split('T')[0],
due_date: (invoice.paid_at || invoice.submitted_at || invoice.created_at || new Date().toISOString()).split('T')[0],
amount: total,
description: invoice.status === 'paid' ? 'Payment for completed subcontractor work.' : 'Subcontractor invoice for completed work.',
notes: invoice.notes,
items: items.map((item, idx) => ({
description: item.description,
amount: Number(item.unit_price || 0) * Number(item.quantity || 1),
sort_order: item.sort_order ?? idx,
})),
});
} catch (err) {
alert(`Failed to download invoice: ${err.message || 'unknown error'}`);
}
};
const handleReopenSubcontractorPO = async (po) => {
updateSubcontractorPO(po, { status: 'draft', paid_at: null, cancelled_at: null }, 'Failed to reopen PO');
};
@@ -622,7 +847,7 @@ export default function Invoices() {
return (
<Layout>
<Layout loading={pageLoading}>
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* Top row: 60% chart + 40% stat cards */}
{(() => {
@@ -653,7 +878,7 @@ export default function Invoices() {
<div style={{ ...CARD }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Revenue Overview</span>
<select className="filter-select" value={chartYear} onChange={e => setChartYear(Number(e.target.value))} style={{ width: 90, borderRadius: 8, fontSize: 11, fontWeight: 500 }}>
<select className="filter-select" value={chartYear} onChange={e => setExportYear(Number(e.target.value))} style={{ width: 90, borderRadius: 8, fontSize: 11, fontWeight: 500 }}>
{(paidYears.length > 0 ? paidYears : [new Date().getFullYear()]).map(y => <option key={y} value={y}>{y}</option>)}
</select>
</div>
@@ -694,7 +919,6 @@ export default function Invoices() {
{ id: 'expenses', label: 'Expenses' },
{ id: 'subcontractors', label: 'Subcontractors' },
{ id: 'invoices', label: 'Invoices' },
{ id: 'legacy', label: 'Legacy' },
].map(t => (
<button
key={t.id}
@@ -1075,7 +1299,7 @@ export default function Invoices() {
return (
<tr key={inv.id}>
<td style={{ ...TD }}>
<button type="button" className="table-link" onClick={() => navigate(`/sub-invoices/${inv.id}`)}>
<button type="button" className="table-link" onClick={() => setViewingSubInvoice(inv)}>
{inv.invoice_number || '—'}
</button>
</td>
@@ -1195,7 +1419,7 @@ export default function Invoices() {
<tbody>{sorted.map(inv => (
<tr key={inv.id}>
<td style={{ ...TD }}>
<button type="button" className="dashboard-inline-link" onClick={() => navigate(`/invoices/${inv.id}`)}>{inv.invoice_number}</button>
<button type="button" className="dashboard-inline-link" onClick={() => setViewingInvoice(inv)}>{inv.invoice_number}</button>
</td>
<td style={{ ...TD }}>
<button type="button" className="dashboard-inline-link" onClick={() => inv.company?.id && navigate(`/company/${inv.company.id}`)}>{inv.company?.name || inv.bill_to || '—'}</button>
@@ -1252,24 +1476,22 @@ export default function Invoices() {
})()}
{financeTab === 'legacy' && <div>
<div style={{ display: 'flex', alignItems: 'center', gap: 0, marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10, minHeight: 'var(--btn-height)' }}>
{[
{ id: 'invoices', label: 'INVOICES' },
{ id: 'expenses', label: 'EXPENSES' },
{ id: 'sub-invoices', label: 'SUBCONTRACTOR INVOICES' },
].map((t, i, arr) => (
<span key={t.id} style={{ display: 'flex', alignItems: 'center' }}>
<button
type="button"
onClick={() => setActiveTab(t.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontSize: 13, letterSpacing: 0.5, fontWeight: activeTab === t.id ? 600 : 400, color: activeTab === t.id ? 'var(--text-primary)' : 'var(--text-muted)', fontFamily: 'inherit' }}
>{t.label}</button>
{i < arr.length - 1 && <span style={{ margin: '0 10px', color: 'var(--border)', userSelect: 'none' }}>|</span>}
</span>
<button
key={t.id}
type="button"
onClick={() => setActiveTab(t.id)}
className={`section-tab-btn${activeTab === t.id ? ' is-active' : ''}`}
>{t.label}</button>
))}
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 10, minHeight: 'var(--btn-height)', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{companyNames.length > 0 && activeTab === 'invoices' && (
<select className="filter-select" style={{ width: 120 }} value={filterCompany} onChange={e => setFilterCompany(e.target.value)}>
@@ -1310,10 +1532,10 @@ export default function Invoices() {
{loading ? (
<p style={{ color: 'var(--text-muted)' }}>Loading...</p>
) : filtered.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No invoices.</p>
<div className="card card-empty-center">No invoices</div>
) : (
<div style={{ background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<table>
<div className="table-wrapper">
<table className="table-sticky-head">
<thead>
<tr>
<SortTh col="invoice_number" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle}>Invoice #</SortTh>
@@ -1330,7 +1552,7 @@ export default function Invoices() {
if (key === 'invoice_date' || key === 'due_date') return new Date(inv[key]).getTime();
return inv[key] || inv.company?.name || '';
}).map(inv => (
<tr key={inv.id} onClick={() => navigate(`/invoices/${inv.id}`)} style={{ cursor: 'pointer' }}>
<tr key={inv.id} onClick={() => setViewingInvoice(inv)} style={{ cursor: 'pointer' }}>
<td style={{ fontWeight: 400 }}>{inv.invoice_number}</td>
<td style={{ fontWeight: 400 }}>{inv.bill_to || inv.company?.name}</td>
<td style={{ color: 'var(--text-muted)' }}>{new Date(inv.invoice_date).toLocaleDateString()}</td>
@@ -1359,17 +1581,17 @@ export default function Invoices() {
{activeTab === 'expenses' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<div>
<div style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 10 }}>
{expensesLoading ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading...</p>
) : expensesError ? (
<p style={{ color: 'var(--danger)', fontSize: 13 }}>{expensesError}</p>
) : filteredExpenses.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No expenses yet.</p>
<div className="card card-empty-center">No expenses</div>
) : (
<div style={{ background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<table>
<div className="table-wrapper">
<table className="table-sticky-head">
<thead>
<tr>
<SortTh col="date" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle}>Date</SortTh>
@@ -1431,16 +1653,16 @@ export default function Invoices() {
{activeTab === 'sub-invoices' && (
<div>
<div style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 10 }}>
{subInvoicesLoading ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading...</p>
) : subInvoicesError ? (
<p style={{ color: 'var(--danger)', fontSize: 13 }}>{subInvoicesError}</p>
) : subInvoices.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No sub invoices yet.</p>
<div className="card card-empty-center">No sub invoices</div>
) : (
<div style={{ background: 'var(--card-bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<table>
<div className="table-wrapper">
<table className="table-sticky-head">
<thead>
<tr>
<SortTh col="invoice_number" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle}>Invoice #</SortTh>
@@ -1461,7 +1683,7 @@ export default function Invoices() {
const total = (inv.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
const subInvoiceStatusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
return (
<tr key={inv.id} style={{ cursor: 'pointer' }} onClick={() => navigate(`/sub-invoices/${inv.id}`)}>
<tr key={inv.id} style={{ cursor: 'pointer' }} onClick={() => setViewingSubInvoice(inv)}>
<td style={{ fontWeight: 400 }}>{inv.invoice_number}</td>
<td>
<div style={{ fontWeight: 400 }}>{inv.profile?.name || 'External'}</div>
@@ -1488,9 +1710,139 @@ export default function Invoices() {
)}
</div>}{/* end legacy wrapper */}
{viewingSubInvoice && (() => {
const invoice = viewingSubInvoice;
const sortedItems = [...(invoice.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
const total = sortedItems.reduce((s, item) => s + Number(item.unit_price || 0) * Number(item.quantity || 1), 0);
const invoiceStatus = invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—';
const subInvoiceStatusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
return (
<div style={popupOverlayStyle} onClick={() => { if (!markingPaid) setViewingSubInvoice(null); }}>
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16, paddingBottom: 18, borderBottom: '1px solid var(--border)' }}>
<div>
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--text-primary)', letterSpacing: 0.2, lineHeight: 1.1 }}>{invoice.invoice_number || 'Subcontractor Invoice'}</div>
<div style={{ marginTop: 6, fontSize: 13, color: 'var(--text-secondary)' }}>
{invoice.profile?.name || 'External'}{invoice.profile?.email ? ` · ${invoice.profile.email}` : ''}
</div>
</div>
<StatusBadge status={subInvoiceStatusColor[invoice.status] || 'not_started'} label={invoiceStatus} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 18, padding: '18px 0', borderBottom: '1px solid var(--border)' }}>
<div>
<div style={FIELD_LABEL_STYLE}>Submitted</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.submitted_at ? new Date(invoice.submitted_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div>
</div>
<div>
<div style={FIELD_LABEL_STYLE}>Paid</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.paid_at ? new Date(invoice.paid_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div>
</div>
<div>
<div style={FIELD_LABEL_STYLE}>Line Items</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{sortedItems.length}</div>
</div>
<div>
<div style={FIELD_LABEL_STYLE}>Total</div>
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--accent)', lineHeight: 1.1 }}>${total.toFixed(2)}</div>
</div>
</div>
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto', paddingTop: 18 }}>
{sortedItems.length === 0 ? (
<div className="card-empty-center">No line items</div>
) : (
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '8%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '32%' }} />
<col style={{ width: '16%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '12%' }} />
<col style={{ width: '12%' }} />
</colgroup>
<thead>
<tr>
<th style={{ textAlign: 'center' }}>Work</th>
<th style={{ textAlign: 'center' }}>R#</th>
<th style={{ textAlign: 'left' }}>Description</th>
<th style={{ textAlign: 'center' }}>Type</th>
<th style={{ textAlign: 'center' }}>Qty</th>
<th style={{ textAlign: 'center' }}>Unit Price</th>
<th style={{ textAlign: 'center' }}>Amount</th>
</tr>
</thead>
<tbody>
{sortedItems.map(item => (
<tr key={item.id}>
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)', textAlign: 'center' }}>{subInvoiceItemWorkLabel(item)}</td>
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)', textAlign: 'center' }}>{subInvoiceItemVersionLabel(item)}</td>
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)' }}>{cleanSubInvoiceItemDescription(item)}</td>
<td style={{ padding: '5px 0', fontSize: 13, color: 'var(--text-primary)', textAlign: 'center' }}>{viewingSubInvoiceTaskTypeMap[item.task_id] || 'Other'}</td>
<td style={{ padding: '5px 0', textAlign: 'center', fontSize: 13, color: 'var(--text-primary)' }}>{item.quantity || 1}</td>
<td style={{ padding: '5px 0', textAlign: 'center', fontSize: 13, color: 'var(--text-primary)' }}>${Number(item.unit_price || 0).toFixed(2)}</td>
<td style={{ padding: '5px 0', textAlign: 'center', fontSize: 13, color: 'var(--text-primary)' }}>${(Number(item.unit_price || 0) * Number(item.quantity || 1)).toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
)}
{invoice.notes && (
<div style={{ marginTop: 18, padding: '12px 14px', background: 'var(--card-bg-2)', borderRadius: 6, border: '1px solid var(--border)' }}>
<div style={FIELD_LABEL_STYLE}>Notes</div>
<div style={{ fontSize: 13, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap' }}>{invoice.notes}</div>
</div>
)}
</div>
<div className="modal-action-row" style={{ paddingTop: 18, borderTop: '1px solid var(--border)' }}>
<button type="button" className="btn btn-outline" onClick={() => handleDownloadSubInvoice(invoice)}>
Download Invoice
</button>
{invoice.status === 'submitted' && (
<LoadingButton className="btn btn-outline" onClick={async () => {
await handleMarkSubInvoicePaid(invoice);
setViewingSubInvoice(current => current?.id === invoice.id ? { ...current, status: 'paid', paid_at: new Date().toISOString() } : current);
}} loading={markingPaid === invoice.id} loadingText="Processing…">
Mark as Paid
</LoadingButton>
)}
{invoice.status === 'paid' && (
<button type="button" className="btn btn-outline" onClick={() => handleDownloadSubInvoiceReceipt(invoice)}>
Download Receipt
</button>
)}
<button type="button" className="btn btn-outline" onClick={() => setViewingSubInvoice(null)}>
Cancel
</button>
</div>
</div>
</div>
);
})()}
{viewingInvoice && (
<TeamInvoiceDetailPanel
invoiceId={viewingInvoice.id}
initialInvoice={viewingInvoice}
embedded
onClose={() => setViewingInvoice(null)}
onInvoiceUpdated={(updatedInvoice) => {
setViewingInvoice(updatedInvoice);
setInvoices((current) => current.map((invoice) => (invoice.id === updatedInvoice.id ? { ...invoice, ...updatedInvoice } : invoice)));
}}
onInvoiceDeleted={(deletedId) => {
setInvoices((current) => current.filter((invoice) => invoice.id !== deletedId));
setViewingInvoice((current) => (current?.id === deletedId ? null : current));
}}
/>
)}
{viewingExpense && (() => {
const LABEL = { fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 3 };
const INPUT = { fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' };
const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 };
const INPUT = FINANCE_MODAL_INPUT_STYLE;
const isPdf = viewingExpense.receipt_name?.toLowerCase().endsWith('.pdf');
const isDirty = expenseDetailEditing && (
newExpense.amount !== String(viewingExpense.amount ?? '') ||
@@ -1510,9 +1862,13 @@ export default function Invoices() {
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: '0 0 260px', overflowY: 'auto' }}>
<div style={LABEL}>Expense Detail</div>
{expenseDetailEditing ? (
<input type="number" step="0.01" min="0" required style={{ ...INPUT, fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#ef4444', fontVariantNumeric: 'tabular-nums', padding: '0 6px' }} value={newExpense.amount} onChange={e => setNewExpense(p => ({ ...p, amount: e.target.value }))} />
<CurrencyInput
value={newExpense.amount}
required
onChange={e => setNewExpense(p => ({ ...p, amount: e.target.value }))}
/>
) : (
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#ef4444', fontVariantNumeric: 'tabular-nums' }}>
<div style={{ ...FINANCE_MODAL_TOTAL_VALUE_STYLE, color: 'var(--danger)' }}>
${Number(viewingExpense.amount).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
)}
@@ -1568,7 +1924,7 @@ export default function Invoices() {
)}
</div>
{/* Footer: buttons bottom-right */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 14, marginTop: 14, flexShrink: 0 }}>
<div className="modal-action-row" style={{ paddingTop: 14, marginTop: 14, flexShrink: 0 }}>
{expenseDetailEditing ? (
<>
<button className="btn btn-outline" disabled={!isDirty || addingExpense} onClick={async () => { await saveExpense(); setExpenseDetailEditing(false); setViewingExpense(null); }}>{addingExpense ? 'Saving…' : 'Save'}</button>
@@ -1590,8 +1946,8 @@ export default function Invoices() {
})()}
{showExpenseForm && (() => {
const LABEL = { fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 3 };
const INPUT = { fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' };
const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 };
const INPUT = FINANCE_MODAL_INPUT_STYLE;
return (
<div style={popupOverlayStyle} onClick={cancelExpenseEdit}>
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
@@ -1601,7 +1957,11 @@ export default function Invoices() {
{/* Left: fields */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: '0 0 260px', overflowY: 'auto' }}>
<div style={LABEL}>New Expense</div>
<input type="number" step="0.01" min="0" required placeholder="0.00" style={{ ...INPUT, fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#ef4444', fontVariantNumeric: 'tabular-nums', padding: '0 6px' }} value={newExpense.amount} onChange={e => setNewExpense(p => ({ ...p, amount: e.target.value }))} />
<CurrencyInput
value={newExpense.amount}
required
onChange={e => setNewExpense(p => ({ ...p, amount: e.target.value }))}
/>
<div>
<div style={LABEL}>Description</div>
<input type="text" required placeholder="What was this for?" style={INPUT} value={newExpense.description} onChange={e => setNewExpense(p => ({ ...p, description: e.target.value }))} />
@@ -1629,7 +1989,7 @@ export default function Invoices() {
</div>
</div>
{/* Footer */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 14, marginTop: 14, flexShrink: 0 }}>
<div className="modal-action-row" style={{ paddingTop: 14, marginTop: 14, flexShrink: 0 }}>
<button type="submit" className="btn btn-outline" disabled={addingExpense}>{addingExpense ? 'Saving…' : 'Save'}</button>
<button type="button" className="btn btn-outline" onClick={cancelExpenseEdit}>Cancel</button>
</div>
@@ -1642,8 +2002,8 @@ export default function Invoices() {
</div>{/* end flex column wrapper */}
{showInvoiceForm && (() => {
const LABEL = { fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 3 };
const INPUT = { fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' };
const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 };
const INPUT = FINANCE_MODAL_INPUT_STYLE;
return (
<div style={popupOverlayStyle} onClick={() => { if (!invSaving) invClose(); }}>
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
@@ -1672,11 +2032,11 @@ export default function Invoices() {
</>}
<div>
<div style={LABEL}>Notes</div>
<textarea style={{ ...INPUT, minHeight: 72, resize: 'vertical' }} value={invNotes} onChange={e => setInvNotes(e.target.value)} placeholder="Payment terms, notes…" />
<textarea style={FINANCE_MODAL_TEXTAREA_STYLE} value={invNotes} onChange={e => setInvNotes(e.target.value)} placeholder="Payment terms, notes…" />
</div>
<div style={{ marginTop: 'auto', paddingTop: 8 }}>
<div style={LABEL}>Total</div>
<div style={{ fontSize: 28, fontWeight: 400, color: 'var(--accent)', fontVariantNumeric: 'tabular-nums' }}>${invTotal.toFixed(2)}</div>
<div style={FINANCE_MODAL_TOTAL_VALUE_STYLE}>${invTotal.toFixed(2)}</div>
</div>
</div>
@@ -1697,7 +2057,10 @@ export default function Invoices() {
return (
<div key={t.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--card-bg-2)', borderRadius: 4, border: '1px solid var(--border)', marginBottom: 4 }}>
<div>
<div style={{ fontSize: 12 }}>{t.project?.name} {t.title || t.service_type}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span className="badge badge-initial">New</span>
<div style={{ fontSize: 12 }}>{t.project?.name} {t.title || t.service_type}</div>
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{price ? `$${Number(price.price).toFixed(2)}` : 'No price'}</div>
</div>
<button type="button" className="btn btn-outline" style={{ fontSize: 11 }} disabled={added} onClick={() => invAddTask(t)}>{added ? 'Added' : '+ Add'}</button>
@@ -1715,9 +2078,20 @@ export default function Invoices() {
</div>
{invUnbilledRevisions.map(r => {
const added = invItems.some(i => i.submission_id === r.id);
const revisionQty = getRevisionChargeQuantity(r?.version_number);
const initial = (r.task?.submissions || []).find(s => s.type === 'initial') || r.task?.submissions?.[0];
const revisionServiceType = initial?.service_type || r.service_type || r.task?.title || 'Revision';
const revisionPrice = invPriceList.find(p => p.service_type === revisionServiceType && p.price_type === 'revision');
const revisionLabel = revisionQty > 0 ? (revisionPrice ? `$${Number(revisionPrice.price).toFixed(2)}` : 'No price') : 'Free';
return (
<div key={r.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 10px', background: 'var(--card-bg-2)', borderRadius: 4, border: '1px solid var(--border)', marginBottom: 4 }}>
<div style={{ fontSize: 12 }}>{r.task?.project?.name} {r.task?.title} R{String(r.version_number || 0).padStart(2, '0')}</div>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span className="badge badge-client_revision">Revision</span>
<div style={{ fontSize: 12 }}>{r.task?.project?.name} {r.task?.title} R{String(r.version_number || 0).padStart(2, '0')}</div>
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{revisionLabel}</div>
</div>
<button type="button" className="btn btn-outline" style={{ fontSize: 11 }} disabled={added} onClick={() => invAddRevision(r)}>{added ? 'Added' : '+ Add'}</button>
</div>
);
@@ -1727,16 +2101,19 @@ export default function Invoices() {
{/* Line items */}
<div style={{ flex: 1 }}>
<div style={LABEL}>Line Items</div>
<div style={{ display: 'grid', gridTemplateColumns: '20px 1fr 60px 100px 90px 28px', gap: 6, marginBottom: 6 }}>
{['', 'Description', 'Qty', 'Unit Price', 'Total', ''].map((h, i) => <div key={i} style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.5, color: 'var(--text-muted)', textAlign: i > 2 ? 'right' : 'left' }}>{h}</div>)}
<div style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 60px 100px 90px 28px', gap: 6, marginBottom: 6 }}>
{['', 'Type', 'Description', 'Qty', 'Unit Price', 'Total', ''].map((h, i) => <div key={i} style={{ fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.5, color: 'var(--text-muted)', textAlign: i > 3 ? 'right' : 'left' }}>{h}</div>)}
</div>
{invItems.map((item, idx) => (
<div key={item.id} onDragOver={e => e.preventDefault()} onDrop={() => invHandleDrop(idx)} style={{ display: 'grid', gridTemplateColumns: '20px 1fr 60px 100px 90px 28px', gap: 6, alignItems: 'center', marginBottom: 4 }}>
<div key={item.id} onDragOver={e => e.preventDefault()} onDrop={() => invHandleDrop(idx)} style={{ display: 'grid', gridTemplateColumns: '20px 90px 1fr 60px 100px 90px 28px', gap: 6, alignItems: 'center', marginBottom: 4 }}>
<div draggable onDragStart={() => { invDragItem.current = idx; }} style={{ cursor: 'grab', color: 'var(--text-muted)', fontSize: 12, textAlign: 'center', userSelect: 'none' }}></div>
<input style={{ ...INPUT, padding: '4px 8px' }} type="text" placeholder="Description…" value={item.description} onChange={e => invUpdateItem(item.id, 'description', e.target.value)} />
<input style={{ ...INPUT, padding: '4px 6px', textAlign: 'center' }} type="number" min="1" value={item.quantity} onChange={e => invUpdateItem(item.id, 'quantity', e.target.value)} />
<input style={{ ...INPUT, padding: '4px 8px', textAlign: 'right' }} type="number" min="0" step="0.01" placeholder="0.00" value={item.unit_price} onChange={e => invUpdateItem(item.id, 'unit_price', e.target.value)} />
<div style={{ textAlign: 'right', fontSize: 13, color: 'var(--text-primary)', paddingRight: 2 }}>${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)}</div>
<span className={`badge ${item.submission_id ? 'badge-client_revision' : 'badge-initial'}`}>
{item.submission_id ? 'Revision' : 'New'}
</span>
<input style={FINANCE_MODAL_LINE_ITEM_INPUT_STYLE} type="text" placeholder="Description…" value={item.description} onChange={e => invUpdateItem(item.id, 'description', e.target.value)} />
<input style={{ ...FINANCE_MODAL_NUMBER_INPUT_STYLE, textAlign: 'center' }} type="number" min="1" value={item.quantity} onChange={e => invUpdateItem(item.id, 'quantity', e.target.value)} />
<input style={{ ...FINANCE_MODAL_NUMBER_INPUT_STYLE, textAlign: 'right' }} type="number" min="0" step="0.01" placeholder="0.00" value={item.unit_price} onChange={e => invUpdateItem(item.id, 'unit_price', e.target.value)} />
<div style={{ minHeight: 32, display: 'flex', alignItems: 'center', justifyContent: 'flex-end', textAlign: 'right', fontSize: 13, color: 'var(--text-primary)', paddingRight: 2, fontVariantNumeric: 'tabular-nums' }}>${((Number(item.quantity) || 0) * (Number(item.unit_price) || 0)).toFixed(2)}</div>
<button type="button" onClick={() => invRemoveItem(item.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', fontSize: 14, padding: 2 }}></button>
</div>
))}
@@ -1746,7 +2123,7 @@ export default function Invoices() {
</div>
{/* Footer */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 14, marginTop: 14, flexShrink: 0 }}>
<div className="modal-action-row" style={{ paddingTop: 14, marginTop: 14, flexShrink: 0 }}>
<button type="button" className="btn btn-outline" disabled={invSaving} onClick={() => invHandleSave('draft')}>{invSaving ? 'Saving…' : 'Save Draft'}</button>
<LoadingButton className="btn btn-outline" style={{ color: 'var(--accent)', borderColor: 'var(--accent)' }} onClick={() => invHandleSave('sent')} loading={invSaving} loadingText="Sending…">Finalise & Send</LoadingButton>
<button type="button" className="btn btn-outline" disabled={invSaving} onClick={invClose}>Cancel</button>
+373
View File
@@ -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>
);
}
+77 -114
View File
@@ -1,14 +1,13 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import Layout from '../../components/Layout';
import SortTh from '../../components/SortTh';
import StatusBadge from '../../components/StatusBadge';
import PageLoader from '../../components/PageLoader';
import LoadingButton from '../../components/LoadingButton';
import { supabase } from '../../lib/supabase';
import { generateSubcontractorPOPDF } from '../../lib/invoice';
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
import { useSortable } from '../../hooks/useSortable';
const statusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
import SubcontractorInvoiceDetailView from '../../components/SubcontractorInvoiceDetailView';
export default function SubInvoiceDetail() {
const { id } = useParams();
@@ -17,6 +16,7 @@ export default function SubInvoiceDetail() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [generating, setGenerating] = useState(false);
const [error, setError] = useState('');
const { sortKey, sortDir, toggle, sort } = useSortable('description');
useEffect(() => {
@@ -25,18 +25,24 @@ export default function SubInvoiceDetail() {
.select('*, profile:profiles!subcontractor_invoices_profile_id_fkey(id, name, email), items:subcontractor_invoice_items(*)')
.eq('id', id)
.single()
.then(({ data }) => {
setInvoice(data);
.then(({ data, error: err }) => {
if (err || !data) setError('Invoice not found.');
setInvoice(data || null);
setLoading(false);
});
}, [id]);
const total = (invoice?.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
const total = (invoice?.items || []).reduce((sum, item) => sum + Number(item.unit_price || 0) * Number(item.quantity || 1), 0);
const sortedItems = [...(invoice?.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
const tableItems = sort(sortedItems, (item, key) => {
const typeSort = /\bR(\d{2})\b/i.test(String(item.description || ''))
? Number(String(item.description).match(/\bR(\d{2})\b/i)?.[1] || 0)
: item.task_id ? 0 : 999;
if (key === 'type') return typeSort;
if (key === 'description') return item.description || '';
if (key === 'quantity') return Number(item.quantity || 0);
if (key === 'unit_price') return Number(item.unit_price || 0);
if (key === 'amount') return Number(item.unit_price || 0) * Number(item.quantity || 1);
if (key === 'line_total') return Number(item.unit_price || 0) * Number(item.quantity || 1);
return '';
});
@@ -58,15 +64,18 @@ export default function SubInvoiceDetail() {
});
const handleMarkPaid = async () => {
if (!invoice) return;
setSaving(true);
try {
const paidAt = new Date().toISOString();
const { error } = await supabase.from('subcontractor_invoices').update({ status: 'paid', paid_at: paidAt }).eq('id', id);
if (error) throw error;
const { error: updateError } = await supabase.from('subcontractor_invoices').update({ status: 'paid', paid_at: paidAt }).eq('id', id);
if (updateError) throw updateError;
const updated = { ...invoice, status: 'paid', paid_at: paidAt };
setInvoice(updated);
let pdfBlob = null;
try { pdfBlob = await generateSubcontractorPOPDF(buildPDFArgs(updated), { output: 'blob' }); } catch {}
try {
pdfBlob = await generateSubcontractorPOPDF(buildPDFArgs(updated), { output: 'blob' });
} catch { /* PDF generation failed — continue without attachment */ }
if (invoice.profile?.email) {
try {
const attachments = pdfBlob ? [await blobToEmailAttachment(pdfBlob, `${invoice.invoice_number}-receipt.pdf`)] : [];
@@ -77,136 +86,90 @@ export default function SubInvoiceDetail() {
paidDate: new Date(paidAt).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }),
}, attachments);
} catch (emailErr) {
alert(`Marked paid, but email failed: ${emailErr.message || 'unknown error'}`);
setError(`Marked paid, but email failed: ${emailErr.message || 'unknown error'}`);
}
}
} catch (err) {
alert(`Failed to mark as paid: ${err.message}`);
setError(`Failed to mark as paid: ${err.message}`);
}
setSaving(false);
};
const handleReceipt = async () => {
if (!invoice) return;
setGenerating(true);
try {
await generateSubcontractorPOPDF(buildPDFArgs(invoice));
} catch (err) {
alert(err.message);
setError(err.message);
}
setGenerating(false);
};
const handleDelete = async () => {
if (!invoice) return;
if (!window.confirm(`Delete invoice ${invoice.invoice_number}? This cannot be undone.`)) return;
setSaving(true);
const { error } = await supabase.from('subcontractor_invoices').delete().eq('id', id);
if (error) { alert('Failed to delete: ' + error.message); setSaving(false); return; }
navigate('/invoices', { state: { tab: 'sub-invoices' } });
const { error: deleteError } = await supabase.from('subcontractor_invoices').delete().eq('id', id);
if (deleteError) {
setError(`Failed to delete: ${deleteError.message}`);
setSaving(false);
return;
}
navigate('/finances', { state: { tab: 'sub-invoices' } });
};
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
if (!invoice) return <Layout><p style={{ padding: 24 }}>Invoice not found.</p></Layout>;
if (loading) return <Layout><PageLoader /></Layout>;
if (!invoice) return <Layout><p style={{ padding: 24 }}>{error || 'Invoice not found.'}</p></Layout>;
const sortedItems = [...(invoice.items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
const headerActions = (
<>
{invoice.status === 'submitted' ? (
<LoadingButton className="btn btn-outline" loading={saving} loadingText="Processing…" disabled={saving} onClick={handleMarkPaid}>
Mark as Paid
</LoadingButton>
) : null}
{invoice.status === 'paid' ? (
<LoadingButton className="btn btn-outline" loading={generating} loadingText="Generating…" disabled={generating} onClick={handleReceipt}>
Download Receipt
</LoadingButton>
) : null}
<button className="btn btn-outline" style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }} onClick={handleDelete} disabled={saving}>
Delete
</button>
</>
);
return (
<Layout>
<button className="back-link" onClick={() => navigate('/invoices', { state: { tab: 'sub-invoices' } })}>
Back to Subcontractor Invoices
</button>
<div className="page-header">
<div>
<div className="page-title">{invoice.invoice_number}</div>
<div className="page-subtitle">
{invoice.profile?.name || 'External'} · {invoice.profile?.email || '—'}
</div>
</div>
<StatusBadge
status={statusColor[invoice.status] || 'not_started'}
label={invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'}
/>
</div>
<div className="grid-2" style={{ marginBottom: 24 }}>
<div className="card">
<div className="card-title">Invoice Info</div>
<div className="detail-grid" style={{ marginBottom: 0 }}>
<div className="detail-item"><label>Invoice #</label><p style={{ fontWeight: 400 }}>{invoice.invoice_number}</p></div>
<div className="detail-item"><label>Status</label><p style={{ textTransform: 'capitalize' }}>{invoice.status}</p></div>
<div className="detail-item"><label>Submitted</label><p>{invoice.submitted_at ? new Date(invoice.submitted_at).toLocaleDateString() : '—'}</p></div>
{invoice.paid_at && (
<div className="detail-item"><label>Paid On</label><p style={{ color: 'var(--success)', fontWeight: 400 }}>{new Date(invoice.paid_at).toLocaleDateString()}</p></div>
)}
</div>
</div>
<div className="card">
<div className="card-title">Summary</div>
<div className="detail-grid" style={{ marginBottom: 0 }}>
<div className="detail-item"><label>Line Items</label><p>{sortedItems.length}</p></div>
<div className="detail-item"><label>Total</label><p style={{ fontWeight: 400, fontSize: 18 }}>${total.toFixed(2)}</p></div>
</div>
</div>
</div>
<div className="card" style={{ marginBottom: 24 }}>
<div className="card-title">Line Items</div>
{sortedItems.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>No line items.</p>
) : (
<div className="table-wrapper" style={{ border: 'none' }}>
<table>
<thead>
<tr>
<SortTh col="description" sortKey={sortKey} sortDir={sortDir} onSort={toggle}>Description</SortTh>
<SortTh col="quantity" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Qty</SortTh>
<SortTh col="unit_price" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Unit Price</SortTh>
<SortTh col="amount" sortKey={sortKey} sortDir={sortDir} onSort={toggle} style={{ textAlign: 'right' }}>Amount</SortTh>
</tr>
</thead>
<tbody>
{tableItems.map(item => (
<tr key={item.id}>
<td>{item.description}</td>
<td style={{ textAlign: 'right' }}>{item.quantity}</td>
<td style={{ textAlign: 'right' }}>${Number(item.unit_price).toFixed(2)}</td>
<td style={{ textAlign: 'right', fontWeight: 400 }}>${(Number(item.unit_price) * Number(item.quantity || 1)).toFixed(2)}</td>
</tr>
))}
<tr>
<td colSpan={3} style={{ textAlign: 'right', fontWeight: 400, borderTop: '1px solid var(--border)', paddingTop: 10 }}>Total</td>
<td style={{ textAlign: 'right', fontWeight: 400, fontSize: 16, borderTop: '1px solid var(--border)', paddingTop: 10 }}>${total.toFixed(2)}</td>
</tr>
</tbody>
</table>
<SubcontractorInvoiceDetailView
error={error}
headerActions={headerActions}
leftCardTitle="Submitted By"
leftCardBody={(
<>
<div style={{ fontSize: 15, fontWeight: 400 }}>{invoice.profile?.name || 'External'}</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 4 }}>{invoice.profile?.email || '—'}</div>
</>
)}
rightCardTitle="Invoice Details"
rightCardBody={(
<div className="invoice-detail-meta-grid">
<div className="invoice-detail-meta-item"><label>Invoice #</label><p>{invoice.invoice_number}</p></div>
<div className="invoice-detail-meta-item"><label>Status</label><p>{invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'}</p></div>
<div className="invoice-detail-meta-item"><label>Submitted</label><p>{invoice.submitted_at ? new Date(invoice.submitted_at).toLocaleDateString() : '—'}</p></div>
{invoice.paid_at ? <div className="invoice-detail-meta-item"><label>Paid On</label><p style={{ color: 'var(--success, #16a34a)' }}>{new Date(invoice.paid_at).toLocaleDateString()}</p></div> : null}
<div className="invoice-detail-meta-item"><label>Line Items</label><p>{sortedItems.length}</p></div>
<div className="invoice-detail-meta-item"><label>Total</label><p style={{ fontSize: 18, color: 'var(--accent)' }}>${total.toFixed(2)}</p></div>
</div>
)}
{invoice.notes && (
<div style={{ marginTop: 16, padding: '12px 14px', background: 'var(--bg)', borderRadius: 4, border: '1px solid var(--border)' }}>
<div style={{ fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 6 }}>Notes</div>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap', margin: 0 }}>{invoice.notes}</p>
</div>
)}
</div>
<div className="card">
<div className="card-title">Actions</div>
<div className="action-buttons">
{invoice.status === 'submitted' && (
<button className="btn btn-success" onClick={handleMarkPaid} disabled={saving}>
{saving ? 'Processing…' : 'Mark as Paid'}
</button>
)}
{invoice.status === 'paid' && (
<button className="btn btn-outline" onClick={handleReceipt} disabled={generating}>
{generating ? 'Generating…' : 'Download Receipt'}
</button>
)}
<button className="btn-icon btn-icon-danger" title="Delete Invoice" onClick={handleDelete} disabled={saving}>
</button>
</div>
</div>
sortKey={sortKey}
sortDir={sortDir}
onSort={toggle}
items={tableItems}
notes={invoice.notes}
total={total}
/>
</Layout>
);
}
+5 -40
View File
@@ -1,11 +1,11 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import Layout from '../../components/Layout';
import PageLoader from '../../components/PageLoader';
import LoadingButton from '../../components/LoadingButton';
import SortTh from '../../components/SortTh';
import StatusBadge from '../../components/StatusBadge';
import { supabase } from '../../lib/supabase';
import { sendEmail } from '../../lib/email';
import { generateSubcontractorPOPDF } from '../../lib/invoice';
import { useSortable } from '../../hooks/useSortable';
@@ -73,49 +73,14 @@ export default function SubcontractorPODetail() {
return data;
};
const sendPOEmail = async (sentPO) => {
if (!sentPO?.profile?.email) {
alert('This subcontractor has no email on file.');
return;
}
await sendEmail('subcontractor_po_sent', sentPO.profile.email, {
poNumber: sentPO.po_number || 'Purchase Order',
subcontractorName: sentPO.profile?.name || 'there',
projectName: sentPO.project?.name || 'Subcontractor Work',
companyName: sentPO.project?.company?.name || 'Fourge Branding',
amount: `$${Number(sentPO.amount).toFixed(2)}`,
dueDate: sentPO.due_date ? new Date(sentPO.due_date).toLocaleDateString() : 'Not set',
terms: sentPO.terms || 'Net 15',
scope: sentPO.items?.length
? sentPO.items.map(item => `${item.description} - $${Number(item.amount).toFixed(2)}`).join('\n')
: sentPO.description,
portalUrl: 'https://portal.fourgebranding.com/my-purchase-orders',
});
};
const handleFinalizeSend = async () => {
if (!window.confirm(`Finalize and send ${po.po_number || 'this PO'} to ${po.profile?.email || 'the subcontractor'}?`)) return;
const sentPO = await updatePO({ status: 'sent', sent_at: new Date().toISOString() }, 'Failed to finalize PO');
if (!sentPO) return;
try {
await sendPOEmail(sentPO);
alert('PO email sent successfully.');
} catch (error) {
alert(`PO was finalized, but the email failed: ${error.message || 'unknown error'}`);
}
};
const handleResend = async () => {
if (!window.confirm(`Resend ${po.po_number || 'this PO'} to ${po.profile?.email || 'the subcontractor'}?`)) return;
setSaving(true);
try {
await sendPOEmail(po);
alert('PO email sent successfully.');
} catch (error) {
alert(`Failed to send PO: ${error.message || 'unknown error'}`);
} finally {
setSaving(false);
}
alert('PO emails are disabled.');
};
const handleReadyToPay = () => updatePO({ status: 'ready_to_pay' }, 'Failed to mark ready to pay');
@@ -136,7 +101,7 @@ export default function SubcontractorPODetail() {
setSaving(false);
return;
}
navigate('/invoices', { state: { tab: 'subcontractor-po' } });
navigate('/finances', { state: { tab: 'subcontractor-po' } });
};
const handleDownload = async () => {
@@ -149,7 +114,7 @@ export default function SubcontractorPODetail() {
}
};
if (loading) return <Layout><p style={{ padding: 24, color: 'var(--text-muted)' }}>Loading...</p></Layout>;
if (loading) return <Layout><PageLoader /></Layout>;
if (!po) return <Layout><p>Purchase order not found.</p></Layout>;
const sortedItems = (po.items || []).slice().sort((a, b) => Number(a.sort_order || 0) - Number(b.sort_order || 0));
@@ -163,7 +128,7 @@ export default function SubcontractorPODetail() {
return (
<Layout>
<button className="back-link" onClick={() => navigate('/invoices', { state: { tab: 'subcontractor-po' } })}> Back to Subcontractor PO</button>
<button className="back-link" onClick={() => navigate('/finances', { state: { tab: 'subcontractor-po' } })}> Back to Subcontractor PO</button>
<div className="page-header">
<div>