Added prevailing wage calculator and custom math modifiers
This commit is contained in:
+230
-10
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user