Files
hermes-config/skills/self-hosting/shop-pro-quote/references/quote-pricing-and-reconciliation.md
T
2026-07-12 10:17:17 -04:00

5.5 KiB
Raw Blame History

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 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:

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)

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

  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.