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