initial commit
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user