Compare commits

..

3 Commits

Author SHA1 Message Date
Krao Hasanee 1f52b6e96c fix: sub billing only applies to work an external sub delivered
Report was flagging every unbilled delivered version as "Not Invoiced"
for sub billing, even when a team member delivered it — team work is
billed to the client directly and is never sub-invoiced. Now traces
each version's actual deliverer and shows N/A when it's a team member,
regardless of who delivered earlier versions on the same task.

Also: SubcontractorInvoiceForm no longer masks fetch errors as "No
completed tasks available to invoice" — the real error now shows in
the Completed Tasks panel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 20:15:04 -04:00
Krao Hasanee 2c1ff61b29 fix: popups no longer close on backdrop click, only via explicit close/cancel
Clicking the dimmed area outside a modal (+Task, +Invoice, expense,
review, etc.) accidentally dismissed it and lost in-progress input.
Every popup already has an explicit Cancel/Close/✕ button, so the
backdrop click handler is removed; also drops the now-dead
onClose/blockClose prop plumbing that only existed to support it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 16:45:37 -04:00
Krao Hasanee bf70646a54 fix: sub invoice eligibility follows delivery author, not task assignment
Completed Tasks list scoped by task.assigned_to meant a reassigned task
made the original sub's already-delivered versions permanently unbillable
by anyone. Now scoped by deliveries.sent_by directly, so each version
stays billable by whoever actually delivered it, independent of who
currently owns the task.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 16:04:24 -04:00
12 changed files with 84 additions and 74 deletions
+1 -3
View File
@@ -15,8 +15,6 @@ export default function InvoiceDetailPopup({
title, title,
subtitle, subtitle,
headerRight, headerRight,
onClose,
blockClose = false,
metaContent, // JSX fields rendered in flat meta grid (no cards) metaContent, // JSX fields rendered in flat meta grid (no cards)
metaActions, // optional JSX rendered right-aligned beside meta grid (e.g. Edit Dates) metaActions, // optional JSX rendered right-aligned beside meta grid (e.g. Edit Dates)
metaCols = 4, // number of grid columns for meta strip metaCols = 4, // number of grid columns for meta strip
@@ -25,7 +23,7 @@ export default function InvoiceDetailPopup({
children, children,
}) { }) {
return ( return (
<div style={popupOverlayStyle} onClick={() => { if (!blockClose) onClose?.(); }}> <div style={popupOverlayStyle}>
<div <div
style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }}
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
+36 -45
View File
@@ -5,10 +5,12 @@ import { supabase } from '../lib/supabase';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { sendEmail } from '../lib/email'; import { sendEmail } from '../lib/email';
import { isCompletedVersionEligible } from '../lib/invoiceVersionRules'; import { isCompletedVersionEligible } from '../lib/invoiceVersionRules';
import { isReviewShadowDescription } from '../lib/taskVersions';
import { useActionLock } from '../hooks/useActionLock'; import { useActionLock } from '../hooks/useActionLock';
const INVOICE_TODAY = new Date().toISOString().split('T')[0]; const INVOICE_TODAY = new Date().toISOString().split('T')[0];
const REVISION_RATE = 30; const REVISION_RATE = 30;
const ELIGIBLE_TASK_STATUSES = new Set(['not_started', 'in_progress', 'on_hold', 'client_review', 'client_approved', 'invoiced', 'paid']);
const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 4 }; 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 FIELD_INPUT_STYLE = { minHeight: 42, margin: 0 };
const FINANCE_MODAL_INPUT_STYLE = { const FINANCE_MODAL_INPUT_STYLE = {
@@ -113,20 +115,20 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
setLoadingTasks(true); setLoadingTasks(true);
setError(''); setError('');
try { try {
const [{ data: tasks, error: tasksError }, { data: existingInvoices, error: invoicesError }, { data: nextNum, error: nextNumError }] = await Promise.all([ const [{ data: deliveryRows, error: deliveriesError }, { data: existingInvoices, error: invoicesError }, { data: nextNum, error: nextNumError }] = await Promise.all([
// Scoped by who actually delivered each version, not by current task assignment —
// a task can be reassigned mid-flight and the original sub still needs to bill
// for the version(s) they personally delivered.
supabase supabase
.from('tasks') .from('deliveries')
.select('id, title, status, current_version, project:projects(name)') .select('version_number, sent_by, submission:submissions!inner(task_id, description, task:tasks!inner(id, title, status, current_version, project:projects(name)))'),
.in('status', ['not_started', 'in_progress', 'on_hold', 'client_review', 'client_approved', 'invoiced', 'paid'])
.eq('assigned_to', currentUser.id)
.order('title'),
supabase supabase
.from('subcontractor_invoices') .from('subcontractor_invoices')
.select('id, status, items:subcontractor_invoice_items(task_id, version_number, description)') .select('id, status, items:subcontractor_invoice_items(task_id, version_number, description)')
.in('status', ['submitted', 'paid']), .in('status', ['submitted', 'paid']),
supabase.rpc('get_next_sub_invoice_number'), supabase.rpc('get_next_sub_invoice_number'),
]); ]);
if (tasksError) throw tasksError; if (deliveriesError) throw deliveriesError;
if (invoicesError) throw invoicesError; if (invoicesError) throw invoicesError;
if (nextNumError) throw nextNumError; if (nextNumError) throw nextNumError;
@@ -141,46 +143,31 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
}).filter(Boolean)) }).filter(Boolean))
); );
const taskIds = (tasks || []).map((task) => task.id).filter(Boolean); const myName = (currentUser.name || '').trim().toLowerCase();
const { data: submissionRows, error: submissionsError } = taskIds.length > 0 const unitsByKey = new Map();
? await supabase for (const delivery of (deliveryRows || [])) {
.from('submissions') if ((delivery.sent_by || '').trim().toLowerCase() !== myName) continue;
.select('task_id, deliveries(version_number, sent_by, sent_at)') const submission = delivery.submission;
.in('task_id', taskIds) const task = submission?.task;
: { data: [], error: null }; if (!task || !ELIGIBLE_TASK_STATUSES.has(task.status)) continue;
if (submissionsError) throw submissionsError; if (isReviewShadowDescription(submission.description)) continue;
const version = Number(delivery.version_number || 0);
const deliveriesByTask = new Map(); if (!isCompletedVersionEligible(task, version)) continue;
for (const row of (submissionRows || [])) { const key = `${task.id}:${version}`;
const taskId = row.task_id; if (invoicedUnitKeys.has(key) || unitsByKey.has(key)) continue;
if (!taskId) continue; unitsByKey.set(key, {
const bucket = deliveriesByTask.get(taskId) || new Map(); key,
for (const delivery of asArray(row.deliveries)) { task_id: task.id,
if ((delivery.sent_by || '').trim().toLowerCase() !== (currentUser.name || '').trim().toLowerCase()) continue; version_number: version,
const version = Number(delivery.version_number || 0); is_revision: version > 0,
if (!bucket.has(version)) bucket.set(version, delivery); title: task.title,
} project_name: task.project?.name || '',
deliveriesByTask.set(taskId, bucket); description: `${task.project?.name ? `${task.project.name}` : ''}${task.title} ${versionLabel(version)}`,
rate: subcontractorVersionRate(version, rate),
});
} }
const units = (tasks || []).flatMap((task) => { const units = [...unitsByKey.values()].sort((a, b) => a.title.localeCompare(b.title) || a.version_number - b.version_number);
const versions = [...(deliveriesByTask.get(task.id)?.keys() || [])].sort((a, b) => a - b);
return versions
.filter((version) => isCompletedVersionEligible(task, version))
.map((version) => {
const key = `${task.id}:${version}`;
return {
key,
task_id: task.id,
version_number: version,
is_revision: version > 0,
title: task.title,
project_name: task.project?.name || '',
description: `${task.project?.name ? `${task.project.name}` : ''}${task.title} ${versionLabel(version)}`,
rate: subcontractorVersionRate(version, rate),
};
});
}).filter((unit) => !invoicedUnitKeys.has(unit.key));
setCompletedTasks(units); setCompletedTasks(units);
} catch (err) { } catch (err) {
@@ -360,6 +347,8 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
</div> </div>
{loadingTasks ? ( {loadingTasks ? (
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>Loading</div> <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>Loading</div>
) : error ? (
<div style={{ fontSize: 12, color: 'var(--danger)' }}>{error}</div>
) : completedTasks.length === 0 ? ( ) : completedTasks.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>No completed tasks available to invoice.</div> <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>No completed tasks available to invoice.</div>
) : ( ) : (
@@ -455,6 +444,8 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
</div> </div>
{loadingTasks ? ( {loadingTasks ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading...</p> <p style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading...</p>
) : error ? (
<p style={{ fontSize: 13, color: 'var(--danger)' }}>{error}</p>
) : completedTasks.length === 0 ? ( ) : completedTasks.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No completed tasks available to invoice.</p> <p style={{ fontSize: 13, color: 'var(--text-muted)' }}>No completed tasks available to invoice.</p>
) : ( ) : (
-2
View File
@@ -2353,7 +2353,6 @@ function DimensionEditorModal({ sourceImage, onApply, onCancel }) {
return ( return (
<div <div
style={{ ...popupOverlayStyle, zIndex: 9999, padding: 20 }} style={{ ...popupOverlayStyle, zIndex: 9999, padding: 20 }}
onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
> >
<div style={{ ...popupSurfaceStyle, borderRadius: 8, display: 'flex', flexDirection: 'column', maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden', padding: 0 }}> <div style={{ ...popupSurfaceStyle, borderRadius: 8, display: 'flex', flexDirection: 'column', maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden', padding: 0 }}>
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}> <div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
@@ -3436,7 +3435,6 @@ function PhotoEditorModal({
return ( return (
<div <div
style={{ ...popupOverlayStyle, zIndex: 9999, padding: 20 }} style={{ ...popupOverlayStyle, zIndex: 9999, padding: 20 }}
onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
> >
<div style={{ ...popupSurfaceStyle, borderRadius: 8, display: 'flex', flexDirection: 'column', maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden', padding: 0 }}> <div style={{ ...popupSurfaceStyle, borderRadius: 8, display: 'flex', flexDirection: 'column', maxWidth: '98vw', maxHeight: '96vh', overflow: 'hidden', padding: 0 }}>
{/* Header */} {/* Header */}
-1
View File
@@ -642,7 +642,6 @@ export default function ProfilePage() {
{isSelfView && editOpen && ( {isSelfView && editOpen && (
<div <div
style={popupOverlayStyle} style={popupOverlayStyle}
onClick={() => { if (!savingProfile) setEditOpen(false); }}
> >
<div <div
style={{ style={{
+2 -2
View File
@@ -596,7 +596,7 @@ export default function ProjectDetailPage() {
</div> </div>
{showClientTask && isClient && ( {showClientTask && isClient && (
<div style={popupOverlayStyle} onClick={() => { setShowClientTask(false); setClientTaskKey(k => k + 1); setClientTaskError(''); }}> <div style={popupOverlayStyle}>
<div style={TASK_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}> <div style={TASK_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}>
<div style={{ ...TASK_MODAL_TITLE_STYLE, marginBottom: 14 }}>New Task</div> <div style={{ ...TASK_MODAL_TITLE_STYLE, marginBottom: 14 }}>New Task</div>
<RequestForm <RequestForm
@@ -618,7 +618,7 @@ export default function ProjectDetailPage() {
)} )}
{addingMember && isTeam && ( {addingMember && isTeam && (
<div style={popupOverlayStyle} onClick={() => { setAddingMember(false); setSelectedExts(new Set()); }}> <div style={popupOverlayStyle}>
<div style={{ ...TASK_MODAL_SHELL_STYLE, width: 'min(480px, 96vw)', display: 'flex', flexDirection: 'column', gap: 16, padding: 0 }} onClick={e => e.stopPropagation()}> <div style={{ ...TASK_MODAL_SHELL_STYLE, width: 'min(480px, 96vw)', display: 'flex', flexDirection: 'column', gap: 16, padding: 0 }} onClick={e => e.stopPropagation()}>
<div style={{ padding: '18px 21px 0' }}> <div style={{ padding: '18px 21px 0' }}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 2 }}>Manage Subcontractors</div> <div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', marginBottom: 2 }}>Manage Subcontractors</div>
+4 -4
View File
@@ -1035,7 +1035,7 @@ export default function TaskDetail() {
</div> </div>
{reviewModal && ( {reviewModal && (
<div style={popupOverlayStyle} onClick={() => { if (!reviewSaving) { setReviewModal(false); setReviewFiles([]); setReviewNotes(''); } }}> <div style={popupOverlayStyle}>
<div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 14 }} onClick={e => e.stopPropagation()}> <div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 14 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Place in Review</div> <div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>Place in Review</div>
<div> <div>
@@ -1054,7 +1054,7 @@ export default function TaskDetail() {
)} )}
{revisionModal && ( {revisionModal && (
<div style={{ position: 'fixed', inset: 0, background: 'var(--overlay-scrim, rgba(0,0,0,0.58))', zIndex: 1200, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }} onClick={() => { if (!revisionSaving) { setRevisionModal(false); setRevisionForm({ description: '', deadline: '' }); setRevisionFiles([]); } }}> <div style={{ position: 'fixed', inset: 0, background: 'var(--overlay-scrim, rgba(0,0,0,0.58))', zIndex: 1200, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
<div className="card" style={{ ...CARD, width: '100%', maxWidth: 480, display: 'flex', flexDirection: 'column', gap: 18 }} onClick={e => e.stopPropagation()}> <div className="card" style={{ ...CARD, width: '100%', maxWidth: 480, display: 'flex', flexDirection: 'column', gap: 18 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 18, fontWeight: 500, color: 'var(--text-primary)', letterSpacing: 0.2, lineHeight: 1.1 }}>Request Revision</div> <div style={{ fontSize: 18, fontWeight: 500, color: 'var(--text-primary)', letterSpacing: 0.2, lineHeight: 1.1 }}>Request Revision</div>
<div className="form-group"> <div className="form-group">
@@ -1090,7 +1090,7 @@ export default function TaskDetail() {
)} )}
{rejectModal && ( {rejectModal && (
<div style={popupOverlayStyle} onClick={() => { if (!rejectSaving) { setRejectModal(false); setRejectNote(''); } }}> <div style={popupOverlayStyle}>
<div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}> <div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', lineHeight: 1.1 }}>Reject Task</div> <div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', lineHeight: 1.1 }}>Reject Task</div>
<div> <div>
@@ -1114,7 +1114,7 @@ export default function TaskDetail() {
)} )}
{amendModal && ( {amendModal && (
<div style={popupOverlayStyle} onClick={() => { setAmendModal(false); setAmendForm({ description: '' }); setAmendFiles([]); }}> <div style={popupOverlayStyle}>
<div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}> <div style={{ ...popupSurfaceStyle, width: 'min(480px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', lineHeight: 1.1 }}>Amend Request</div> <div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', lineHeight: 1.1 }}>Amend Request</div>
<div> <div>
+2 -2
View File
@@ -653,7 +653,7 @@ export default function RequestsPage() {
> >
{/* Add project modal */} {/* Add project modal */}
{showAddProjectForm && ( {showAddProjectForm && (
<div style={popupOverlayStyle} onClick={() => { setShowAddProjectForm(false); setAddProjectForm({ name: '', companyId: '' }); setAddProjectError(''); }}> <div style={popupOverlayStyle}>
<div style={PROJECT_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}> <div style={PROJECT_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 14 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 14 }}>
<div style={TASK_MODAL_TITLE_STYLE}>Add Project</div> <div style={TASK_MODAL_TITLE_STYLE}>Add Project</div>
@@ -682,7 +682,7 @@ export default function RequestsPage() {
{/* Add task modal */} {/* Add task modal */}
{showAddForm && ( {showAddForm && (
<div style={popupOverlayStyle} onClick={() => { setShowAddForm(false); setAddFormKey(k => k + 1); setAddError(''); }}> <div style={popupOverlayStyle}>
<div style={TASK_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}> <div style={TASK_MODAL_SHELL_STYLE} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 14 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 14 }}>
<div style={TASK_MODAL_TITLE_STYLE}>{isTeam ? 'Add Task' : 'New Task'}</div> <div style={TASK_MODAL_TITLE_STYLE}>{isTeam ? 'Add Task' : 'New Task'}</div>
-2
View File
@@ -103,8 +103,6 @@ function ClientInvoiceModal({ invoice, onClose }) {
title={invoice.invoice_number} title={invoice.invoice_number}
subtitle={invoice.bill_to || company?.name} subtitle={invoice.bill_to || company?.name}
headerRight={<StatusBadge status={statusColor[invoice.status] || 'not_started'} label={`${invoiceStatusLabel(invoice.status)}${isOverdue ? ' · Overdue' : ''}`} />} headerRight={<StatusBadge status={statusColor[invoice.status] || 'not_started'} label={`${invoiceStatusLabel(invoice.status)}${isOverdue ? ' · Overdue' : ''}`} />}
onClose={onClose}
blockClose={Boolean(downloading)}
metaContent={<> metaContent={<>
<div><div style={F}>Invoice Date</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.invoice_date ? new Date(invoice.invoice_date).toLocaleDateString() : '—'}</div></div> <div><div style={F}>Invoice Date</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{invoice.invoice_date ? new Date(invoice.invoice_date).toLocaleDateString() : '—'}</div></div>
<div><div style={F}>Due Date</div><div style={{ fontSize: 13, color: isOverdue ? 'var(--danger)' : 'var(--text-primary)' }}>{invoice.due_date ? new Date(invoice.due_date).toLocaleDateString() : '—'}</div></div> <div><div style={F}>Due Date</div><div style={{ fontSize: 13, color: isOverdue ? 'var(--danger)' : 'var(--text-primary)' }}>{invoice.due_date ? new Date(invoice.due_date).toLocaleDateString() : '—'}</div></div>
+1 -3
View File
@@ -428,7 +428,7 @@ export default function MyInvoices() {
</div> </div>
{showInvoiceForm && ( {showInvoiceForm && (
<div style={popupOverlayStyle} onClick={() => setShowInvoiceForm(false)}> <div style={popupOverlayStyle}>
<div <div
style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@@ -469,8 +469,6 @@ export default function MyInvoices() {
title={inv?.invoice_number || 'Subcontractor Invoice'} title={inv?.invoice_number || 'Subcontractor Invoice'}
subtitle={`${currentUser?.name || 'Subcontractor'}${currentUser?.email ? ` · ${currentUser.email}` : ''}`} subtitle={`${currentUser?.name || 'Subcontractor'}${currentUser?.email ? ` · ${currentUser.email}` : ''}`}
headerRight={inv ? <StatusBadge status={STATUS_BADGE[inv.status] || 'not_started'} label={invoiceStatus} /> : null} headerRight={inv ? <StatusBadge status={STATUS_BADGE[inv.status] || 'not_started'} label={invoiceStatus} /> : null}
onClose={() => { if (!busy) { setViewingInvoice(null); setDetailError(''); } }}
blockClose={busy}
loading={detailLoading && !inv} loading={detailLoading && !inv}
metaContent={inv ? <> metaContent={inv ? <>
<div><div style={F}>Created</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{inv.created_at ? new Date(inv.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div></div> <div><div style={F}>Created</div><div style={{ fontSize: 13, color: 'var(--text-primary)' }}>{inv.created_at ? new Date(inv.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</div></div>
-2
View File
@@ -403,8 +403,6 @@ export function TeamInvoiceDetailPanel({
label={`${invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'}${isOverdue ? ' · Overdue' : ''}`} label={`${invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'}${isOverdue ? ' · Overdue' : ''}`}
/> />
) : null} ) : null}
onClose={onClose}
blockClose={saving || Boolean(generating)}
loading={loading} loading={loading}
metaContent={invoice ? <> metaContent={invoice ? <>
<div> <div>
+3 -5
View File
@@ -1761,8 +1761,6 @@ export default function Invoices() {
title={invoice.invoice_number || 'Subcontractor Invoice'} title={invoice.invoice_number || 'Subcontractor Invoice'}
subtitle={`${invoice.profile?.name || 'External'}${invoice.profile?.email ? ` · ${invoice.profile.email}` : ''}`} subtitle={`${invoice.profile?.name || 'External'}${invoice.profile?.email ? ` · ${invoice.profile.email}` : ''}`}
headerRight={<StatusBadge status={subInvoiceStatusColor[invoice.status] || 'not_started'} label={invoiceStatus} />} headerRight={<StatusBadge status={subInvoiceStatusColor[invoice.status] || 'not_started'} label={invoiceStatus} />}
onClose={() => { if (!markingPaid) setViewingSubInvoice(null); }}
blockClose={Boolean(markingPaid)}
metaContent={<> metaContent={<>
<div><div style={F}>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={F}>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={F}>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={F}>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>
@@ -1819,7 +1817,7 @@ export default function Invoices() {
newExpense.removeReceipt === true newExpense.removeReceipt === true
); );
return ( return (
<div style={popupOverlayStyle} onClick={() => { setViewingExpense(null); setExpenseDetailEditing(false); }}> <div style={popupOverlayStyle}>
<div style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}> <div style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
{/* Main content row */} {/* Main content row */}
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0 }}> <div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0 }}>
@@ -1914,7 +1912,7 @@ export default function Invoices() {
const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 }; const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 };
const INPUT = FINANCE_MODAL_INPUT_STYLE; const INPUT = FINANCE_MODAL_INPUT_STYLE;
return ( return (
<div style={popupOverlayStyle} onClick={cancelExpenseEdit}> <div style={popupOverlayStyle}>
<div style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}> <div style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
<form onSubmit={handleAddExpense} style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, gap: 0 }}> <form onSubmit={handleAddExpense} style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, gap: 0 }}>
{/* Main content row */} {/* Main content row */}
@@ -1970,7 +1968,7 @@ export default function Invoices() {
const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 }; const LABEL = { ...FIELD_LABEL_STYLE, marginBottom: 3 };
const INPUT = FINANCE_MODAL_INPUT_STYLE; const INPUT = FINANCE_MODAL_INPUT_STYLE;
return ( return (
<div style={popupOverlayStyle} onClick={() => { if (!invSaving) invClose(); }}> <div style={popupOverlayStyle}>
<div style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}> <div style={{ ...popupSurfaceStyle, width: '80vw', height: '80vh', display: 'flex', flexDirection: 'column', gap: 0 }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0 }}> <div style={{ display: 'flex', gap: 24, alignItems: 'stretch', flex: 1, minHeight: 0 }}>
+35 -3
View File
@@ -34,9 +34,16 @@ function subBillingStatusFromInvoices(items = []) {
function billingLabel(status) { function billingLabel(status) {
if (status === 'paid') return 'Paid'; if (status === 'paid') return 'Paid';
if (status === 'invoiced') return 'Invoiced'; if (status === 'invoiced') return 'Invoiced';
if (status === 'not_applicable') return 'N/A';
return 'Not Invoiced'; return 'Not Invoiced';
} }
function asArray(value) {
if (Array.isArray(value)) return value;
if (value == null) return [];
return typeof value === 'object' ? [value] : [];
}
function versionTypeLabel(versionNumber) { function versionTypeLabel(versionNumber) {
return Number(versionNumber || 0) === 0 ? 'New' : 'Revision'; return Number(versionNumber || 0) === 0 ? 'New' : 'Revision';
} }
@@ -85,6 +92,7 @@ export default function TeamReports() {
{ data: submissionRows, error: submissionsError }, { data: submissionRows, error: submissionsError },
{ data: invoiceItemRows, error: invoiceItemsError }, { data: invoiceItemRows, error: invoiceItemsError },
{ data: subInvoiceRows, error: subInvoicesError }, { data: subInvoiceRows, error: subInvoicesError },
{ data: profileRows, error: profilesError },
] = await Promise.all([ ] = await Promise.all([
supabase supabase
.from('tasks') .from('tasks')
@@ -92,7 +100,7 @@ export default function TeamReports() {
.order('title'), .order('title'),
supabase supabase
.from('submissions') .from('submissions')
.select('id, task_id, type, version_number, submitted_at, invoiced, description') .select('id, task_id, type, version_number, submitted_at, invoiced, description, delivery:deliveries(sent_by)')
.in('type', ['initial', 'revision']) .in('type', ['initial', 'revision'])
.order('submitted_at', { ascending: true }), .order('submitted_at', { ascending: true }),
supabase supabase
@@ -101,15 +109,28 @@ export default function TeamReports() {
supabase supabase
.from('subcontractor_invoices') .from('subcontractor_invoices')
.select('status, items:subcontractor_invoice_items(task_id, version_number, description)') .select('status, items:subcontractor_invoice_items(task_id, version_number, description)')
.in('status', ['submitted', 'approved', 'paid']) .in('status', ['submitted', 'approved', 'paid']),
supabase
.from('profiles')
.select('name, role'),
]); ]);
if (tasksError) throw tasksError; if (tasksError) throw tasksError;
if (submissionsError) throw submissionsError; if (submissionsError) throw submissionsError;
if (invoiceItemsError) throw invoiceItemsError; if (invoiceItemsError) throw invoiceItemsError;
if (subInvoicesError) throw subInvoicesError; if (subInvoicesError) throw subInvoicesError;
if (profilesError) throw profilesError;
if (cancelled) return; if (cancelled) return;
// Sub billing only applies to work an external subcontractor delivered.
// A team member's own delivery is billed to the client directly and is
// never sub-invoiced, no matter who started the task's earlier versions.
const roleByName = new Map(
(profileRows || [])
.filter((profile) => profile.name)
.map((profile) => [profile.name.trim().toLowerCase(), profile.role])
);
const taskMap = new Map((taskRows || []).map((task) => [task.id, task])); const taskMap = new Map((taskRows || []).map((task) => [task.id, task]));
const companiesMap = new Map(); const companiesMap = new Map();
const projectsMap = new Map(); const projectsMap = new Map();
@@ -137,6 +158,12 @@ export default function TeamReports() {
if (!entry.first_submitted_at || (submission.submitted_at && submission.submitted_at < entry.first_submitted_at)) { if (!entry.first_submitted_at || (submission.submitted_at && submission.submitted_at < entry.first_submitted_at)) {
entry.first_submitted_at = submission.submitted_at; entry.first_submitted_at = submission.submitted_at;
} }
// Duplicate submissions for the same task+version can exist (double-submits);
// take the deliverer from whichever one actually has a delivery logged.
if (!entry.delivered_by) {
const deliverer = asArray(submission.delivery)[0]?.sent_by;
if (deliverer) entry.delivered_by = deliverer;
}
} }
const invoiceItemGroups = new Map(); const invoiceItemGroups = new Map();
@@ -190,7 +217,12 @@ export default function TeamReports() {
const key = `${task.id}:${Number(versionEntry.version_number || 0)}`; const key = `${task.id}:${Number(versionEntry.version_number || 0)}`;
const invoiceItems = invoiceItemGroups.get(key) || []; const invoiceItems = invoiceItemGroups.get(key) || [];
const billingStatus = billingStatusFromInvoices(invoiceItems); const billingStatus = billingStatusFromInvoices(invoiceItems);
const subBillingStatus = subBillingStatusFromInvoices(subBillingGroups.get(key) || []); const delivererRole = versionEntry.delivered_by
? roleByName.get(versionEntry.delivered_by.trim().toLowerCase()) || null
: null;
const subBillingStatus = delivererRole === 'external'
? subBillingStatusFromInvoices(subBillingGroups.get(key) || [])
: 'not_applicable';
rows.push({ rows.push({
key, key,
company_id: company?.id || '', company_id: company?.id || '',