Files
2026-07-12 10:17:17 -04:00

148 lines
5.5 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.