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:
@@ -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,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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user