Added prevailing wage calculator and custom math modifiers
This commit is contained in:
@@ -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<ExportTableModalProps> = (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
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+50
-17
@@ -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<ItemRowProps> = (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<StandardPrice[]>([]);
|
||||
@@ -272,7 +265,7 @@ const ItemRow: Component<ItemRowProps> = (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,10 +277,17 @@ const ItemRow: Component<ItemRowProps> = (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"
|
||||
/>
|
||||
<Show when={pricing().hasDirectQuantityAdjustment}>
|
||||
<div class="mt-1 text-right text-[9px] font-black uppercase tracking-[0.15em] text-primary">
|
||||
Adj {formatNumber(pricing().effectiveQuantity)}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Price — w-24 */}
|
||||
<div class="w-24 shrink-0 relative" ref={anchorRef}>
|
||||
<div class="space-y-1">
|
||||
<div class="relative">
|
||||
<span class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50 text-[10px] pointer-events-none">$</span>
|
||||
<NumericInput
|
||||
inputRef={(el: any) => desktopInputRef = el}
|
||||
@@ -296,7 +296,7 @@ const ItemRow: Component<ItemRowProps> = (props) => {
|
||||
value={props.item.unitPrice}
|
||||
onUpdate={(val) => props.onUpdate(props.item.id, { unitPrice: val })}
|
||||
onDragStart={(e) => {
|
||||
const val = props.item.unitPrice;
|
||||
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));
|
||||
@@ -313,6 +313,13 @@ const ItemRow: Component<ItemRowProps> = (props) => {
|
||||
onTextSearchExit={() => setIsSearchingPrice(false)}
|
||||
onBlur={() => setTimeout(() => setIsSearchingPrice(false), 150)}
|
||||
/>
|
||||
</div>
|
||||
<Show when={pricing().hasDirectUnitPriceAdjustment}>
|
||||
<div class="text-right text-[9px] font-black uppercase tracking-[0.15em] text-primary">
|
||||
Adj ${formatNumber(pricing().effectiveUnitPrice)}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<PriceDropdown />
|
||||
</div>
|
||||
|
||||
@@ -320,7 +327,7 @@ const ItemRow: Component<ItemRowProps> = (props) => {
|
||||
<div
|
||||
draggable="true"
|
||||
onDragStart={(e) => {
|
||||
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<ItemRowProps> = (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())}
|
||||
<div>${formatNumber(pricing().baseSubtotal)}</div>
|
||||
<Show when={pricing().hasAdjustedSubtotal}>
|
||||
<div class="mt-1 text-[9px] font-black uppercase tracking-[0.15em] text-primary">
|
||||
Adjusted
|
||||
</div>
|
||||
<div class="text-[10px] font-black text-primary">
|
||||
${formatNumber(subtotal())}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Markup — w-32 (conditional) */}
|
||||
@@ -465,6 +480,11 @@ const ItemRow: Component<ItemRowProps> = (props) => {
|
||||
<div class="w-20">
|
||||
<div class="text-[9px] font-bold text-muted-foreground/60 uppercase mb-1 ml-1">Qty</div>
|
||||
<NumericInput value={props.item.quantity} onUpdate={(val) => 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" />
|
||||
<Show when={pricing().hasDirectQuantityAdjustment}>
|
||||
<div class="mt-1 text-right text-[9px] font-black uppercase tracking-[0.15em] text-primary">
|
||||
Adj {formatNumber(pricing().effectiveQuantity)}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<div class="text-[9px] font-bold text-muted-foreground/60 uppercase mb-1 ml-1">Price</div>
|
||||
@@ -473,6 +493,11 @@ const ItemRow: Component<ItemRowProps> = (props) => {
|
||||
<NumericInput inputRef={(el: any) => 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)} />
|
||||
<PriceDropdown />
|
||||
</div>
|
||||
<Show when={pricing().hasDirectUnitPriceAdjustment}>
|
||||
<div class="mt-1 text-right text-[9px] font-black uppercase tracking-[0.15em] text-primary">
|
||||
Adj ${formatNumber(pricing().effectiveUnitPrice)}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.isSidebarCollapsed() || !props.hideColumns}>
|
||||
<div class="w-32">
|
||||
@@ -495,6 +520,14 @@ const ItemRow: Component<ItemRowProps> = (props) => {
|
||||
<div class={`font-black text-sm text-right ${props.isSelected ? 'text-primary' : 'text-foreground'}`}>${formatNumber(total())}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={pricing().hasDirectSubtotalAdjustment || pricing().hasAdjustedSubtotal}>
|
||||
<div class="pl-7 text-right text-[10px] font-black uppercase tracking-[0.15em] text-primary/70">
|
||||
Adjusted
|
||||
</div>
|
||||
<div class="pl-7 text-right text-sm font-black text-primary">
|
||||
${formatNumber(pricing().effectiveSubtotal)}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Scope, SubScope, EstimateItem, JobInfo } from '../types';
|
||||
import { NOTICE_TO_OWNERS } from '../notice-to-owners';
|
||||
import { COMPANY_NAME, COMPANY_ADDRESS, COMPANY_PHONE, COMPANY_EMAIL } from '../company-info';
|
||||
import { ModifierRegistry } from '../utils/modifier-registry';
|
||||
import { getEffectiveItemPricing, getItemsPricingTotals } from '../utils/pricing';
|
||||
|
||||
interface PrintEstimateProps {
|
||||
scopes: Scope[];
|
||||
@@ -44,10 +45,13 @@ const PrintEstimate: Component<PrintEstimateProps> = (props) => {
|
||||
if (!scope) return 0;
|
||||
|
||||
props.items.filter((i: EstimateItem) => i.scopeId === scopeId).forEach((item: EstimateItem) => {
|
||||
const base = item.quantity * item.unitPrice;
|
||||
scopeSubtotal += base;
|
||||
scopeMarkup += item.markupType === 'percent' ? base * (item.markup / 100) : item.markup;
|
||||
scopeContingency += item.contingencyType === 'percent' ? base * (item.contingency / 100) : item.contingency;
|
||||
const modifiers = item.subScopeId
|
||||
? (props.subScopes.find(subScope => subScope.id === item.subScopeId)?.modifiers || [])
|
||||
: [];
|
||||
const pricing = getEffectiveItemPricing(item, modifiers);
|
||||
scopeSubtotal += pricing.effectiveSubtotal;
|
||||
scopeMarkup += pricing.markupAmount;
|
||||
scopeContingency += pricing.contingencyAmount;
|
||||
});
|
||||
|
||||
const mgmtFee = scope.useManagementLogic === 'percent'
|
||||
@@ -63,12 +67,7 @@ const PrintEstimate: Component<PrintEstimateProps> = (props) => {
|
||||
|
||||
scopeSubScopes.forEach(ss => {
|
||||
const ssItems = props.items.filter(i => i.subScopeId === ss.id);
|
||||
const ssTotalWithMarkup = ssItems.reduce((sum, item) => {
|
||||
const base = item.quantity * item.unitPrice;
|
||||
const markup = item.markupType === 'percent' ? base * (item.markup / 100) : item.markup;
|
||||
const contingency = item.contingencyType === 'percent' ? base * (item.contingency / 100) : item.contingency;
|
||||
return sum + base + markup + contingency;
|
||||
}, 0);
|
||||
const ssTotalWithMarkup = getItemsPricingTotals(ssItems, ss.modifiers || []).total;
|
||||
|
||||
(ss.modifiers || []).forEach(m => {
|
||||
if (!m.includeInTotal) return;
|
||||
@@ -95,7 +94,13 @@ const PrintEstimate: Component<PrintEstimateProps> = (props) => {
|
||||
};
|
||||
};
|
||||
|
||||
const getItemSubtotal = (item: EstimateItem) => item.quantity * item.unitPrice;
|
||||
const getItemPricing = (item: EstimateItem) => {
|
||||
const modifiers = item.subScopeId
|
||||
? (props.subScopes.find(subScope => subScope.id === item.subScopeId)?.modifiers || [])
|
||||
: [];
|
||||
return getEffectiveItemPricing(item, modifiers);
|
||||
};
|
||||
const getItemSubtotal = (item: EstimateItem) => getItemPricing(item).effectiveSubtotal;
|
||||
const getManagerScopeVisibleTotal = (scopeId: string) =>
|
||||
props.items
|
||||
.filter((item: EstimateItem) => item.scopeId === scopeId)
|
||||
@@ -274,8 +279,8 @@ const PrintEstimate: Component<PrintEstimateProps> = (props) => {
|
||||
<div class="grid grid-cols-12 gap-3 px-3 py-2 text-xs border-t border-border/20 items-start">
|
||||
<div class="col-span-2 font-bold text-foreground">Scope Level</div>
|
||||
<div class="col-span-6 text-foreground/90">{item.description}</div>
|
||||
<div class="col-span-1 text-right text-foreground">{item.quantity}</div>
|
||||
<div class="col-span-1 text-right text-foreground">${props.formatNumber(item.unitPrice)}</div>
|
||||
<div class="col-span-1 text-right text-foreground">{getItemPricing(item).effectiveQuantity}</div>
|
||||
<div class="col-span-1 text-right text-foreground">${props.formatNumber(getItemPricing(item).effectiveUnitPrice)}</div>
|
||||
<div class="col-span-2 text-right font-bold text-foreground">${props.formatNumber(getItemSubtotal(item))}</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -294,8 +299,8 @@ const PrintEstimate: Component<PrintEstimateProps> = (props) => {
|
||||
<div class="grid grid-cols-12 gap-3 px-3 py-2 text-xs border-t border-border/20 items-start">
|
||||
<div class="col-span-2 font-bold text-foreground">{entry.subScope.name}</div>
|
||||
<div class="col-span-6 text-foreground/90">{item.description}</div>
|
||||
<div class="col-span-1 text-right text-foreground">{item.quantity}</div>
|
||||
<div class="col-span-1 text-right text-foreground">${props.formatNumber(item.unitPrice)}</div>
|
||||
<div class="col-span-1 text-right text-foreground">{getItemPricing(item).effectiveQuantity}</div>
|
||||
<div class="col-span-1 text-right text-foreground">${props.formatNumber(getItemPricing(item).effectiveUnitPrice)}</div>
|
||||
<div class="col-span-2 text-right font-bold text-foreground">${props.formatNumber(getItemSubtotal(item))}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ModifierRegistry } from '../utils/modifier-registry';
|
||||
import { appStore } from '../store/appStore';
|
||||
import { SupplierDropdown } from './ui/SupplierDropdown';
|
||||
import ScopeTextTemplateModal from './ScopeTextTemplateModal';
|
||||
import { getItemsPricingTotals } from '../utils/pricing';
|
||||
|
||||
interface ScopeCardProps {
|
||||
scope: Scope;
|
||||
@@ -51,20 +52,28 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
|
||||
const scopeItems = createMemo(() => props.items.filter(i => !i.subScopeId));
|
||||
|
||||
const totals = createMemo(() => {
|
||||
let subtotal = 0;
|
||||
let markup = 0;
|
||||
let contingency = 0;
|
||||
let totalQuantity = 0;
|
||||
const scopeLevelTotals = getItemsPricingTotals(scopeItems(), []);
|
||||
let subtotal = scopeLevelTotals.subtotal;
|
||||
let markup = scopeLevelTotals.markup;
|
||||
let contingency = scopeLevelTotals.contingency;
|
||||
let totalQuantity = scopeLevelTotals.quantity;
|
||||
|
||||
props.items.forEach(item => {
|
||||
const base = item.quantity * item.unitPrice;
|
||||
subtotal += base;
|
||||
totalQuantity += item.quantity;
|
||||
markup += item.markupType === 'percent' ? base * (item.markup / 100) : item.markup;
|
||||
contingency += item.contingencyType === 'percent' ? base * (item.contingency / 100) : item.contingency;
|
||||
let modifiersTotal = 0;
|
||||
props.subScopes.forEach(ss => {
|
||||
const ssItems = props.items.filter(i => i.subScopeId === ss.id);
|
||||
const ssTotals = getItemsPricingTotals(ssItems, ss.modifiers || []);
|
||||
subtotal += ssTotals.subtotal;
|
||||
markup += ssTotals.markup;
|
||||
contingency += ssTotals.contingency;
|
||||
totalQuantity += ssTotals.quantity;
|
||||
|
||||
(ss.modifiers || []).forEach(m => {
|
||||
if (!m.includeInTotal) return;
|
||||
modifiersTotal += ModifierRegistry.calculate(m, ssTotals.total, ssItems);
|
||||
});
|
||||
});
|
||||
|
||||
// Add Management and Delivery
|
||||
// Add Management and Delivery after all scope and sub-scope item totals are accounted for.
|
||||
const mgmtTotal = props.scope.useManagementLogic === 'percent'
|
||||
? subtotal * (props.scope.managementFee / 100)
|
||||
: props.scope.managementFee;
|
||||
@@ -73,22 +82,6 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
|
||||
? subtotal * (props.scope.deliveryFee / 100)
|
||||
: props.scope.deliveryFee;
|
||||
|
||||
let modifiersTotal = 0;
|
||||
props.subScopes.forEach(ss => {
|
||||
const ssItems = props.items.filter(i => i.subScopeId === ss.id);
|
||||
const ssTotalWithMarkup = ssItems.reduce((sum, item) => {
|
||||
const base = item.quantity * item.unitPrice;
|
||||
const markup = item.markupType === 'percent' ? base * (item.markup / 100) : item.markup;
|
||||
const contingency = item.contingencyType === 'percent' ? base * (item.contingency / 100) : item.contingency;
|
||||
return sum + base + markup + contingency;
|
||||
}, 0);
|
||||
|
||||
(ss.modifiers || []).forEach(m => {
|
||||
if (!m.includeInTotal) return;
|
||||
modifiersTotal += ModifierRegistry.calculate(m, ssTotalWithMarkup, ssItems);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
subtotal,
|
||||
markup,
|
||||
|
||||
@@ -6,6 +6,7 @@ import LazyItem from './LazyItem';
|
||||
import ModifierRow from './ModifierRow';
|
||||
import { appStore } from '../store/appStore';
|
||||
import { ModifierRegistry, MODIFIER_MODULES } from '../utils/modifier-registry';
|
||||
import { getItemsPricingTotals } from '../utils/pricing';
|
||||
|
||||
interface SubScopeCardProps {
|
||||
subScope: SubScope;
|
||||
@@ -36,28 +37,20 @@ const SubScopeCard: Component<SubScopeCardProps> = (props) => {
|
||||
const subScopeItems = () => props.items.filter(item => item.subScopeId === props.subScope.id);
|
||||
|
||||
const totals = createMemo(() => {
|
||||
const itemTotals = props.items.reduce((acc, item) => {
|
||||
const base = item.quantity * item.unitPrice;
|
||||
const markup = item.markupType === 'percent' ? base * (item.markup / 100) : item.markup;
|
||||
const contingency = item.contingencyType === 'percent' ? base * (item.contingency / 100) : item.contingency;
|
||||
return {
|
||||
amount: acc.amount + base + markup + contingency,
|
||||
quantity: acc.quantity + item.quantity
|
||||
};
|
||||
}, { amount: 0, quantity: 0 });
|
||||
|
||||
let modifiedAmount = itemTotals.amount;
|
||||
const modifiers = props.subScope.modifiers || [];
|
||||
const itemTotals = getItemsPricingTotals(props.items, modifiers);
|
||||
|
||||
let modifiedAmount = itemTotals.total;
|
||||
|
||||
modifiers.forEach(m => {
|
||||
if (!m.includeInTotal) return;
|
||||
modifiedAmount += ModifierRegistry.calculate(m, itemTotals.amount, subScopeItems());
|
||||
modifiedAmount += ModifierRegistry.calculate(m, itemTotals.total, subScopeItems());
|
||||
});
|
||||
|
||||
return {
|
||||
amount: modifiedAmount,
|
||||
quantity: itemTotals.quantity,
|
||||
baseAmount: itemTotals.amount
|
||||
baseAmount: itemTotals.total
|
||||
};
|
||||
});
|
||||
|
||||
@@ -220,6 +213,7 @@ const SubScopeCard: Component<SubScopeCardProps> = (props) => {
|
||||
onToggleSelection={props.onToggleSelection}
|
||||
hideColumns={props.hideColumns}
|
||||
activeSupplier={props.scopeSupplier}
|
||||
subScopeModifiers={props.subScope.modifiers}
|
||||
/>
|
||||
</LazyItem>
|
||||
)}
|
||||
@@ -236,7 +230,7 @@ const SubScopeCard: Component<SubScopeCardProps> = (props) => {
|
||||
{(modifier) => (
|
||||
<ModifierRow
|
||||
modifier={modifier}
|
||||
calculatedValue={ModifierRegistry.calculateDisplay(modifier, totals().baseAmount, subScopeItems())}
|
||||
calculatedValue={ModifierRegistry.calculateDisplay(modifier, totals().baseAmount, subScopeItems(), props.subScope.modifiers || [])}
|
||||
onUpdate={(id, updates) => appStore.updateModifier(props.subScope.id, id, updates)}
|
||||
onDelete={(id) => appStore.removeModifier(props.subScope.id, id)}
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createMemo } from 'solid-js';
|
||||
import type { Accessor } from 'solid-js';
|
||||
import { appStore } from '../../store/appStore';
|
||||
import { ModifierRegistry } from '../../utils/modifier-registry';
|
||||
import { getEffectiveItemPricing, getItemsPricingTotals } from '../../utils/pricing';
|
||||
|
||||
export function useEstimateCalculations(selectedIds: Accessor<string[]>) {
|
||||
const items = appStore.estimateItems;
|
||||
@@ -15,15 +16,17 @@ export function useEstimateCalculations(selectedIds: Accessor<string[]>) {
|
||||
let contingency = 0;
|
||||
let fees = 0;
|
||||
const scopeSubtotals = new Map<string, number>();
|
||||
const subScopes = appStore.subScopes;
|
||||
const subScopeById = new Map(subScopes.map(subScope => [subScope.id, subScope]));
|
||||
|
||||
items.forEach(item => {
|
||||
const base = item.quantity * item.unitPrice;
|
||||
subtotal += base;
|
||||
markup += item.markupType === 'percent' ? base * (item.markup / 100) : item.markup;
|
||||
contingency += item.contingencyType === 'percent' ? base * (item.contingency / 100) : item.contingency;
|
||||
const pricing = getEffectiveItemPricing(item, item.subScopeId ? (subScopeById.get(item.subScopeId)?.modifiers || []) : []);
|
||||
subtotal += pricing.effectiveSubtotal;
|
||||
markup += pricing.markupAmount;
|
||||
contingency += pricing.contingencyAmount;
|
||||
|
||||
if (item.scopeId) {
|
||||
scopeSubtotals.set(item.scopeId, (scopeSubtotals.get(item.scopeId) || 0) + base);
|
||||
scopeSubtotals.set(item.scopeId, (scopeSubtotals.get(item.scopeId) || 0) + pricing.effectiveSubtotal);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -40,17 +43,10 @@ export function useEstimateCalculations(selectedIds: Accessor<string[]>) {
|
||||
});
|
||||
|
||||
let modifiersTotal = 0;
|
||||
const subScopes = appStore.subScopes;
|
||||
|
||||
subScopes.forEach(subScope => {
|
||||
const scopeId = subScope.id;
|
||||
const subScopeItems = items.filter(i => i.subScopeId === scopeId); // Wait, subScope.id is compared to item.subScopeId
|
||||
const subScopeTotalWithMarkup = subScopeItems.reduce((sum, item) => {
|
||||
const base = item.quantity * item.unitPrice;
|
||||
const markup = item.markupType === 'percent' ? base * (item.markup / 100) : item.markup;
|
||||
const contingency = item.contingencyType === 'percent' ? base * (item.contingency / 100) : item.contingency;
|
||||
return sum + base + markup + contingency;
|
||||
}, 0);
|
||||
const subScopeItems = items.filter(i => i.subScopeId === subScope.id);
|
||||
const subScopeTotalWithMarkup = getItemsPricingTotals(subScopeItems, subScope.modifiers || []).total;
|
||||
|
||||
(subScope.modifiers || []).forEach(m => {
|
||||
if (!m.includeInTotal) return;
|
||||
@@ -86,19 +82,18 @@ export function useEstimateCalculations(selectedIds: Accessor<string[]>) {
|
||||
|
||||
const selectionTotals = createMemo(() => {
|
||||
const selected = items.filter(i => selectedIds().includes(i.id));
|
||||
const subScopeById = new Map(appStore.subScopes.map(subScope => [subScope.id, subScope]));
|
||||
let qty = 0, price = 0, sub = 0, mark = 0, cont = 0, tot = 0;
|
||||
|
||||
selected.forEach(item => {
|
||||
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 pricing = getEffectiveItemPricing(item, item.subScopeId ? (subScopeById.get(item.subScopeId)?.modifiers || []) : []);
|
||||
|
||||
qty += item.quantity;
|
||||
price += item.unitPrice;
|
||||
sub += base;
|
||||
mark += markupAmt;
|
||||
cont += contingencyAmt;
|
||||
tot += base + markupAmt + contingencyAmt;
|
||||
qty += pricing.effectiveQuantity;
|
||||
price += pricing.effectiveUnitPrice;
|
||||
sub += pricing.effectiveSubtotal;
|
||||
mark += pricing.markupAmount;
|
||||
cont += pricing.contingencyAmount;
|
||||
tot += pricing.total;
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
+12
-4
@@ -84,8 +84,12 @@ export const appStore = {
|
||||
value: module.defaultValue,
|
||||
valueType: module.defaultValueType,
|
||||
unitLabel: module.defaultUnitLabel,
|
||||
type: module.id as any, // Temporary cast
|
||||
includeInTotal: module.id !== 'man-hours'
|
||||
type: module.id as Modifier['type'],
|
||||
includeInTotal: module.id !== 'man-hours',
|
||||
presetId: module.defaultPresetId,
|
||||
targetField: module.defaultTargetField,
|
||||
expression: module.defaultExpression,
|
||||
parameters: module.defaultParameters ? { ...module.defaultParameters } : undefined
|
||||
};
|
||||
|
||||
setSubScopes(s => s.id === subScopeId, produce((ss) => {
|
||||
@@ -130,8 +134,12 @@ export const appStore = {
|
||||
value: module.defaultValue,
|
||||
valueType: module.defaultValueType,
|
||||
unitLabel: module.defaultUnitLabel,
|
||||
type: module.id as any,
|
||||
includeInTotal: module.id !== 'man-hours'
|
||||
type: module.id as Modifier['type'],
|
||||
includeInTotal: module.id !== 'man-hours',
|
||||
presetId: module.defaultPresetId,
|
||||
targetField: module.defaultTargetField,
|
||||
expression: module.defaultExpression,
|
||||
parameters: module.defaultParameters ? { ...module.defaultParameters } : undefined
|
||||
};
|
||||
setActiveTemplateModifiers(prev => [...prev, newModifier]);
|
||||
},
|
||||
|
||||
+7
-1
@@ -16,7 +16,9 @@ export interface Scope {
|
||||
supplier?: string;
|
||||
}
|
||||
|
||||
export type ModifierType = 'tax' | 'man-hours' | 'custom';
|
||||
export type ModifierType = 'tax' | 'man-hours' | 'adjustment' | 'computed-field' | 'prevailing-wage' | 'custom-calculation' | 'custom';
|
||||
export type ModifierPresetId = 'prevailing-wage' | 'custom';
|
||||
export type ModifierTargetField = 'unitPrice' | 'quantity' | 'subtotal';
|
||||
|
||||
export interface Modifier {
|
||||
id: string;
|
||||
@@ -27,6 +29,10 @@ export interface Modifier {
|
||||
type: ModifierType;
|
||||
unitLabel?: string; // e.g. "hrs" for man-hours
|
||||
includeInTotal: boolean;
|
||||
presetId?: ModifierPresetId;
|
||||
targetField?: ModifierTargetField;
|
||||
expression?: string;
|
||||
parameters?: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface SubScope {
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import type { Modifier, EstimateItem } from '../types';
|
||||
import type { Modifier, EstimateItem, ModifierTargetField } from '../types';
|
||||
import {
|
||||
CUSTOM_CALC_DEFAULT_EXPRESSION,
|
||||
getComputedModifierStatus,
|
||||
getEffectiveItemPricing,
|
||||
PREVAILING_WAGE_DEFAULT_EXPRESSION,
|
||||
PREVAILING_WAGE_DEFAULT_PARAMETERS,
|
||||
resolveModifierModuleId
|
||||
} from './pricing';
|
||||
|
||||
export interface ModifierModule {
|
||||
id: string;
|
||||
@@ -8,12 +16,16 @@ export interface ModifierModule {
|
||||
defaultValue: number;
|
||||
defaultValueType: 'percent' | 'amount' | 'unit';
|
||||
defaultUnitLabel?: string;
|
||||
defaultPresetId?: 'prevailing-wage' | 'custom';
|
||||
defaultTargetField?: ModifierTargetField;
|
||||
defaultExpression?: string;
|
||||
defaultParameters?: Record<string, number>;
|
||||
calculate: (modifier: Modifier, subScopeTotalWithMarkup: number, subScopeItems: EstimateItem[]) => number;
|
||||
calculateDisplay?: (modifier: Modifier, subScopeTotalWithMarkup: number, subScopeItems: EstimateItem[]) => number;
|
||||
calculateDisplay?: (modifier: Modifier, subScopeTotalWithMarkup: number, subScopeItems: EstimateItem[], subScopeModifiers?: Modifier[]) => number;
|
||||
}
|
||||
|
||||
const getPreMarkupSubtotal = (items: EstimateItem[]) => {
|
||||
return items.reduce((sum, item) => sum + item.quantity * item.unitPrice, 0);
|
||||
const getPreMarkupSubtotal = (items: EstimateItem[], modifiers: Modifier[] = []) => {
|
||||
return items.reduce((sum, item) => sum + getEffectiveItemPricing(item, modifiers).effectiveSubtotal, 0);
|
||||
};
|
||||
|
||||
const TaxModule: ModifierModule = {
|
||||
@@ -38,10 +50,10 @@ const ManHoursModule: ModifierModule = {
|
||||
defaultValueType: 'unit',
|
||||
defaultUnitLabel: 'factor',
|
||||
calculate: () => 0,
|
||||
calculateDisplay: (m, _, items) => {
|
||||
calculateDisplay: (m, _, items, subScopeModifiers = []) => {
|
||||
const factor = m.value || 1;
|
||||
if (factor === 0) return 0;
|
||||
const subtotal = getPreMarkupSubtotal(items);
|
||||
const subtotal = getPreMarkupSubtotal(items, subScopeModifiers);
|
||||
return subtotal / factor;
|
||||
}
|
||||
};
|
||||
@@ -59,23 +71,62 @@ const SimpleAdjustmentModule: ModifierModule = {
|
||||
}
|
||||
};
|
||||
|
||||
const PrevailingWageModule: ModifierModule = {
|
||||
id: 'prevailing-wage',
|
||||
name: 'Prevailing Wage',
|
||||
description: 'Scales each item price by the prevailing wage multiplier.',
|
||||
defaultLabel: 'Prevailing Wage',
|
||||
defaultValue: 0,
|
||||
defaultValueType: 'unit',
|
||||
defaultPresetId: 'prevailing-wage',
|
||||
defaultTargetField: 'unitPrice',
|
||||
defaultExpression: PREVAILING_WAGE_DEFAULT_EXPRESSION,
|
||||
defaultParameters: { ...PREVAILING_WAGE_DEFAULT_PARAMETERS },
|
||||
calculate: () => 0,
|
||||
calculateDisplay: (modifier) => getComputedModifierStatus(modifier).multiplier || 0
|
||||
};
|
||||
|
||||
const CustomCalculationModule: ModifierModule = {
|
||||
id: 'custom-calculation',
|
||||
name: 'Custom Math',
|
||||
description: 'Applies a custom expression using price, quantity, and subtotal variables.',
|
||||
defaultLabel: 'Custom Math',
|
||||
defaultValue: 0,
|
||||
defaultValueType: 'unit',
|
||||
defaultPresetId: 'custom',
|
||||
defaultTargetField: 'unitPrice',
|
||||
defaultExpression: CUSTOM_CALC_DEFAULT_EXPRESSION,
|
||||
calculate: () => 0,
|
||||
calculateDisplay: () => 0
|
||||
};
|
||||
|
||||
export const MODIFIER_MODULES: ModifierModule[] = [
|
||||
TaxModule,
|
||||
ManHoursModule,
|
||||
SimpleAdjustmentModule
|
||||
SimpleAdjustmentModule,
|
||||
PrevailingWageModule,
|
||||
CustomCalculationModule
|
||||
];
|
||||
|
||||
const getModuleByResolvedId = (id: string) => MODIFIER_MODULES.find(m => m.id === id) || SimpleAdjustmentModule;
|
||||
|
||||
export const ModifierRegistry = {
|
||||
getModule: (id: string) => MODIFIER_MODULES.find(m => m.id === id) || SimpleAdjustmentModule,
|
||||
getModule: (id: string, modifier?: Modifier) => {
|
||||
if (id === 'computed-field' && modifier) {
|
||||
return getModuleByResolvedId(resolveModifierModuleId(modifier));
|
||||
}
|
||||
|
||||
return getModuleByResolvedId(id);
|
||||
},
|
||||
|
||||
calculate: (modifier: Modifier, baseTotal: number, items: EstimateItem[]) => {
|
||||
const module = ModifierRegistry.getModule(modifier.moduleId);
|
||||
const module = ModifierRegistry.getModule(modifier.moduleId, modifier);
|
||||
return module.calculate(modifier, baseTotal, items);
|
||||
},
|
||||
|
||||
calculateDisplay: (modifier: Modifier, baseTotal: number, items: EstimateItem[]) => {
|
||||
const module = ModifierRegistry.getModule(modifier.moduleId);
|
||||
if (module.calculateDisplay) return module.calculateDisplay(modifier, baseTotal, items);
|
||||
calculateDisplay: (modifier: Modifier, baseTotal: number, items: EstimateItem[], subScopeModifiers: Modifier[] = []) => {
|
||||
const module = ModifierRegistry.getModule(modifier.moduleId, modifier);
|
||||
if (module.calculateDisplay) return module.calculateDisplay(modifier, baseTotal, items, subScopeModifiers);
|
||||
return module.calculate(modifier, baseTotal, items);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
import type { EstimateItem, Modifier, ModifierTargetField } from '../types';
|
||||
|
||||
export const PREVAILING_WAGE_DEFAULT_PARAMETERS = {
|
||||
baseRate: 30,
|
||||
prevailingRate: 50
|
||||
} as const;
|
||||
|
||||
export const PREVAILING_WAGE_DEFAULT_EXPRESSION = 'price * multiplier';
|
||||
export const CUSTOM_CALC_DEFAULT_EXPRESSION = 'price';
|
||||
|
||||
export const TARGET_FIELD_LABELS: Record<ModifierTargetField, string> = {
|
||||
unitPrice: 'Price',
|
||||
quantity: 'Quantity',
|
||||
subtotal: 'Subtotal'
|
||||
};
|
||||
|
||||
const PREVIEW_ITEM: EstimateItem = {
|
||||
id: '__preview__',
|
||||
description: 'Preview',
|
||||
quantity: 1,
|
||||
unitPrice: 1,
|
||||
markup: 0,
|
||||
markupType: 'percent',
|
||||
contingency: 0,
|
||||
contingencyType: 'percent'
|
||||
};
|
||||
|
||||
type Token =
|
||||
| { type: 'number'; value: number }
|
||||
| { type: 'identifier'; value: string }
|
||||
| { type: 'operator'; value: '+' | '-' | '*' | '/' }
|
||||
| { type: 'paren'; value: '(' | ')' };
|
||||
|
||||
interface ComputedItemState {
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
}
|
||||
|
||||
export interface ComputedModifierStatus {
|
||||
parameters: Record<string, number>;
|
||||
expression: string;
|
||||
targetField: ModifierTargetField;
|
||||
multiplier?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface EffectiveItemPricing {
|
||||
baseQuantity: number;
|
||||
effectiveQuantity: number;
|
||||
baseUnitPrice: number;
|
||||
effectiveUnitPrice: number;
|
||||
baseSubtotal: number;
|
||||
effectiveSubtotal: number;
|
||||
markupAmount: number;
|
||||
contingencyAmount: number;
|
||||
total: number;
|
||||
hasAdjustedQuantity: boolean;
|
||||
hasAdjustedUnitPrice: boolean;
|
||||
hasAdjustedSubtotal: boolean;
|
||||
hasDirectQuantityAdjustment: boolean;
|
||||
hasDirectUnitPriceAdjustment: boolean;
|
||||
hasDirectSubtotalAdjustment: boolean;
|
||||
}
|
||||
|
||||
export interface ItemTotals {
|
||||
subtotal: number;
|
||||
markup: number;
|
||||
contingency: number;
|
||||
total: number;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export const resolveModifierModuleId = (modifier: Modifier) => {
|
||||
if (modifier.moduleId === 'computed-field') {
|
||||
return modifier.presetId === 'custom' ? 'custom-calculation' : 'prevailing-wage';
|
||||
}
|
||||
|
||||
return modifier.moduleId;
|
||||
};
|
||||
|
||||
export const isPrevailingWageModifier = (modifier: Modifier) => resolveModifierModuleId(modifier) === 'prevailing-wage';
|
||||
export const isCustomCalculationModifier = (modifier: Modifier) => resolveModifierModuleId(modifier) === 'custom-calculation';
|
||||
export const isComputedFieldModifier = (modifier: Modifier) => isPrevailingWageModifier(modifier) || isCustomCalculationModifier(modifier);
|
||||
|
||||
export const getResolvedTargetField = (modifier: Modifier): ModifierTargetField => {
|
||||
if (isPrevailingWageModifier(modifier)) return 'unitPrice';
|
||||
return modifier.targetField || 'unitPrice';
|
||||
};
|
||||
|
||||
export const getComputedModifierParameters = (modifier: Modifier) => {
|
||||
const defaults: Record<string, number> = isPrevailingWageModifier(modifier)
|
||||
? PREVAILING_WAGE_DEFAULT_PARAMETERS
|
||||
: {};
|
||||
|
||||
return {
|
||||
...defaults,
|
||||
...(modifier.parameters || {})
|
||||
};
|
||||
};
|
||||
|
||||
export const getModifierExpression = (modifier: Modifier) => {
|
||||
if (isPrevailingWageModifier(modifier)) {
|
||||
return PREVAILING_WAGE_DEFAULT_EXPRESSION;
|
||||
}
|
||||
|
||||
return modifier.expression?.trim() || CUSTOM_CALC_DEFAULT_EXPRESSION;
|
||||
};
|
||||
|
||||
const tokenizeExpression = (expression: string): Token[] => {
|
||||
const tokens: Token[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < expression.length) {
|
||||
const char = expression[index];
|
||||
|
||||
if (/\s/.test(char)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/[+\-*/]/.test(char)) {
|
||||
tokens.push({ type: 'operator', value: char as '+' | '-' | '*' | '/' });
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '(' || char === ')') {
|
||||
tokens.push({ type: 'paren', value: char });
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/\d|\./.test(char)) {
|
||||
let end = index + 1;
|
||||
while (end < expression.length && /[\d.]/.test(expression[end])) end += 1;
|
||||
const raw = expression.slice(index, end);
|
||||
const value = Number.parseFloat(raw);
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new Error(`Invalid number "${raw}".`);
|
||||
}
|
||||
tokens.push({ type: 'number', value });
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/[A-Za-z_]/.test(char)) {
|
||||
let end = index + 1;
|
||||
while (end < expression.length && /[A-Za-z0-9_]/.test(expression[end])) end += 1;
|
||||
tokens.push({ type: 'identifier', value: expression.slice(index, end) });
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported token "${char}".`);
|
||||
}
|
||||
|
||||
return tokens;
|
||||
};
|
||||
|
||||
const evaluateArithmeticExpression = (expression: string, context: Record<string, number>) => {
|
||||
const trimmedExpression = expression.trim();
|
||||
if (!trimmedExpression) {
|
||||
throw new Error('Expression is required.');
|
||||
}
|
||||
|
||||
const tokens = tokenizeExpression(trimmedExpression);
|
||||
let index = 0;
|
||||
|
||||
const peek = () => tokens[index];
|
||||
const consume = () => tokens[index++];
|
||||
|
||||
const parseExpression = (): number => {
|
||||
let value = parseTerm();
|
||||
while (peek()?.type === 'operator' && (peek()?.value === '+' || peek()?.value === '-')) {
|
||||
const operator = consume().value;
|
||||
const nextValue = parseTerm();
|
||||
value = operator === '+' ? value + nextValue : value - nextValue;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const parseTerm = (): number => {
|
||||
let value = parseFactor();
|
||||
while (peek()?.type === 'operator' && (peek()?.value === '*' || peek()?.value === '/')) {
|
||||
const operator = consume().value;
|
||||
const nextValue = parseFactor();
|
||||
if (operator === '/') {
|
||||
if (nextValue === 0) throw new Error('Division by zero is not allowed.');
|
||||
value /= nextValue;
|
||||
} else {
|
||||
value *= nextValue;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const parseFactor = (): number => {
|
||||
const token = peek();
|
||||
if (!token) throw new Error('Unexpected end of expression.');
|
||||
|
||||
if (token.type === 'operator' && token.value === '-') {
|
||||
consume();
|
||||
return -parseFactor();
|
||||
}
|
||||
|
||||
if (token.type === 'number') {
|
||||
consume();
|
||||
return token.value;
|
||||
}
|
||||
|
||||
if (token.type === 'identifier') {
|
||||
consume();
|
||||
const value = context[token.value];
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new Error(`Unknown variable "${token.value}".`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
if (token.type === 'paren' && token.value === '(') {
|
||||
consume();
|
||||
const value = parseExpression();
|
||||
const closing = consume();
|
||||
if (!closing || closing.type !== 'paren' || closing.value !== ')') {
|
||||
throw new Error('Missing closing parenthesis.');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
throw new Error('Unexpected token in expression.');
|
||||
};
|
||||
|
||||
const value = parseExpression();
|
||||
if (index < tokens.length) {
|
||||
throw new Error('Unexpected trailing token.');
|
||||
}
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new Error('Expression did not produce a finite number.');
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const buildModifierContext = (modifier: Modifier, item: EstimateItem, state: ComputedItemState) => {
|
||||
const parameters = getComputedModifierParameters(modifier);
|
||||
const baseRate = parameters.baseRate;
|
||||
const prevailingRate = parameters.prevailingRate;
|
||||
|
||||
if (isPrevailingWageModifier(modifier)) {
|
||||
if (!Number.isFinite(baseRate) || baseRate <= 0) {
|
||||
throw new Error('Base rate must be greater than 0.');
|
||||
}
|
||||
if (!Number.isFinite(prevailingRate)) {
|
||||
throw new Error('Prevailing wage rate must be a valid number.');
|
||||
}
|
||||
}
|
||||
|
||||
const targetField = getResolvedTargetField(modifier);
|
||||
const subtotal = state.quantity * state.unitPrice;
|
||||
const context: Record<string, number> = {
|
||||
value: targetField === 'unitPrice'
|
||||
? state.unitPrice
|
||||
: targetField === 'quantity'
|
||||
? state.quantity
|
||||
: subtotal,
|
||||
price: state.unitPrice,
|
||||
quantity: state.quantity,
|
||||
subtotal,
|
||||
basePrice: item.unitPrice,
|
||||
baseQuantity: item.quantity,
|
||||
baseSubtotal: item.quantity * item.unitPrice,
|
||||
markup: item.markup,
|
||||
contingency: item.contingency,
|
||||
...parameters
|
||||
};
|
||||
|
||||
if (Number.isFinite(baseRate) && baseRate > 0 && Number.isFinite(prevailingRate)) {
|
||||
context.multiplier = prevailingRate / baseRate;
|
||||
}
|
||||
|
||||
return {
|
||||
parameters,
|
||||
targetField,
|
||||
context
|
||||
};
|
||||
};
|
||||
|
||||
const applyComputedModifierToState = (modifier: Modifier, item: EstimateItem, state: ComputedItemState) => {
|
||||
const expression = getModifierExpression(modifier);
|
||||
const { targetField, context } = buildModifierContext(modifier, item, state);
|
||||
const nextValue = evaluateArithmeticExpression(expression, context);
|
||||
|
||||
if (targetField === 'unitPrice') {
|
||||
return {
|
||||
quantity: state.quantity,
|
||||
unitPrice: nextValue
|
||||
};
|
||||
}
|
||||
|
||||
if (targetField === 'quantity') {
|
||||
return {
|
||||
quantity: nextValue,
|
||||
unitPrice: state.unitPrice
|
||||
};
|
||||
}
|
||||
|
||||
if (state.quantity === 0) {
|
||||
if (nextValue === 0) {
|
||||
return {
|
||||
quantity: state.quantity,
|
||||
unitPrice: 0
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Subtotal output requires a non-zero quantity.');
|
||||
}
|
||||
|
||||
return {
|
||||
quantity: state.quantity,
|
||||
unitPrice: nextValue / state.quantity
|
||||
};
|
||||
};
|
||||
|
||||
export const getComputedModifierStatus = (
|
||||
modifier: Modifier,
|
||||
item: EstimateItem = PREVIEW_ITEM,
|
||||
state: ComputedItemState = { quantity: item.quantity, unitPrice: item.unitPrice }
|
||||
): ComputedModifierStatus => {
|
||||
const expression = getModifierExpression(modifier);
|
||||
|
||||
try {
|
||||
const { parameters, targetField, context } = buildModifierContext(modifier, item, state);
|
||||
evaluateArithmeticExpression(expression, context);
|
||||
return {
|
||||
parameters,
|
||||
expression,
|
||||
targetField,
|
||||
multiplier: context.multiplier
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
parameters: getComputedModifierParameters(modifier),
|
||||
expression,
|
||||
targetField: getResolvedTargetField(modifier),
|
||||
error: error instanceof Error ? error.message : 'Invalid expression.'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const getEffectiveItemPricing = (item: EstimateItem, modifiers: Modifier[] = []): EffectiveItemPricing => {
|
||||
let state: ComputedItemState = {
|
||||
quantity: item.quantity,
|
||||
unitPrice: item.unitPrice
|
||||
};
|
||||
const adjustedTargets = new Set<ModifierTargetField>();
|
||||
|
||||
modifiers.forEach((modifier) => {
|
||||
if (!modifier.includeInTotal || !isComputedFieldModifier(modifier)) return;
|
||||
|
||||
try {
|
||||
state = applyComputedModifierToState(modifier, item, state);
|
||||
adjustedTargets.add(getResolvedTargetField(modifier));
|
||||
} catch {
|
||||
// Invalid computed modifiers are treated as inactive for pricing.
|
||||
}
|
||||
});
|
||||
|
||||
const effectiveSubtotal = state.quantity * state.unitPrice;
|
||||
const markupAmount = item.markupType === 'percent'
|
||||
? effectiveSubtotal * (item.markup / 100)
|
||||
: item.markup;
|
||||
const contingencyAmount = item.contingencyType === 'percent'
|
||||
? effectiveSubtotal * (item.contingency / 100)
|
||||
: item.contingency;
|
||||
|
||||
return {
|
||||
baseQuantity: item.quantity,
|
||||
effectiveQuantity: state.quantity,
|
||||
baseUnitPrice: item.unitPrice,
|
||||
effectiveUnitPrice: state.unitPrice,
|
||||
baseSubtotal: item.quantity * item.unitPrice,
|
||||
effectiveSubtotal,
|
||||
markupAmount,
|
||||
contingencyAmount,
|
||||
total: effectiveSubtotal + markupAmount + contingencyAmount,
|
||||
hasAdjustedQuantity: state.quantity !== item.quantity,
|
||||
hasAdjustedUnitPrice: state.unitPrice !== item.unitPrice,
|
||||
hasAdjustedSubtotal: effectiveSubtotal !== item.quantity * item.unitPrice,
|
||||
hasDirectQuantityAdjustment: adjustedTargets.has('quantity'),
|
||||
hasDirectUnitPriceAdjustment: adjustedTargets.has('unitPrice'),
|
||||
hasDirectSubtotalAdjustment: adjustedTargets.has('subtotal')
|
||||
};
|
||||
};
|
||||
|
||||
export const getItemsPricingTotals = (items: EstimateItem[], modifiers: Modifier[] = []): ItemTotals => {
|
||||
return items.reduce<ItemTotals>((acc, item) => {
|
||||
const pricing = getEffectiveItemPricing(item, modifiers);
|
||||
return {
|
||||
subtotal: acc.subtotal + pricing.effectiveSubtotal,
|
||||
markup: acc.markup + pricing.markupAmount,
|
||||
contingency: acc.contingency + pricing.contingencyAmount,
|
||||
total: acc.total + pricing.total,
|
||||
quantity: acc.quantity + pricing.effectiveQuantity
|
||||
};
|
||||
}, {
|
||||
subtotal: 0,
|
||||
markup: 0,
|
||||
contingency: 0,
|
||||
total: 0,
|
||||
quantity: 0
|
||||
});
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { For, createSignal, createMemo, type Component } from 'solid-js';
|
||||
import { FolderOpen, Save, Calculator, Plus } from 'lucide-solid';
|
||||
import type { TemplateItem } from '../types';
|
||||
import type { EstimateItem, TemplateItem } from '../types';
|
||||
import TemplateItemList from '../components/template/TemplateItemList';
|
||||
import ModifierRow from '../components/ModifierRow';
|
||||
import { ModifierRegistry, MODIFIER_MODULES } from '../utils/modifier-registry';
|
||||
@@ -8,6 +8,7 @@ import TemplateLoadModal from '../components/template/TemplateLoadModal';
|
||||
import { pb, COLLECTIONS } from '../utils/db';
|
||||
|
||||
import { appStore } from '../store/appStore';
|
||||
import { getItemsPricingTotals } from '../utils/pricing';
|
||||
|
||||
const TemplateCreator: Component = () => {
|
||||
const templateId = appStore.templateId;
|
||||
@@ -21,9 +22,12 @@ const TemplateCreator: Component = () => {
|
||||
const defaultSubScopeName = appStore.templateSubScopeName;
|
||||
const setDefaultSubScopeName = appStore.setTemplateSubScopeName;
|
||||
|
||||
const templateSubtotal = createMemo(() => {
|
||||
return items.reduce((sum, item) => sum + (item.quantity * item.unitPrice), 0);
|
||||
});
|
||||
const templateEstimateItems = createMemo<EstimateItem[]>(() => items.map(item => ({
|
||||
...item,
|
||||
scopeId: undefined,
|
||||
subScopeId: undefined
|
||||
})));
|
||||
const templateSubtotal = createMemo(() => getItemsPricingTotals(templateEstimateItems(), modifiers).total);
|
||||
|
||||
const [showLoadModal, setShowLoadModal] = createSignal(false);
|
||||
|
||||
@@ -194,7 +198,7 @@ const TemplateCreator: Component = () => {
|
||||
{(modifier) => (
|
||||
<ModifierRow
|
||||
modifier={modifier}
|
||||
calculatedValue={ModifierRegistry.calculateDisplay(modifier, templateSubtotal(), [])}
|
||||
calculatedValue={ModifierRegistry.calculateDisplay(modifier, templateSubtotal(), templateEstimateItems(), modifiers)}
|
||||
onUpdate={appStore.updateTemplateModifier}
|
||||
onDelete={appStore.removeTemplateModifier}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user