initial commit
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
# Split/Full Pricing Toggle
|
||||
|
||||
## Where It Lives
|
||||
|
||||
Three separate components — each has its own copy:
|
||||
|
||||
| Location | Component | File |
|
||||
|----------|-----------|------|
|
||||
| Quote Generator | `ServiceRow` (memo) | `src/components/quoteGenerator/ServiceRow.tsx` |
|
||||
| RO Create/Edit | `ServiceRow` (inline) | `src/components/ROForm.tsx` |
|
||||
| RO Detail Modal | `EditableServiceRow` (inline) | `src/pages/RODetailModal.tsx` |
|
||||
|
||||
All three share the same toggle pattern. When adding the toggle to a new service row, copy the pattern from any existing one.
|
||||
|
||||
## Type Fields
|
||||
|
||||
Added to `Service` / `QuoteService` / `ROService` in `src/types.ts`:
|
||||
|
||||
```ts
|
||||
pricingMode?: 'full' | 'split';
|
||||
laborHours?: number;
|
||||
laborRate?: number;
|
||||
partsCost?: number;
|
||||
```
|
||||
|
||||
`ROService` already had `pricingMode` and split fields. `QuoteService` extended `Service` to add them.
|
||||
|
||||
## Toggle Pattern
|
||||
|
||||
A pill-button group rendered in the row header:
|
||||
|
||||
```tsx
|
||||
<div className="flex rounded-lg border border-gray-300 dark:border-gray-600 overflow-hidden text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isSplit) {
|
||||
onChange(index, 'pricingMode', 'full');
|
||||
}
|
||||
}}
|
||||
className={`px-2.5 py-1 font-medium transition-colors ${
|
||||
!isSplit
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
Full
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!isSplit) {
|
||||
const flatTotal = service.total || total;
|
||||
const rate = defaultLaborRate;
|
||||
const hours = rate > 0 ? Math.round((flatTotal / rate) * 100) / 100 : 0;
|
||||
onChange(index, 'pricingMode', 'split');
|
||||
onChange(index, 'laborHours', hours);
|
||||
onChange(index, 'laborRate', rate);
|
||||
onChange(index, 'partsCost', 0);
|
||||
}
|
||||
}}
|
||||
className={`px-2.5 py-1 font-medium transition-colors ${
|
||||
isSplit
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
Split
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
### State Derivation
|
||||
|
||||
```ts
|
||||
const isSplit = service.pricingMode === 'split'; // default Full
|
||||
```
|
||||
|
||||
When `pricingMode` is undefined (existing data without the field), it defaults to **Full**.
|
||||
|
||||
### Mode Switch Behavior
|
||||
|
||||
| Switch | What Happens |
|
||||
|--------|-------------|
|
||||
| Full → Split | Derives `laborHours = flatten Total / laborRate` from the flat price. Sets `laborRate = defaultLaborRate`, `partsCost = 0` |
|
||||
| Split → Full | Just sets `pricingMode = 'full'`. Split field values persist in state but are hidden |
|
||||
|
||||
### Full Mode Display
|
||||
- Grid collapses to 2 columns: **Name** + **Total Price** (editable input for `service.total`)
|
||||
|
||||
### Split Mode Display
|
||||
- Grid shows 5 columns: **Name**, **Labor Hrs**, **Rate/hr**, **Parts $**, **Total** (read-only, calculated)
|
||||
|
||||
## Total Calculation
|
||||
|
||||
`roServiceTotal()` in `src/lib/totals.ts`:
|
||||
|
||||
```ts
|
||||
export function roServiceTotal(s): number {
|
||||
const split = (s.laborHours || 0) * (s.laborRate || 0) + (s.partsCost || 0);
|
||||
return split || s.total || 0;
|
||||
}
|
||||
```
|
||||
|
||||
For Full mode: hours=0 → split=0 → falls back to `s.total`. **Every call site must pass `total: s.total || 0`** to `roServiceTotal` — otherwise Full-mode services compute to $0.
|
||||
|
||||
## Where Total Must Be Passed
|
||||
|
||||
Search the codebase for `roServiceTotal` call sites and ensure `total` is in the argument:
|
||||
|
||||
- `ROForm.tsx` ServiceRow render — the `total` const used for display
|
||||
- `ROForm.tsx` `computeROTotals` mapper
|
||||
- `ROForm.tsx` `handleSubmit` cleanedServices mapper
|
||||
- `RODetailModal.tsx` EditableServiceRow render
|
||||
- `RODetailModal.tsx` service cleanup on save
|
||||
|
||||
## Initial Defaults
|
||||
|
||||
Every place that creates a new service must set `pricingMode: 'full'`:
|
||||
|
||||
| Location | File | Line |
|
||||
|----------|------|------|
|
||||
| ROForm edit path fallback | `ROForm.tsx` | ~385 |
|
||||
| ROForm create path | `ROForm.tsx` | ~413 |
|
||||
| ROForm handleAddService | `ROForm.tsx` | ~553 |
|
||||
| RODetailModal handleAddService | `RODetailModal.tsx` | ~822 |
|
||||
| RODetailModal createEmptyServiceForm | `RODetailModal.tsx` | 57-67 (already 'full') |
|
||||
| QuoteGenerator ServiceSearch handleAdd | `ServiceSearch.tsx` | 56 (addService passes defaults) |
|
||||
|
||||
## Persistence
|
||||
|
||||
When saving services back to PocketBase, `pricingMode` must be included in the cleaned service object:
|
||||
|
||||
```ts
|
||||
pricingMode: (s.pricingMode || 'full') as 'full' | 'split',
|
||||
```
|
||||
|
||||
This is in:
|
||||
- `ROForm.tsx` handleSubmit cleanedServices
|
||||
- `RODetailModal.tsx` service save handler
|
||||
Reference in New Issue
Block a user