5.5 KiB
Quote Pricing Modes & RO Reconciliation
Patterns added 2026-07-08 covering Split/Full pricing and approved-service writeback.
Split vs Full Pricing Mode
Services in the Quote Generator and RO Form now support two pricing modes via pricingMode: 'full' | 'split' on the Service / QuoteService / ROService type.
Type Fields (src/types.ts)
interface Service {
// ... existing ...
laborHours?: number;
laborRate?: number;
partsCost?: number;
pricingMode?: 'full' | 'split';
}
ROService already had pricingMode?: 'full' | 'split' (line 219). QuoteService inherits through Service.
Schema (src/schemas/quote.ts)
quoteServiceSchema = z.object({
// ... existing ...
pricingMode: z.enum(['full', 'split']).optional(),
laborHours: z.number().nonnegative().optional(),
laborRate: z.number().nonnegative().optional(),
partsCost: z.number().nonnegative().optional(),
});
Quote Generator — ServiceRow.tsx
Toggle buttons per row in expanded view:
- [Full] default — shows single
$price input - [Split] — shows Hrs × Rate + Parts inputs, total auto-calc
= $XXX.XX
Switching Full→Split: derives hours = price / defaultLaborRate from settings.
Switching Split→Full: keeps current price (from split calc), hides split fields.
Auto-recalc: changing Hrs/Rate/Parts triggers price = (hrs × rate) + partsCost on every onChange.
RO Form — ROForm.tsx (ServiceRow sub-component)
Same Full/Split toggle on each service row header. Key details:
- Split mode (default when
pricingModeis undefined — backward-compatible): 5-column grid (Name, Labor Hrs, Rate/hr, Parts $, read-only Total) - Full mode: 2-column grid (Name, editable Total Price input)
roServiceTotal()already falls back tos.totalwhen split calc = 0 — always passtotal: s.total || 0when calling it- Switching Full→Split: derives hours from flat total ÷ defaultLaborRate, sets laborRate/partsCost
Catalog Service Add
Both ServiceSearch.tsx (quote) and ROForm.tsx (RO) map split pricing fields from the PB services collection. The RO form's handleAddCatalogService already detects split vs full:
const laborPrice = (svc.laborHours || 0) * (svc.laborRate || 0);
const isSplit = laborPrice > 0 || (svc.partsCost || 0) > 0;
// sets pricingMode accordingly
Where to check for existing split/full support
RODetailModal.tsx already has full split/full support (EditableServiceRow, PricingBadge, new service form toggle). No changes needed there.
Approved Quote Services Sync Back to RO
Added in src/components/quoteGenerator/QuoteSummary.tsx to both handleSave and ensureShareToken.
Trigger
When a quote is saved/shared and repairOriginId is set (quote was generated from a Repair Order).
What happens
- Filters services where
approved === true - For each approved service, maps to ROService shape:
{ name, description, laborHours, laborRate, partsCost, total, status: 'pending' } - Loads the current RO's services
- Merges: matching by
name— if exists, updates price/etc but preserves existingstatusandtechnician; if new, appends it - Writes merged services back to the RO via
pb.collection('repairOrders').update()
Why two sync points
handleSave— Save Quote buttonensureShareToken— Share with Customer / Send Email / Send Text (auto-saves the quote)
Both paths are needed because share auto-creates the quote without going through handleSave.
Pagination Removal Pattern
When users want "show all records" instead of paginated results:
Before (paginated)
const records = await pb.collection('repairOrders').getList(page, perPage, {
filter: `userId = '${userId}'`,
sort: '-id',
});
return {
items: records.items.map(normalizeRO),
page: records.page,
perPage: records.perPage,
totalItems: records.totalItems,
totalPages: records.totalPages,
};
After (unlimited)
const records = await pb.collection('repairOrders').getFullList({
filter: `userId = '${userId}'`,
sort: '-id',
});
return {
items: (records as any[]).map(normalizeRO),
page: 1,
perPage: 999999,
totalItems: records.length,
totalPages: 1, // makes hasMore=false in usePagedList
};
This keeps usePagedList's interface happy while making hasMore always false.
The "Load more" button never appears.
Cross-Domain LocalStorage Debug Pattern
When same built code works on one domain but fails on another:
Core insight
Zustand stores with persist middleware save to localStorage under a fixed key (e.g. spq-quote). localStorage is per-origin in the browser. Same nginx server block, same build files, same PocketBase — but different browser origins have different localStorage.
Most common failure
Stale discount type or service data from an older app version that doesn't match the current Zod schema. In QuoteSummary.tsx:126:
const parsed = quoteWriteSchema.safeParse(data);
if (!parsed.success) {
console.warn('Quote schema validation failed:', JSON.stringify(parsed.error.issues, null, 2));
showToast(parsed.error.issues[0].message, 'error');
}
Verification
- Open DevTools → Console on the failing domain
- Click Save/Share — look for
Quote schema validation failed:warning - The console warning shows the exact Zod issue (e.g.
invalid_enum_valueondiscountType) - Open Application → Local Storage → key
spq-quoteto see stale state
Fix
Clear localStorage for that domain (localStorage.removeItem('spq-quote')) and reload.