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 <noreply@anthropic.com>
This commit is contained in:
@@ -29,7 +29,11 @@
|
|||||||
"Bash(vercel ls *)",
|
"Bash(vercel ls *)",
|
||||||
"Bash(vercel --prod)",
|
"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 '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 *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,19 @@ export function getVisibleTaskSubmissions(submissions = []) {
|
|||||||
return (submissions || []).filter(submission => !isReviewShadowSubmission(submission));
|
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) {
|
export function getLatestVisibleSubmissionForVersion(taskId, submissions = [], versionNumber = 0) {
|
||||||
return getVisibleTaskSubmissions(submissions)
|
return getVisibleTaskSubmissions(submissions)
|
||||||
.filter(submission => submission?.task_id === taskId && (submission?.version_number || 0) === versionNumber)
|
.filter(submission => submission?.task_id === taskId && (submission?.version_number || 0) === versionNumber)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useAuth } from '../../context/AuthContext';
|
|||||||
import { generateInvoicePDF } from '../../lib/invoice';
|
import { generateInvoicePDF } from '../../lib/invoice';
|
||||||
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
|
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
|
||||||
import { withTimeout } from '../../lib/withTimeout';
|
import { withTimeout } from '../../lib/withTimeout';
|
||||||
import { isReviewShadowDescription } from '../../lib/taskVersions';
|
import { isReviewShadowDescription, pickInitialServiceType } from '../../lib/taskVersions';
|
||||||
import { getRevisionChargeQuantity, isCompletedVersionEligible, isInitialVersionEligible } from '../../lib/invoiceVersionRules';
|
import { getRevisionChargeQuantity, isCompletedVersionEligible, isInitialVersionEligible } from '../../lib/invoiceVersionRules';
|
||||||
|
|
||||||
// Computed at module load time — stable for the lifetime of the invoice creation session
|
// 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 })
|
.order('submitted_at', { ascending: false })
|
||||||
: { data: [] };
|
: { data: [] };
|
||||||
const tasksWithService = (companyTasks || []).filter(isInitialVersionEligible).map(t => {
|
const tasksWithService = (companyTasks || []).filter(isInitialVersionEligible).map(t => {
|
||||||
const initial = (t.submissions || []).find(s => s.type === 'initial') || (t.submissions || [])[0];
|
return { ...t, service_type: pickInitialServiceType(t.submissions, t.title) };
|
||||||
return { ...t, service_type: initial?.service_type || t.title };
|
|
||||||
});
|
});
|
||||||
setUninvoicedTasks(tasksWithService);
|
setUninvoicedTasks(tasksWithService);
|
||||||
// Deduplicate by (task_id, version_number) — multiple submission rows per version (e.g. "Add Files") must not produce multiple invoice charges
|
// 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 getRevisionServiceType = (revision) => {
|
||||||
const initial = (revision.task?.submissions || []).find(s => s.type === 'initial') || (revision.task?.submissions || [])[0];
|
return pickInitialServiceType(revision.task?.submissions, revision.service_type || revision.task?.title || 'Revision');
|
||||||
return initial?.service_type || revision.service_type || revision.task?.title || 'Revision';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const addRevisionAsItem = (revision) => {
|
const addRevisionAsItem = (revision) => {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import LoadingButton from '../../components/LoadingButton';
|
|||||||
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
|
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
|
||||||
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
|
import { popupOverlayStyle, popupSurfaceStyle } from '../../lib/popupStyles';
|
||||||
import FileAttachment from '../../components/FileAttachment';
|
import FileAttachment from '../../components/FileAttachment';
|
||||||
import { isReviewShadowDescription } from '../../lib/taskVersions';
|
import { isReviewShadowDescription, pickInitialServiceType } from '../../lib/taskVersions';
|
||||||
import { getRevisionChargeQuantity, isCompletedVersionEligible, isInitialVersionEligible } from '../../lib/invoiceVersionRules';
|
import { getRevisionChargeQuantity, isCompletedVersionEligible, isInitialVersionEligible } from '../../lib/invoiceVersionRules';
|
||||||
import { TeamInvoiceDetailPanel } from './TeamInvoiceDetail';
|
import { TeamInvoiceDetailPanel } from './TeamInvoiceDetail';
|
||||||
import InvoiceDetailPopup, { POPUP_FIELD_LABEL } from '../../components/InvoiceDetailPopup';
|
import InvoiceDetailPopup, { POPUP_FIELD_LABEL } from '../../components/InvoiceDetailPopup';
|
||||||
@@ -354,8 +354,7 @@ export default function Invoices() {
|
|||||||
: { data: [] };
|
: { data: [] };
|
||||||
const uninvoicedTasks = (companyTasks || []).filter(isInitialVersionEligible);
|
const uninvoicedTasks = (companyTasks || []).filter(isInitialVersionEligible);
|
||||||
setInvUnbilledTasks(uninvoicedTasks.map(t => {
|
setInvUnbilledTasks(uninvoicedTasks.map(t => {
|
||||||
const ini = (t.submissions || []).find(s => s.type === 'initial') || t.submissions?.[0];
|
return { ...t, service_type: pickInitialServiceType(t.submissions, t.title) };
|
||||||
return { ...t, service_type: ini?.service_type || t.title };
|
|
||||||
}));
|
}));
|
||||||
const revMap = new Map();
|
const revMap = new Map();
|
||||||
for (const r of (revisions || []).filter(r => !isReviewShadowDescription(r.description))) {
|
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)]);
|
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 invAddRevision = (rev) => {
|
||||||
const ini = (rev.task?.submissions || []).find(s => s.type === 'initial') || rev.task?.submissions?.[0];
|
const svcType = pickInitialServiceType(rev.task?.submissions, rev.service_type || rev.task?.title || 'Revision');
|
||||||
const svcType = ini?.service_type || rev.service_type || rev.task?.title || 'Revision';
|
|
||||||
const price = invPriceList.find(p => p.service_type === svcType && p.price_type === 'revision');
|
const price = invPriceList.find(p => p.service_type === svcType && p.price_type === 'revision');
|
||||||
const revisionChargeQty = getRevisionChargeQuantity(rev?.version_number);
|
const revisionChargeQty = getRevisionChargeQuantity(rev?.version_number);
|
||||||
const qty = revisionChargeQty > 0 ? revisionChargeQty : 1;
|
const qty = revisionChargeQty > 0 ? revisionChargeQty : 1;
|
||||||
@@ -2020,8 +2018,7 @@ export default function Invoices() {
|
|||||||
{invUnbilledRevisions.map(r => {
|
{invUnbilledRevisions.map(r => {
|
||||||
const added = invItems.some(i => i.submission_id === r.id);
|
const added = invItems.some(i => i.submission_id === r.id);
|
||||||
const revisionQty = getRevisionChargeQuantity(r?.version_number);
|
const revisionQty = getRevisionChargeQuantity(r?.version_number);
|
||||||
const initial = (r.task?.submissions || []).find(s => s.type === 'initial') || r.task?.submissions?.[0];
|
const revisionServiceType = pickInitialServiceType(r.task?.submissions, r.service_type || r.task?.title || 'Revision');
|
||||||
const revisionServiceType = initial?.service_type || r.service_type || r.task?.title || 'Revision';
|
|
||||||
const revisionPrice = invPriceList.find(p => p.service_type === revisionServiceType && p.price_type === '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';
|
const revisionLabel = revisionQty > 0 ? (revisionPrice ? `$${Number(revisionPrice.price).toFixed(2)}` : 'No price') : 'Free';
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user