329 lines
19 KiB
TypeScript
329 lines
19 KiB
TypeScript
import type { Component } 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;
|
|
calculatedValue: number;
|
|
onUpdate: (id: string, updates: Partial<Modifier>) => void;
|
|
onDelete: (id: string) => void;
|
|
}
|
|
|
|
const ModifierRow: Component<ModifierRowProps> = (props) => {
|
|
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">
|
|
<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-medium text-muted-foreground focus:text-foreground focus:ring-0 py-0.5 w-48"
|
|
placeholder="Modifier Name"
|
|
/>
|
|
|
|
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<button
|
|
onClick={() => props.onUpdate(props.modifier.id, { valueType: 'percent' })}
|
|
class={`p-1 rounded-md transition-colors text-[10px] font-bold ${props.modifier.valueType === 'percent' ? 'bg-primary/10 text-primary' : 'hover:bg-muted text-muted-foreground'}`}
|
|
>
|
|
%
|
|
</button>
|
|
<button
|
|
onClick={() => props.onUpdate(props.modifier.id, { valueType: 'amount' })}
|
|
class={`p-1 rounded-md transition-colors text-[10px] font-bold ${props.modifier.valueType === 'amount' ? 'bg-primary/10 text-primary' : 'hover:bg-muted text-muted-foreground'}`}
|
|
>
|
|
$
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
const unit = props.modifier.unitLabel || 'hrs';
|
|
props.onUpdate(props.modifier.id, { valueType: 'unit', unitLabel: unit });
|
|
}}
|
|
class={`px-1.5 py-1 rounded-md transition-colors text-[9px] font-black tracking-tighter ${props.modifier.valueType === 'unit' ? 'bg-primary/10 text-primary' : 'hover:bg-muted text-muted-foreground'}`}
|
|
>
|
|
UNIT
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-4">
|
|
<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'}
|
|
/>
|
|
<Show when={(props.modifier.valueType === 'unit' && props.modifier.unitLabel) || props.modifier.moduleId === 'man-hours'}>
|
|
<span class="text-[10px] font-bold text-primary/60">
|
|
{props.modifier.moduleId === 'man-hours' ? 'factor' : props.modifier.unitLabel}
|
|
</span>
|
|
</Show>
|
|
</div>
|
|
|
|
<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>
|
|
|
|
<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'}`}
|
|
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>
|
|
</div>
|
|
|
|
<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"
|
|
>
|
|
<Trash2 class="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ModifierRow;
|