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 { sendEmail } from '../lib/email';
import { isCompletedVersionEligible } from '../lib/invoiceVersionRules';
import { isReviewShadowDescription } from '../lib/taskVersions';
import { useActionLock } from '../hooks/useActionLock';
const INVOICE_TODAY = new Date().toISOString().split('T')[0];
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_INPUT_STYLE = { minHeight: 42, margin: 0 };
const FINANCE_MODAL_INPUT_STYLE = {
@@ -113,20 +115,20 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
setLoadingTasks(true);
setError('');
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
.from('tasks')
.select('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'),
.from('deliveries')
.select('version_number, sent_by, submission:submissions!inner(task_id, description, task:tasks!inner(id, title, status, current_version, project:projects(name)))'),
supabase
.from('subcontractor_invoices')
.select('id, status, items:subcontractor_invoice_items(task_id, version_number, description)')
.in('status', ['submitted', 'paid']),
supabase.rpc('get_next_sub_invoice_number'),
]);
if (tasksError) throw tasksError;
if (deliveriesError) throw deliveriesError;
if (invoicesError) throw invoicesError;
if (nextNumError) throw nextNumError;
@@ -141,35 +143,19 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
}).filter(Boolean))
);
const taskIds = (tasks || []).map((task) => task.id).filter(Boolean);
const { data: submissionRows, error: submissionsError } = taskIds.length > 0
? await supabase
.from('submissions')
.select('task_id, deliveries(version_number, sent_by, sent_at)')
.in('task_id', taskIds)
: { data: [], error: null };
if (submissionsError) throw submissionsError;
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 myName = (currentUser.name || '').trim().toLowerCase();
const unitsByKey = new Map();
for (const delivery of (deliveryRows || [])) {
if ((delivery.sent_by || '').trim().toLowerCase() !== myName) continue;
const submission = delivery.submission;
const task = submission?.task;
if (!task || !ELIGIBLE_TASK_STATUSES.has(task.status)) continue;
if (isReviewShadowDescription(submission.description)) continue;
const version = Number(delivery.version_number || 0);
if (!bucket.has(version)) bucket.set(version, delivery);
}
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) => {
if (!isCompletedVersionEligible(task, version)) continue;
const key = `${task.id}:${version}`;
return {
if (invoicedUnitKeys.has(key) || unitsByKey.has(key)) continue;
unitsByKey.set(key, {
key,
task_id: task.id,
version_number: version,
@@ -178,9 +164,10 @@ export default function SubcontractorInvoiceForm({ embedded = false, onCancel, o
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));
}
const units = [...unitsByKey.values()].sort((a, b) => a.title.localeCompare(b.title) || a.version_number - b.version_number);
setCompletedTasks(units);
} catch (err) {