Added prevailing wage calculator and custom math modifiers
CI / build (push) Has been skipped
CI / deploy (push) Successful in 50s

This commit is contained in:
2026-03-20 15:11:32 -05:00
parent 59c6211bb9
commit a3ac0feddf
12 changed files with 881 additions and 163 deletions
+230 -10
View File
@@ -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<ModifierRowProps> = (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 (
<div class="rounded-2xl border border-border/60 bg-background/70 p-4 space-y-3">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1 space-y-3">
<div class="flex items-center gap-3">
<input
type="text"
title={module().description}
value={props.modifier.name}
onInput={(e) => 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"
/>
<span class="rounded-full bg-primary/10 px-2 py-0.5 text-[9px] font-black uppercase tracking-[0.2em] text-primary">
Unit Price
</span>
</div>
<div class="grid gap-3 md:grid-cols-[120px_120px_auto]">
<div>
<div class="mb-1 text-[9px] font-black uppercase tracking-[0.2em] text-muted-foreground">Base Rate</div>
<NumericInput
value={computedStatus().parameters.baseRate ?? 0}
onUpdate={(val) => 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"
/>
</div>
<div>
<div class="mb-1 text-[9px] font-black uppercase tracking-[0.2em] text-muted-foreground">Prevailing</div>
<NumericInput
value={computedStatus().parameters.prevailingRate ?? 0}
onUpdate={(val) => 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"
/>
</div>
<div class="flex items-end">
<div class="rounded-xl border border-border/60 bg-muted/20 px-3 py-2 text-[10px] font-black uppercase tracking-[0.15em] text-muted-foreground">
<Show when={!computedStatus().error && computedStatus().multiplier !== undefined} fallback={'Formula: price * multiplier'}>
Multiplier {computedStatus().multiplier?.toFixed(4)}x
</Show>
</div>
</div>
</div>
<Show when={computedStatus().error}>
<div class="min-h-[1rem] text-[10px] font-bold uppercase tracking-widest text-destructive">
{computedStatus().error}
</div>
</Show>
</div>
<div class="flex items-center gap-2">
<button
onClick={() => props.onUpdate(props.modifier.id, { includeInTotal: !props.modifier.includeInTotal })}
class={`p-1.5 rounded-lg transition-all ${props.modifier.includeInTotal ? 'text-primary' : 'text-muted-foreground/30'}`}
title={props.modifier.includeInTotal ? 'Included' : 'Excluded'}
>
<Show when={props.modifier.includeInTotal} fallback={<EyeOff class="w-3.5 h-3.5" />}>
<Calculator class="w-3.5 h-3.5" />
</Show>
</button>
<button
onClick={() => props.onDelete(props.modifier.id)}
class="p-1.5 text-muted-foreground/40 hover:text-destructive hover:bg-destructive/10 rounded-lg transition-all"
>
<Trash2 class="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
);
}
if (isCustom()) {
return (
<div class="rounded-2xl border border-border/60 bg-background/70 p-4 space-y-3">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1 space-y-3">
<div class="flex items-center gap-3">
<input
type="text"
title={module().description}
value={props.modifier.name}
onInput={(e) => 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"
/>
<select
value={computedStatus().targetField}
onChange={(e) => props.onUpdate(props.modifier.id, { targetField: e.currentTarget.value as Modifier['targetField'] })}
class="rounded-lg border border-border bg-background px-2 py-1 text-[10px] font-black uppercase tracking-wider text-muted-foreground outline-none focus:border-primary"
>
<option value="unitPrice">{TARGET_FIELD_LABELS.unitPrice}</option>
<option value="quantity">{TARGET_FIELD_LABELS.quantity}</option>
<option value="subtotal">{TARGET_FIELD_LABELS.subtotal}</option>
</select>
</div>
<div>
<div class="mb-1 flex items-center justify-between text-[9px] font-black uppercase tracking-[0.2em] text-muted-foreground">
<span>Math</span>
<span class="text-primary">Inputs: price, quantity, subtotal</span>
</div>
<input
type="text"
value={computedStatus().expression}
onInput={(e) => 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}
/>
</div>
<div class="flex items-center justify-between gap-4">
<div class="min-h-[1rem] text-[10px] font-bold uppercase tracking-widest">
<Show
when={!computedStatus().error}
fallback={<span class="text-destructive">{computedStatus().error}</span>}
>
<span class="text-primary/70">
Output updates {TARGET_FIELD_LABELS[computedStatus().targetField].toLowerCase()}
</span>
</Show>
</div>
<div class="text-right text-[10px] font-black uppercase tracking-[0.2em] text-muted-foreground">
Base inputs stay editable
</div>
</div>
</div>
<div class="flex items-center gap-2">
<button
onClick={() => props.onUpdate(props.modifier.id, { includeInTotal: !props.modifier.includeInTotal })}
class={`p-1.5 rounded-lg transition-all ${props.modifier.includeInTotal ? 'text-primary' : 'text-muted-foreground/30'}`}
title={props.modifier.includeInTotal ? 'Included' : 'Excluded'}
>
<Show when={props.modifier.includeInTotal} fallback={<EyeOff class="w-3.5 h-3.5" />}>
<Calculator class="w-3.5 h-3.5" />
</Show>
</button>
<button
onClick={() => props.onDelete(props.modifier.id)}
class="p-1.5 text-muted-foreground/40 hover:text-destructive hover:bg-destructive/10 rounded-lg transition-all"
>
<Trash2 class="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
);
}
if (isComputed()) {
return (
<div class="rounded-2xl border border-border/60 bg-background/70 p-4 space-y-3">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1 space-y-2">
<div class="flex items-center gap-3">
<input
type="text"
title={module().description}
value={props.modifier.name}
onInput={(e) => 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"
/>
<span class="rounded-full bg-primary/10 px-2 py-0.5 text-[9px] font-black uppercase tracking-[0.2em] text-primary">
Legacy Modifier
</span>
</div>
<div class="min-h-[1rem] text-[10px] font-bold uppercase tracking-widest">
<Show
when={!computedStatus().error}
fallback={<span class="text-destructive">{computedStatus().error}</span>}
>
<span class="text-primary/70">Existing computed modifier remains supported</span>
</Show>
</div>
</div>
<div class="flex items-center gap-2">
<button
onClick={() => props.onUpdate(props.modifier.id, { includeInTotal: !props.modifier.includeInTotal })}
class={`p-1.5 rounded-lg transition-all ${props.modifier.includeInTotal ? 'text-primary' : 'text-muted-foreground/30'}`}
title={props.modifier.includeInTotal ? 'Included' : 'Excluded'}
>
<Show when={props.modifier.includeInTotal} fallback={<EyeOff class="w-3.5 h-3.5" />}>
<Calculator class="w-3.5 h-3.5" />
</Show>
</button>
<button
onClick={() => props.onDelete(props.modifier.id)}
class="p-1.5 text-muted-foreground/40 hover:text-destructive hover:bg-destructive/10 rounded-lg transition-all"
>
<Trash2 class="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
);
}
return (
<div class="group flex items-center justify-between py-2 px-1 hover:bg-primary/5 rounded-xl transition-all">
<div class="flex items-center gap-3">
{/* Name */}
<input
type="text"
title={module().description}
@@ -32,7 +257,6 @@ const ModifierRow: Component<ModifierRowProps> = (props) => {
placeholder="Modifier Name"
/>
{/* Type Toggles - Visible on hover */}
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => props.onUpdate(props.modifier.id, { valueType: 'percent' })}
@@ -59,13 +283,12 @@ const ModifierRow: Component<ModifierRowProps> = (props) => {
</div>
<div class="flex items-center gap-4">
{/* Value Input */}
<div class="flex items-center gap-2">
<NumericInput
value={props.modifier.value}
onUpdate={(val) => 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'}
/>
<Show when={(props.modifier.valueType === 'unit' && props.modifier.unitLabel) || props.modifier.moduleId === 'man-hours'}>
<span class="text-[10px] font-bold text-primary/60">
@@ -74,14 +297,12 @@ const ModifierRow: Component<ModifierRowProps> = (props) => {
</Show>
</div>
{/* Calculated Result */}
<div class="w-28 text-right flex items-center justify-end gap-1.5">
<span class={`text-sm font-medium transition-colors ${props.modifier.includeInTotal ? 'text-foreground' : 'text-muted-foreground/40'}`}>
{props.modifier.moduleId === 'man-hours' ? '' : '$'}{formatNumber(props.calculatedValue)}
{props.modifier.moduleId === 'man-hours' ? ' hrs' : ''}
</span>
{/* Active/Draft Toggle */}
<button
onClick={() => props.onUpdate(props.modifier.id, { includeInTotal: !props.modifier.includeInTotal })}
class={`p-1 rounded-md transition-all ${props.modifier.includeInTotal ? 'text-primary' : 'text-muted-foreground/30'}`}
@@ -93,7 +314,6 @@ const ModifierRow: Component<ModifierRowProps> = (props) => {
</button>
</div>
{/* Delete */}
<button
onClick={() => props.onDelete(props.modifier.id)}
class="p-1 opacity-0 group-hover:opacity-100 text-muted-foreground/30 hover:text-destructive hover:bg-destructive/10 rounded-lg transition-all"