# 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`) ```typescript 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`) ```typescript 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 `pricingMode` is 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 to `s.total` when split calc = 0 — always pass `total: s.total || 0` when 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: ```typescript 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 1. Filters services where `approved === true` 2. For each approved service, maps to ROService shape: `{ name, description, laborHours, laborRate, partsCost, total, status: 'pending' }` 3. Loads the current RO's services 4. Merges: matching by `name` — if exists, updates price/etc but preserves existing `status` and `technician`; if new, appends it 5. Writes merged services back to the RO via `pb.collection('repairOrders').update()` ### Why two sync points - `handleSave` — Save Quote button - `ensureShareToken` — 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) ```typescript 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) ```typescript 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`: ```typescript 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 1. Open DevTools → Console on the failing domain 2. Click Save/Share — look for `Quote schema validation failed:` warning 3. The console warning shows the exact Zod issue (e.g. `invalid_enum_value` on `discountType`) 4. Open Application → Local Storage → key `spq-quote` to see stale state ### Fix Clear localStorage for that domain (`localStorage.removeItem('spq-quote')`) and reload.