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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user