4.4 KiB
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:
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:
<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
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:
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.tsxServiceRow render — thetotalconst used for displayROForm.tsxcomputeROTotalsmapperROForm.tsxhandleSubmitcleanedServices mapperRODetailModal.tsxEditableServiceRow renderRODetailModal.tsxservice 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:
pricingMode: (s.pricingMode || 'full') as 'full' | 'split',
This is in:
ROForm.tsxhandleSubmit cleanedServicesRODetailModal.tsxservice save handler