feat: per-version R## tracking and billing separation

Each revision (R00, R01, R02) is now an independent trackable unit:

invoiceVersionRules: add deriveVersionStatus, buildInvoiceStatusByKey,
  parseVersionFromItemDescription for per-version status derivation

Tasks.jsx + ProjectDetail.jsx:
  - Fetch invoice_items joined with invoice.status (team only)
  - allRows/rows now flatMap per version (one row per R##)
  - Status derived from: invoice_items > past version > task.status
  - R00 paid shows as paid; R01 in_progress shows separately
  - tr keys use rowKey (taskId:version) to avoid duplicate key warnings

Invoice lifecycle (TeamInvoiceDetail, TeamInvoices, TeamCreateInvoice):
  - Remove task.status sync on invoice paid/sent/created
  - Keep invoiced:true flag for double-billing prevention
  - Task work status (not_started → client_approved) stays work-flow only

ProjectDetail completion bar uses task.status === client_approved only
  (no longer polluted by invoiced/paid status from invoice sync)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Krao Hasanee
2026-06-09 22:36:36 -05:00
parent da0e5d5c39
commit 5b2d5b65a4
6 changed files with 97 additions and 28 deletions
+1 -1
View File
@@ -235,7 +235,7 @@ export default function CreateInvoice() {
const taskIds = [...new Set(validItems.filter(i => i.task_id && !i.submission_id).map(i => i.task_id))];
if (taskIds.length > 0) {
const { error: taskError } = await supabase.from('tasks').update({ invoiced: true, status: 'invoiced' }).in('id', taskIds).eq('status', 'client_approved');
const { error: taskError } = await supabase.from('tasks').update({ invoiced: true }).in('id', taskIds);
if (taskError) throw taskError;
}
+2 -7
View File
@@ -81,13 +81,8 @@ export function TeamInvoiceDetailPanel({
// Sync task statuses along invoice lifecycle
const { data: freshItems } = await supabase.from('invoice_items').select('task_id, submission_id').eq('invoice_id', invoiceId);
const taskIds = (freshItems || []).filter(i => i.task_id && !i.submission_id).map(i => i.task_id);
if (taskIds.length > 0) {
const newTaskStatus = status === 'paid' ? 'paid' : status === 'sent' ? 'invoiced' : 'client_approved';
// Guard: only update tasks still at the expected prior status so tasks
// that have moved on to a new revision cycle aren't overwritten.
const guardStatus = status === 'paid' ? 'invoiced' : status === 'sent' ? 'client_approved' : 'paid';
await supabase.from('tasks').update({ status: newTaskStatus }).in('id', taskIds).eq('status', guardStatus);
}
// Task work status is not synced to invoice lifecycle.
// Per-version status is derived from invoice_items at display time.
if (status === 'paid') {
try {
const contactEmail = invoice.invoice_email || await getDefaultInvoiceEmail(invoice.company_id, company);
+1 -1
View File
@@ -410,7 +410,7 @@ export default function Invoices() {
const validItems = invItems.filter(i => i.description);
if (validItems.length > 0) await supabase.from('invoice_items').insert(validItems.map(it => ({ invoice_id: invoice.id, task_id: it.task_id || null, submission_id: it.submission_id || null, description: it.description, quantity: Number(it.quantity) || 1, unit_price: Number(it.unit_price) || 0 })));
const taskIds = [...new Set(validItems.filter(i => i.task_id && !i.submission_id).map(i => i.task_id))];
if (taskIds.length > 0) await supabase.from('tasks').update({ invoiced: true, status: 'invoiced' }).in('id', taskIds).eq('status', 'client_approved');
if (taskIds.length > 0) await supabase.from('tasks').update({ invoiced: true }).in('id', taskIds);
const subIds = [...new Set(validItems.filter(i => i.submission_id).map(i => i.submission_id))];
if (subIds.length > 0) await supabase.from('submissions').update({ invoiced: true }).in('id', subIds);
if (status === 'sent') {