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>
This commit is contained in:
Krao Hasanee
2026-07-15 16:04:24 -04:00
parent 0d6ac3666e
commit bf70646a54
+23 -36
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,35 +143,19 @@ 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 deliveriesByTask = new Map();
for (const row of (submissionRows || [])) {
const taskId = row.task_id;
if (!taskId) continue;
const bucket = deliveriesByTask.get(taskId) || new Map();
for (const delivery of asArray(row.deliveries)) {
if ((delivery.sent_by || '').trim().toLowerCase() !== (currentUser.name || '').trim().toLowerCase()) continue;
const version = Number(delivery.version_number || 0); const version = Number(delivery.version_number || 0);
if (!bucket.has(version)) bucket.set(version, delivery); if (!isCompletedVersionEligible(task, version)) continue;
}
deliveriesByTask.set(taskId, bucket);
}
const units = (tasks || []).flatMap((task) => {
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}`; const key = `${task.id}:${version}`;
return { if (invoicedUnitKeys.has(key) || unitsByKey.has(key)) continue;
unitsByKey.set(key, {
key, key,
task_id: task.id, task_id: task.id,
version_number: version, version_number: version,
@@ -178,9 +164,10 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
project_name: task.project?.name || '', project_name: task.project?.name || '',
description: `${task.project?.name ? `${task.project.name}` : ''}${task.title} ${versionLabel(version)}`, description: `${task.project?.name ? `${task.project.name}` : ''}${task.title} ${versionLabel(version)}`,
rate: subcontractorVersionRate(version, rate), rate: subcontractorVersionRate(version, rate),
};
}); });
}).filter((unit) => !invoicedUnitKeys.has(unit.key)); }
const units = [...unitsByKey.values()].sort((a, b) => a.title.localeCompare(b.title) || a.version_number - b.version_number);
setCompletedTasks(units); setCompletedTasks(units);
} catch (err) { } catch (err) {