From 1784af909d0d1d506e50f01f1f39d1ad93c48839 Mon Sep 17 00:00:00 2001 From: Krao Hasanee Date: Thu, 11 Jun 2026 22:29:42 -0400 Subject: [PATCH] fix: invoice picker skips review-shadow rows for service_type/pricing Review-shadow submissions are type=initial with blank service_type; the picker grabbed the first initial found, hitting a shadow -> null service -> $0 price. New pickInitialServiceType() skips shadows and prefers a real service_type. Fixes Harlingen sites showing $0 on invoice. Co-Authored-By: Claude Opus 4.8 --- .claude/settings.local.json | 6 +++++- src/lib/taskVersions.js | 13 +++++++++++++ src/pages/team/TeamCreateInvoice.jsx | 8 +++----- src/pages/team/TeamInvoices.jsx | 11 ++++------- 4 files changed, 25 insertions(+), 13 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 55581b9..8ddeafe 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -29,7 +29,11 @@ "Bash(vercel ls *)", "Bash(vercel --prod)", "Bash(git commit -q -m 'feat: add Sub Billing column to billing report \\(sub invoiced/paid per version\\) *)", - "Bash(git commit -q -m 'fix: report Version Status collapses Approved into Completed *)" + "Bash(git commit -q -m 'fix: report Version Status collapses Approved into Completed *)", + "mcp__plugin_supabase_supabase__list_projects", + "Bash(grep -rni \"task_number\\\\|task #\\\\|task#\\\\|#\\\\${\\\\|taskNumber\\\\|seq\" src/pages/Tasks.jsx src/pages/TaskDetail.jsx src/pages/ProjectDetail.jsx)", + "mcp__plugin_supabase_supabase__apply_migration", + "Bash(git commit -q -m 'fix: invoice picker skips review-shadow rows for service_type/pricing *)" ] } } diff --git a/src/lib/taskVersions.js b/src/lib/taskVersions.js index e78e223..8946ef8 100644 --- a/src/lib/taskVersions.js +++ b/src/lib/taskVersions.js @@ -35,6 +35,19 @@ export function getVisibleTaskSubmissions(submissions = []) { return (submissions || []).filter(submission => !isReviewShadowSubmission(submission)); } +// Picks the representative service_type for a task's initial work for pricing. +// Skips review-shadow rows and blank service_types (those break price lookups), +// preferring a real initial submission, then any submission with a service_type. +export function pickInitialServiceType(submissions = [], fallback = '') { + const visible = getVisibleTaskSubmissions(submissions); + const initialWithSvc = visible.find(s => s?.type === 'initial' && s?.service_type); + if (initialWithSvc?.service_type) return initialWithSvc.service_type; + const anyInitial = visible.find(s => s?.type === 'initial'); + if (anyInitial?.service_type) return anyInitial.service_type; + const anyWithSvc = visible.find(s => s?.service_type); + return anyWithSvc?.service_type || fallback; +} + export function getLatestVisibleSubmissionForVersion(taskId, submissions = [], versionNumber = 0) { return getVisibleTaskSubmissions(submissions) .filter(submission => submission?.task_id === taskId && (submission?.version_number || 0) === versionNumber) diff --git a/src/pages/team/TeamCreateInvoice.jsx b/src/pages/team/TeamCreateInvoice.jsx index 6a8f97a..328b117 100644 --- a/src/pages/team/TeamCreateInvoice.jsx +++ b/src/pages/team/TeamCreateInvoice.jsx @@ -7,7 +7,7 @@ import { useAuth } from '../../context/AuthContext'; import { generateInvoicePDF } from '../../lib/invoice'; import { blobToEmailAttachment, sendEmail } from '../../lib/email'; import { withTimeout } from '../../lib/withTimeout'; -import { isReviewShadowDescription } from '../../lib/taskVersions'; +import { isReviewShadowDescription, pickInitialServiceType } from '../../lib/taskVersions'; import { getRevisionChargeQuantity, isCompletedVersionEligible, isInitialVersionEligible } from '../../lib/invoiceVersionRules'; // Computed at module load time — stable for the lifetime of the invoice creation session @@ -108,8 +108,7 @@ export default function CreateInvoice() { .order('submitted_at', { ascending: false }) : { data: [] }; const tasksWithService = (companyTasks || []).filter(isInitialVersionEligible).map(t => { - const initial = (t.submissions || []).find(s => s.type === 'initial') || (t.submissions || [])[0]; - return { ...t, service_type: initial?.service_type || t.title }; + return { ...t, service_type: pickInitialServiceType(t.submissions, t.title) }; }); setUninvoicedTasks(tasksWithService); // Deduplicate by (task_id, version_number) — multiple submission rows per version (e.g. "Add Files") must not produce multiple invoice charges @@ -140,8 +139,7 @@ export default function CreateInvoice() { }; const getRevisionServiceType = (revision) => { - const initial = (revision.task?.submissions || []).find(s => s.type === 'initial') || (revision.task?.submissions || [])[0]; - return initial?.service_type || revision.service_type || revision.task?.title || 'Revision'; + return pickInitialServiceType(revision.task?.submissions, revision.service_type || revision.task?.title || 'Revision'); }; const addRevisionAsItem = (revision) => { diff --git a/src/pages/team/TeamInvoices.jsx b/src/pages/team/TeamInvoices.jsx index 5b5fdc4..3ca7b23 100644 --- a/src/pages/team/TeamInvoices.jsx +++ b/src/pages/team/TeamInvoices.jsx @@ -15,7 +15,7 @@ import LoadingButton from '../../components/LoadingButton'; import { blobToEmailAttachment, sendEmail } from '../../lib/email'; import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles'; import FileAttachment from '../../components/FileAttachment'; -import { isReviewShadowDescription } from '../../lib/taskVersions'; +import { isReviewShadowDescription, pickInitialServiceType } from '../../lib/taskVersions'; import { getRevisionChargeQuantity, isCompletedVersionEligible, isInitialVersionEligible } from '../../lib/invoiceVersionRules'; import { TeamInvoiceDetailPanel } from './TeamInvoiceDetail'; import InvoiceDetailPopup, { POPUP_FIELD_LABEL } from '../../components/InvoiceDetailPopup'; @@ -354,8 +354,7 @@ export default function Invoices() { : { data: [] }; const uninvoicedTasks = (companyTasks || []).filter(isInitialVersionEligible); setInvUnbilledTasks(uninvoicedTasks.map(t => { - const ini = (t.submissions || []).find(s => s.type === 'initial') || t.submissions?.[0]; - return { ...t, service_type: ini?.service_type || t.title }; + return { ...t, service_type: pickInitialServiceType(t.submissions, t.title) }; })); const revMap = new Map(); for (const r of (revisions || []).filter(r => !isReviewShadowDescription(r.description))) { @@ -375,8 +374,7 @@ export default function Invoices() { setInvItems(prev => prev.length === 1 && !prev[0].description && !prev[0].unit_price ? [invNewItem(desc, price?.price || '', 1, task.id)] : [...prev, invNewItem(desc, price?.price || '', 1, task.id)]); }; const invAddRevision = (rev) => { - const ini = (rev.task?.submissions || []).find(s => s.type === 'initial') || rev.task?.submissions?.[0]; - const svcType = ini?.service_type || rev.service_type || rev.task?.title || 'Revision'; + const svcType = pickInitialServiceType(rev.task?.submissions, rev.service_type || rev.task?.title || 'Revision'); const price = invPriceList.find(p => p.service_type === svcType && p.price_type === 'revision'); const revisionChargeQty = getRevisionChargeQuantity(rev?.version_number); const qty = revisionChargeQty > 0 ? revisionChargeQty : 1; @@ -2020,8 +2018,7 @@ export default function Invoices() { {invUnbilledRevisions.map(r => { const added = invItems.some(i => i.submission_id === r.id); const revisionQty = getRevisionChargeQuantity(r?.version_number); - const initial = (r.task?.submissions || []).find(s => s.type === 'initial') || r.task?.submissions?.[0]; - const revisionServiceType = initial?.service_type || r.service_type || r.task?.title || 'Revision'; + const revisionServiceType = pickInitialServiceType(r.task?.submissions, r.service_type || r.task?.title || 'Revision'); const revisionPrice = invPriceList.find(p => p.service_type === revisionServiceType && p.price_type === 'revision'); const revisionLabel = revisionQty > 0 ? (revisionPrice ? `$${Number(revisionPrice.price).toFixed(2)}` : 'No price') : 'Free'; return (