From a3ac0feddf585832286b8beb1d59ee781b610c15 Mon Sep 17 00:00:00 2001 From: Timothy Cardoza Date: Fri, 20 Mar 2026 15:11:32 -0500 Subject: [PATCH] Added prevailing wage calculator and custom math modifiers --- src/components/ExportTableModal.tsx | 19 +- src/components/ItemRow.tsx | 115 +++-- src/components/ModifierRow.tsx | 240 +++++++++- src/components/PrintEstimate.tsx | 35 +- src/components/ScopeCard.tsx | 47 +- src/components/SubScopeCard.tsx | 22 +- .../useEstimateCalculations.ts | 41 +- src/store/appStore.ts | 16 +- src/types.ts | 8 +- src/utils/modifier-registry.ts | 75 +++- src/utils/pricing.ts | 412 ++++++++++++++++++ src/views/TemplateCreator.tsx | 14 +- 12 files changed, 881 insertions(+), 163 deletions(-) create mode 100644 src/utils/pricing.ts diff --git a/src/components/ExportTableModal.tsx b/src/components/ExportTableModal.tsx index 900f102..e89003a 100644 --- a/src/components/ExportTableModal.tsx +++ b/src/components/ExportTableModal.tsx @@ -3,6 +3,7 @@ import { createSignal, Show, For, onMount } from 'solid-js'; import { Portal } from 'solid-js/web'; import { X, Table, Copy, Download, CheckCircle2 } from 'lucide-solid'; import { appStore } from '../store/appStore'; +import { getEffectiveItemPricing } from '../utils/pricing'; interface ExportTableModalProps { show: boolean; @@ -93,22 +94,18 @@ const ExportTableModal: Component = (props) => { filteredItems.forEach(item => { const scope = scopes().find(s => s.id === item.scopeId); const subScope = subScopes().find(ss => ss.id === item.subScopeId); - - const base = item.quantity * item.unitPrice; - const markupAmt = item.markupType === 'percent' ? base * (item.markup / 100) : item.markup; - const contingencyAmt = item.contingencyType === 'percent' ? base * (item.contingency / 100) : item.contingency; - const total = base + markupAmt + contingencyAmt; + const pricing = getEffectiveItemPricing(item, subScope?.modifiers || []); dataRows.push({ scope: scope?.name || 'Unassigned', subscope: subScope?.name || 'Scope Level', description: item.description, - quantity: item.quantity, - unitPrice: item.unitPrice, - subtotal: base, - markup: markupAmt, - contingency: contingencyAmt, - total: total + quantity: pricing.effectiveQuantity, + unitPrice: pricing.effectiveUnitPrice, + subtotal: pricing.effectiveSubtotal, + markup: pricing.markupAmount, + contingency: pricing.contingencyAmount, + total: pricing.total }); }); diff --git a/src/components/ItemRow.tsx b/src/components/ItemRow.tsx index 22eaadd..2fc6929 100644 --- a/src/components/ItemRow.tsx +++ b/src/components/ItemRow.tsx @@ -3,8 +3,9 @@ import { createMemo, createEffect, createSignal, Show, For, onCleanup } from 'so import { Portal } from 'solid-js/web'; import { GripVertical, Trash2, Copy } from 'lucide-solid'; import NumericInput from './ui/NumericInput'; -import type { EstimateItem, StandardPrice } from '../types'; +import type { EstimateItem, Modifier, StandardPrice } from '../types'; import { pb, COLLECTIONS } from '../utils/db'; +import { getEffectiveItemPricing } from '../utils/pricing'; interface ItemRowProps { item: EstimateItem; @@ -18,23 +19,15 @@ interface ItemRowProps { onDuplicateItem: (id: string) => void; hideColumns?: boolean; activeSupplier?: string; + subScopeModifiers?: Modifier[]; } const ItemRow: Component = (props) => { let descriptionRef: HTMLTextAreaElement | undefined; - const baseTotal = createMemo(() => props.item.quantity * props.item.unitPrice); - - const total = createMemo(() => { - const base = baseTotal(); - const markup = props.item.markupType === 'percent' - ? base * (props.item.markup / 100) - : props.item.markup; - const contingency = props.item.contingencyType === 'percent' - ? base * (props.item.contingency / 100) - : props.item.contingency; - return base + markup + contingency; - }); + const pricing = createMemo(() => getEffectiveItemPricing(props.item, props.subScopeModifiers || [])); + const subtotal = createMemo(() => pricing().effectiveSubtotal); + const total = createMemo(() => pricing().total); const [isSearchingPrice, setIsSearchingPrice] = createSignal(false); const [searchResults, setSearchResults] = createSignal([]); @@ -272,7 +265,7 @@ const ItemRow: Component = (props) => { value={props.item.quantity} onUpdate={(val) => props.onUpdate(props.item.id, { quantity: val })} onDragStart={(e) => { - const val = props.item.quantity; + const val = pricing().effectiveQuantity; const data = { type: 'item-field', itemId: props.item.id, fieldName: 'qty', value: val, label: `${props.item.description || 'Unnamed Item'}: Qty` }; e.dataTransfer!.setData('application/json', JSON.stringify(data)); e.dataTransfer!.setData('text/plain', String(val)); @@ -284,35 +277,49 @@ const ItemRow: Component = (props) => { class="w-full bg-muted/30 border border-border/60 rounded-xl px-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none disabled:opacity-50 cursor-text font-bold text-foreground/80 transition-all" placeholder="Qty" /> + +
+ Adj {formatNumber(pricing().effectiveQuantity)} +
+
{/* Price — w-24 */}
- $ - desktopInputRef = el} - dataFieldName="price" - step="0.1" - value={props.item.unitPrice} - onUpdate={(val) => props.onUpdate(props.item.id, { unitPrice: val })} - onDragStart={(e) => { - const val = props.item.unitPrice; - const data = { type: 'item-field', itemId: props.item.id, fieldName: 'price', value: val, label: `${props.item.description || 'Unnamed Item'}: Price` }; - e.dataTransfer!.setData('application/json', JSON.stringify(data)); - e.dataTransfer!.setData('text/plain', String(val)); - e.dataTransfer!.effectAllowed = 'copy'; - e.stopPropagation(); - }} - draggable="true" - disabled={props.anySelected} - class="w-full bg-muted/30 border border-border/60 rounded-xl pl-5 pr-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none disabled:opacity-50 cursor-text font-bold text-foreground/80 transition-all" - placeholder="Price" - allowTextSearch={true} - onTextSearch={handlePriceSearch} - onKeyDown={handlePriceKeyDown} - onTextSearchExit={() => setIsSearchingPrice(false)} - onBlur={() => setTimeout(() => setIsSearchingPrice(false), 150)} - /> +
+
+ $ + desktopInputRef = el} + dataFieldName="price" + step="0.1" + value={props.item.unitPrice} + onUpdate={(val) => props.onUpdate(props.item.id, { unitPrice: val })} + onDragStart={(e) => { + const val = pricing().effectiveUnitPrice; + const data = { type: 'item-field', itemId: props.item.id, fieldName: 'price', value: val, label: `${props.item.description || 'Unnamed Item'}: Price` }; + e.dataTransfer!.setData('application/json', JSON.stringify(data)); + e.dataTransfer!.setData('text/plain', String(val)); + e.dataTransfer!.effectAllowed = 'copy'; + e.stopPropagation(); + }} + draggable="true" + disabled={props.anySelected} + class="w-full bg-muted/30 border border-border/60 rounded-xl pl-5 pr-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none disabled:opacity-50 cursor-text font-bold text-foreground/80 transition-all" + placeholder="Price" + allowTextSearch={true} + onTextSearch={handlePriceSearch} + onKeyDown={handlePriceKeyDown} + onTextSearchExit={() => setIsSearchingPrice(false)} + onBlur={() => setTimeout(() => setIsSearchingPrice(false), 150)} + /> +
+ +
+ Adj ${formatNumber(pricing().effectiveUnitPrice)} +
+
+
@@ -320,7 +327,7 @@ const ItemRow: Component = (props) => {
{ - const val = baseTotal(); + const val = subtotal(); const data = { type: 'item-field', itemId: props.item.id, fieldName: 'subtotal', value: val, label: `${props.item.description || 'Unnamed Item'}: Subtotal` }; e.dataTransfer!.setData('application/json', JSON.stringify(data)); e.dataTransfer!.setData('text/plain', String(val)); @@ -329,7 +336,15 @@ const ItemRow: Component = (props) => { }} class="w-24 shrink-0 text-right text-muted-foreground/60 text-[11px] font-bold uppercase tracking-tighter cursor-grab hover:text-primary transition-colors" > - ${formatNumber(baseTotal())} +
${formatNumber(pricing().baseSubtotal)}
+ +
+ Adjusted +
+
+ ${formatNumber(subtotal())} +
+
{/* Markup — w-32 (conditional) */} @@ -465,6 +480,11 @@ const ItemRow: Component = (props) => {
Qty
props.onUpdate(props.item.id, { quantity: val })} disabled={props.anySelected} class="w-full bg-muted/30 border border-border/60 rounded-xl px-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none disabled:opacity-50 font-bold text-foreground/80 transition-all" placeholder="Qty" /> + +
+ Adj {formatNumber(pricing().effectiveQuantity)} +
+
Price
@@ -473,6 +493,11 @@ const ItemRow: Component = (props) => { mobileInputRef = el} step="0.1" value={props.item.unitPrice} onUpdate={(val) => props.onUpdate(props.item.id, { unitPrice: val })} disabled={props.anySelected} class="w-full bg-muted/30 border border-border/60 rounded-xl pl-5 pr-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none disabled:opacity-50 font-bold text-foreground/80 transition-all" placeholder="Price" allowTextSearch={true} onTextSearch={handlePriceSearch} onKeyDown={handlePriceKeyDown} onTextSearchExit={() => setIsSearchingPrice(false)} onBlur={() => setTimeout(() => setIsSearchingPrice(false), 150)} />
+ +
+ Adj ${formatNumber(pricing().effectiveUnitPrice)} +
+
@@ -495,6 +520,14 @@ const ItemRow: Component = (props) => {
${formatNumber(total())}
+ +
+ Adjusted +
+
+ ${formatNumber(pricing().effectiveSubtotal)} +
+
); diff --git a/src/components/ModifierRow.tsx b/src/components/ModifierRow.tsx index ba1ce79..21ed9a2 100644 --- a/src/components/ModifierRow.tsx +++ b/src/components/ModifierRow.tsx @@ -1,9 +1,17 @@ import type { Component } from 'solid-js'; -import { Show } from 'solid-js'; +import { Show, createMemo } from 'solid-js'; import { Trash2, Calculator, EyeOff } from 'lucide-solid'; import NumericInput from './ui/NumericInput'; import type { Modifier } from '../types'; import { ModifierRegistry } from '../utils/modifier-registry'; +import { + CUSTOM_CALC_DEFAULT_EXPRESSION, + getComputedModifierStatus, + isComputedFieldModifier, + isCustomCalculationModifier, + isPrevailingWageModifier, + TARGET_FIELD_LABELS +} from '../utils/pricing'; interface ModifierRowProps { modifier: Modifier; @@ -13,16 +21,233 @@ interface ModifierRowProps { } const ModifierRow: Component = (props) => { - const module = () => ModifierRegistry.getModule(props.modifier.moduleId); + const module = () => ModifierRegistry.getModule(props.modifier.moduleId, props.modifier); + const computedStatus = createMemo(() => getComputedModifierStatus(props.modifier)); + const isPrevailing = () => isPrevailingWageModifier(props.modifier); + const isCustom = () => isCustomCalculationModifier(props.modifier); + const isComputed = () => isComputedFieldModifier(props.modifier); const formatNumber = (num: number) => { return num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }; + if (isPrevailing()) { + return ( +
+
+
+
+ props.onUpdate(props.modifier.id, { name: e.currentTarget.value })} + class="bg-transparent border-none outline-none text-sm font-semibold text-foreground focus:ring-0 py-0.5 w-48" + placeholder="Modifier Name" + /> + + Unit Price + +
+ +
+
+
Base Rate
+ props.onUpdate(props.modifier.id, { + targetField: 'unitPrice', + parameters: { + ...computedStatus().parameters, + baseRate: val + } + })} + class="w-full rounded-xl border border-border/60 bg-muted/30 px-3 py-2 text-right text-xs font-bold text-foreground outline-none transition-all focus:border-primary focus:bg-background" + /> +
+
+
Prevailing
+ props.onUpdate(props.modifier.id, { + targetField: 'unitPrice', + parameters: { + ...computedStatus().parameters, + prevailingRate: val + } + })} + class="w-full rounded-xl border border-border/60 bg-muted/30 px-3 py-2 text-right text-xs font-bold text-foreground outline-none transition-all focus:border-primary focus:bg-background" + /> +
+
+
+ + Multiplier {computedStatus().multiplier?.toFixed(4)}x + +
+
+
+ +
+ {computedStatus().error} +
+
+
+ +
+ + +
+
+
+ ); + } + + if (isCustom()) { + return ( +
+
+
+
+ props.onUpdate(props.modifier.id, { name: e.currentTarget.value })} + class="bg-transparent border-none outline-none text-sm font-semibold text-foreground focus:ring-0 py-0.5 w-48" + placeholder="Modifier Name" + /> + +
+ +
+
+ Math + Inputs: price, quantity, subtotal +
+ props.onUpdate(props.modifier.id, { expression: e.currentTarget.value })} + class="w-full rounded-xl border border-border/60 bg-muted/30 px-3 py-2 text-xs font-mono text-foreground outline-none transition-all focus:border-primary focus:bg-background" + placeholder={CUSTOM_CALC_DEFAULT_EXPRESSION} + /> +
+ +
+
+ {computedStatus().error}} + > + + Output updates {TARGET_FIELD_LABELS[computedStatus().targetField].toLowerCase()} + + +
+
+ Base inputs stay editable +
+
+
+ +
+ + +
+
+
+ ); + } + + if (isComputed()) { + return ( +
+
+
+
+ props.onUpdate(props.modifier.id, { name: e.currentTarget.value })} + class="bg-transparent border-none outline-none text-sm font-semibold text-foreground focus:ring-0 py-0.5 w-48" + placeholder="Modifier Name" + /> + + Legacy Modifier + +
+ +
+ {computedStatus().error}} + > + Existing computed modifier remains supported + +
+
+ +
+ + +
+
+
+ ); + } + return (
- {/* Name */} = (props) => { placeholder="Modifier Name" /> - {/* Type Toggles - Visible on hover */}
- {/* Value Input */}
props.onUpdate(props.modifier.id, { value: val })} class="w-20 text-right text-sm border-b border-dashed border-border focus:border-primary outline-none bg-transparent text-primary font-medium" - placeholder={props.modifier.moduleId === 'man-hours' ? "Factor" : "0.00"} + placeholder={props.modifier.moduleId === 'man-hours' ? 'Factor' : '0.00'} /> @@ -74,14 +297,12 @@ const ModifierRow: Component = (props) => {
- {/* Calculated Result */}
{props.modifier.moduleId === 'man-hours' ? '' : '$'}{formatNumber(props.calculatedValue)} {props.modifier.moduleId === 'man-hours' ? ' hrs' : ''} - - {/* Active/Draft Toggle */} +
- {/* Delete */}