Compare commits

...

2 Commits

Author SHA1 Message Date
tcardoza a3ac0feddf Added prevailing wage calculator and custom math modifiers
CI / build (push) Has been skipped
CI / deploy (push) Successful in 50s
2026-03-20 15:11:32 -05:00
tcardoza 59c6211bb9 calculator improvements 2026-03-20 12:22:36 -05:00
15 changed files with 1211 additions and 176 deletions
+103 -11
View File
@@ -7,6 +7,8 @@ interface Entry {
type: 'number' | 'field' | 'marker' | 'result';
value?: number;
label?: string;
markerName?: string;
markerTime?: string;
}
import { Portal } from 'solid-js/web';
@@ -17,6 +19,9 @@ const CalculatorPopover: Component = () => {
const [operator, setOperator] = createSignal<'+' | '-' | '*' | '/' | null>(null);
const [currentInput, setCurrentInput] = createSignal('');
const [isHovered, setIsHovered] = createSignal(false);
const [copyToast, setCopyToast] = createSignal(false);
const [editingMarkerId, setEditingMarkerId] = createSignal<string | null>(null);
const [markerDraft, setMarkerDraft] = createSignal('');
// For simplicity in this version, we'll just track a running total
const [runningTotal, setRunningTotal] = createSignal(0);
@@ -90,7 +95,24 @@ const CalculatorPopover: Component = () => {
};
const createMarker = () => {
setEntries(prev => [...prev, { id: crypto.randomUUID(), type: 'marker', label: `Marker ${new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` }]);
const markerTime = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
setEntries(prev => [...prev, { id: crypto.randomUUID(), type: 'marker', markerName: 'Marker', markerTime }]);
};
const startEditingMarker = (entry: Entry) => {
if (entry.type !== 'marker') return;
setEditingMarkerId(entry.id);
setMarkerDraft(entry.markerName || 'Marker');
};
const saveMarkerName = (id: string) => {
const nextName = markerDraft().trim() || 'Marker';
setEntries(prev => prev.map(entry =>
entry.id === id && entry.type === 'marker'
? { ...entry, markerName: nextName }
: entry
));
setEditingMarkerId(null);
};
const clearInput = () => {
@@ -110,6 +132,36 @@ const CalculatorPopover: Component = () => {
return num.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 4 });
};
const getCopyValue = () => {
if (currentInput() !== '') {
return currentInput().replace(/,/g, '');
}
return String(runningTotal()).replace(/,/g, '');
};
const copyDisplayedValue = async () => {
try {
await navigator.clipboard.writeText(getCopyValue());
setCopyToast(true);
window.setTimeout(() => setCopyToast(false), 2000);
} catch (err) {
console.error('Failed to copy calculator value', err);
}
};
const copyEntryValue = async (entry: Entry) => {
if (entry.type === 'marker' || entry.value === undefined) return;
try {
await navigator.clipboard.writeText(String(entry.value).replace(/,/g, ''));
setCopyToast(true);
window.setTimeout(() => setCopyToast(false), 2000);
} catch (err) {
console.error('Failed to copy calculator history value', err);
}
};
// Keyboard support
const handleKeyDown = (e: KeyboardEvent) => {
if (!isOpen()) return;
@@ -177,7 +229,7 @@ const CalculatorPopover: Component = () => {
<div class="fixed bottom-6 right-6 z-[100] no-print">
<Show when={isOpen()}>
<div
class={`absolute bottom-24 right-0 w-[400px] bg-card rounded-[3rem] shadow-elevated border border-border overflow-hidden flex flex-col transition-all animate-in slide-in-from-bottom-8 duration-500 ease-out ${isHovered() ? 'ring-4 ring-primary/20 bg-primary/5' : ''}`}
class={`absolute bottom-24 right-0 w-[400px] max-h-[calc(100vh-12rem)] bg-card rounded-[3rem] shadow-elevated border border-border overflow-hidden flex flex-col transition-all animate-in slide-in-from-bottom-8 duration-500 ease-out ${isHovered() ? 'ring-4 ring-primary/20 bg-primary/5' : ''}`}
onDragOver={(e) => { e.preventDefault(); setIsHovered(true); }}
onDragLeave={() => setIsHovered(false)}
onDrop={handleDrop}
@@ -201,10 +253,10 @@ const CalculatorPopover: Component = () => {
</div>
{/* Display */}
<div class="p-8 bg-muted text-foreground min-h-[220px] flex flex-col justify-end items-end relative overflow-hidden group/display border-b border-border">
<div class="p-8 bg-muted text-foreground min-h-[220px] flex-1 flex flex-col justify-end items-end relative overflow-hidden group/display border-b border-border">
<div class="absolute top-6 left-8 text-[10px] font-black text-muted-foreground/30 uppercase tracking-[0.3em]">Calculation History</div>
<div class="flex flex-col items-end gap-2 mb-4 max-h-56 overflow-y-auto w-full custom-scrollbar pr-2 scroll-smooth">
<div class="flex-1 flex flex-col items-end gap-2 mb-4 min-h-0 overflow-y-auto w-full custom-scrollbar pr-2 scroll-smooth pt-8">
<For each={entries()}>
{(entry) => (
<Show when={entry.type === 'marker'} fallback={
@@ -212,19 +264,51 @@ const CalculatorPopover: Component = () => {
<Show when={entry.label}>
<span class="text-[9px] font-black text-muted-foreground/40 uppercase tracking-wider truncate max-w-[200px] group-hover/entry:text-muted-foreground/60 transition-colors">{entry.label}</span>
</Show>
<div class={`px-3 py-1 rounded-xl text-xs font-black shadow-sm transition-transform group-hover/entry:scale-105 ${entry.type === 'field' ? 'bg-primary text-primary-foreground shadow-primary/20' :
<button
type="button"
onClick={() => copyEntryValue(entry)}
title="Copy value"
class={`px-3 py-1 rounded-xl text-xs font-black shadow-sm transition-transform group-hover/entry:scale-105 ${entry.type === 'field' ? 'bg-primary text-primary-foreground shadow-primary/20' :
entry.type === 'result' ? 'bg-primary/20 text-primary border border-primary/30' : 'bg-background text-foreground/80 border border-border/60'
}`}>
}`}
>
{entry.type === 'result' && <span class="mr-2 opacity-60">=</span>}
{formatNumber(entry.value || 0)}
</div>
</button>
</div>
}>
<div class="w-full flex items-center gap-4 my-4 opacity-50">
<div class="h-px flex-1 bg-gradient-to-r from-transparent to-border"></div>
<span class="text-[9px] font-black text-muted-foreground/60 uppercase tracking-[0.2em] whitespace-nowrap bg-muted px-3 py-1 rounded-full border border-border italic">
{entry.label}
</span>
<Show
when={editingMarkerId() === entry.id}
fallback={
<button
type="button"
onClick={() => startEditingMarker(entry)}
title="Rename marker"
class="text-[9px] font-black text-muted-foreground/60 uppercase tracking-[0.2em] whitespace-nowrap bg-muted px-3 py-1 rounded-full border border-border italic"
>
{(entry.markerName || 'Marker')} {entry.markerTime}
</button>
}
>
<input
type="text"
value={markerDraft()}
onInput={(e) => setMarkerDraft(e.currentTarget.value)}
onBlur={() => saveMarkerName(entry.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
saveMarkerName(entry.id);
} else if (e.key === 'Escape') {
setEditingMarkerId(null);
}
}}
class="text-[9px] font-black text-muted-foreground/80 uppercase tracking-[0.2em] whitespace-nowrap bg-background px-3 py-1 rounded-full border border-primary/30 italic text-center outline-none"
aria-label="Marker name"
autofocus
/>
</Show>
<div class="h-px flex-1 bg-gradient-to-l from-transparent to-border"></div>
</div>
</Show>
@@ -235,12 +319,20 @@ const CalculatorPopover: Component = () => {
</Show>
</div>
<div class="text-4xl xl:text-5xl font-black tracking-tighter mt-2 flex items-baseline gap-3 group-hover/display:scale-105 transition-transform duration-500 origin-right">
<button
type="button"
onClick={copyDisplayedValue}
title="Copy value"
class="text-4xl xl:text-5xl font-black tracking-tighter mt-2 flex items-baseline gap-3 group-hover/display:scale-105 transition-transform duration-500 origin-right text-right"
>
<Show when={currentInput() === '' && entries().length > 0 && entries()[entries().length - 1].type === 'result'}>
<span class="text-primary text-base font-bold uppercase tracking-widest opacity-60">Total</span>
</Show>
<span class="text-primary text-2xl font-medium">$</span>
{currentInput() || formatNumber(runningTotal())}
</button>
<div class="mt-3 text-[10px] font-black uppercase tracking-[0.25em] text-primary/60">
{copyToast() ? 'Copied' : 'Click any number to copy'}
</div>
</div>
+8 -11
View File
@@ -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
});
});
+74 -41
View File
@@ -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,35 +277,49 @@ 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}>
<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}
dataFieldName="price"
step="0.1"
value={props.item.unitPrice}
onUpdate={(val) => props.onUpdate(props.item.id, { unitPrice: val })}
onDragStart={(e) => {
const val = props.item.unitPrice;
const data = { type: 'item-field', itemId: props.item.id, fieldName: 'price', value: val, label: `${props.item.description || 'Unnamed Item'}: Price` };
e.dataTransfer!.setData('application/json', JSON.stringify(data));
e.dataTransfer!.setData('text/plain', String(val));
e.dataTransfer!.effectAllowed = 'copy';
e.stopPropagation();
}}
draggable="true"
disabled={props.anySelected}
class="w-full bg-muted/30 border border-border/60 rounded-xl pl-5 pr-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none disabled:opacity-50 cursor-text font-bold text-foreground/80 transition-all"
placeholder="Price"
allowTextSearch={true}
onTextSearch={handlePriceSearch}
onKeyDown={handlePriceKeyDown}
onTextSearchExit={() => setIsSearchingPrice(false)}
onBlur={() => setTimeout(() => setIsSearchingPrice(false), 150)}
/>
<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}
dataFieldName="price"
step="0.1"
value={props.item.unitPrice}
onUpdate={(val) => props.onUpdate(props.item.id, { unitPrice: val })}
onDragStart={(e) => {
const val = pricing().effectiveUnitPrice;
const data = { type: 'item-field', itemId: props.item.id, fieldName: 'price', value: val, label: `${props.item.description || 'Unnamed Item'}: Price` };
e.dataTransfer!.setData('application/json', JSON.stringify(data));
e.dataTransfer!.setData('text/plain', String(val));
e.dataTransfer!.effectAllowed = 'copy';
e.stopPropagation();
}}
draggable="true"
disabled={props.anySelected}
class="w-full bg-muted/30 border border-border/60 rounded-xl pl-5 pr-2 py-1.5 text-xs text-right focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none disabled:opacity-50 cursor-text font-bold text-foreground/80 transition-all"
placeholder="Price"
allowTextSearch={true}
onTextSearch={handlePriceSearch}
onKeyDown={handlePriceKeyDown}
onTextSearchExit={() => setIsSearchingPrice(false)}
onBlur={() => setTimeout(() => setIsSearchingPrice(false), 150)}
/>
</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>
);
+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"
+20 -15
View File
@@ -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>
)}
+20 -27
View File
@@ -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,
+8 -14
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+63 -12
View File
@@ -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);
}
};
+412
View File
@@ -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
});
};
+84
View File
@@ -0,0 +1,84 @@
import { parseCSV } from './csv-parser';
export interface ParsedStandardPriceRow {
category: string;
name: string;
price: number;
supplier?: string;
}
export interface StandardPriceImportResult {
rows: ParsedStandardPriceRow[];
skipped: number;
}
const HEADER_ALIASES = {
category: new Set(['category', 'group', 'type', 'materialcategory']),
name: new Set(['name', 'item', 'itemname', 'material', 'materialname', 'description', 'product']),
price: new Set(['price', 'unitprice', 'cost', 'amount', 'materialprice']),
supplier: new Set(['supplier', 'vendor'])
};
const normalizeHeader = (value: string) => value.toLowerCase().replace(/[^a-z0-9]/g, '');
const parsePrice = (value: string) => {
const cleaned = value.replace(/[$,\s]/g, '');
const parsed = Number.parseFloat(cleaned);
return Number.isFinite(parsed) ? parsed : null;
};
const getColumnIndex = (headers: string[], aliases: Set<string>) => {
return headers.findIndex(header => aliases.has(normalizeHeader(header)));
};
export function parseStandardPricesCSV(csvText: string, defaultSupplier?: string): StandardPriceImportResult {
const rows = parseCSV(csvText)
.map(row => row.map(cell => cell.trim()))
.filter(row => row.some(cell => cell !== ''));
if (rows.length === 0) {
throw new Error('The CSV file is empty.');
}
const headerRow = rows[0];
const dataRows = rows.slice(1);
const categoryIndex = getColumnIndex(headerRow, HEADER_ALIASES.category);
const nameIndex = getColumnIndex(headerRow, HEADER_ALIASES.name);
const priceIndex = getColumnIndex(headerRow, HEADER_ALIASES.price);
const supplierIndex = getColumnIndex(headerRow, HEADER_ALIASES.supplier);
if (nameIndex === -1 || priceIndex === -1) {
throw new Error('CSV must include item name and price columns.');
}
const parsedRows: ParsedStandardPriceRow[] = [];
let skipped = 0;
for (const row of dataRows) {
const name = row[nameIndex]?.trim() || '';
const price = parsePrice(row[priceIndex] || '');
if (!name || price === null || price < 0) {
skipped += 1;
continue;
}
const category = categoryIndex >= 0 ? row[categoryIndex]?.trim() || 'Uncategorized' : 'Uncategorized';
const supplierFromRow = supplierIndex >= 0 ? row[supplierIndex]?.trim() || '' : '';
const supplier = supplierFromRow || defaultSupplier?.trim() || '';
parsedRows.push({
category,
name,
price,
supplier: supplier || undefined
});
}
return { rows: parsedRows, skipped };
}
export function normalizeStandardPriceKey(name: string, supplier?: string) {
return `${name.trim().toLowerCase()}::${(supplier || '').trim().toLowerCase()}`;
}
+143 -2
View File
@@ -1,9 +1,10 @@
import { createSignal, onMount, For, createMemo, createResource } from 'solid-js';
import { createSignal, onMount, For, createMemo, createResource, Show } from 'solid-js';
import type { Component } from 'solid-js';
import { Trash2, Plus, RefreshCw, Edit2, Save, X, ChevronDown } from 'lucide-solid';
import { Trash2, Plus, RefreshCw, Edit2, Save, X, ChevronDown, Upload } from 'lucide-solid';
import { pb, COLLECTIONS } from '../utils/db';
import type { StandardPrice } from '../types';
import { appStore } from '../store/appStore';
import { normalizeStandardPriceKey, parseStandardPricesCSV } from '../utils/standard-price-import';
const StandardPricing: Component = () => {
const fetchPrices = async () => {
@@ -20,6 +21,10 @@ const StandardPricing: Component = () => {
const [newName, setNewName] = createSignal('');
const [newPrice, setNewPrice] = createSignal<number>(0);
const [newSupplier, setNewSupplier] = createSignal('');
const [importSupplier, setImportSupplier] = createSignal('');
const [isImporting, setIsImporting] = createSignal(false);
const [isImportOpen, setIsImportOpen] = createSignal(false);
const [importSummary, setImportSummary] = createSignal<string | null>(null);
const [editingId, setEditingId] = createSignal<string | null>(null);
const [editCategory, setEditCategory] = createSignal('');
@@ -64,6 +69,80 @@ const StandardPricing: Component = () => {
}
};
const readFileAsText = (file: File) => new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(new Error('Failed to read the selected file.'));
reader.readAsText(file);
});
const handleImportFile = async (event: Event) => {
const input = event.currentTarget as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
setImportSummary(null);
setIsImporting(true);
try {
const csvText = await readFileAsText(file);
const { rows, skipped } = parseStandardPricesCSV(csvText, importSupplier());
if (rows.length === 0) {
throw new Error('No valid pricing rows were found in the CSV.');
}
const existingRecords = await pb.collection(COLLECTIONS.STANDARD_PRICES).getFullList({
fields: 'id,name,supplier',
requestKey: null
});
const existingByKey = new Map(
(existingRecords as unknown as Pick<StandardPrice, 'id' | 'name' | 'supplier'>[]).map(record => [
normalizeStandardPriceKey(record.name, record.supplier),
record.id
])
);
let created = 0;
let updated = 0;
for (const row of rows) {
const record = {
category: row.category,
name: row.name,
price: row.price,
supplier: row.supplier || ''
};
const existingId = existingByKey.get(normalizeStandardPriceKey(row.name, row.supplier));
if (existingId) {
await pb.collection(COLLECTIONS.STANDARD_PRICES).update(existingId, record);
updated += 1;
continue;
}
const createdRecord = await pb.collection(COLLECTIONS.STANDARD_PRICES).create(record);
existingByKey.set(normalizeStandardPriceKey(row.name, row.supplier), createdRecord.id);
created += 1;
}
await refetch();
await appStore.loadSuppliers();
setImportSummary(`Imported ${rows.length} rows: ${created} added, ${updated} replaced${skipped ? `, ${skipped} skipped` : ''}.`);
} catch (error) {
console.error('Error importing standard prices:', error);
const message = error instanceof Error ? error.message : 'Failed to import standard prices.';
setImportSummary(message);
alert(message);
} finally {
setIsImporting(false);
input.value = '';
}
};
const handleEditStart = (price: StandardPrice) => {
setEditingId(price.id);
setEditCategory(price.category);
@@ -193,6 +272,68 @@ const StandardPricing: Component = () => {
</div>
</div>
<div class="bg-gray-50 rounded-xl mb-8 border border-gray-200 overflow-hidden">
<button
onClick={() => setIsImportOpen(!isImportOpen())}
class="w-full flex items-center justify-between gap-4 px-4 py-3 text-left hover:bg-gray-100/80 transition-colors"
>
<div>
<h3 class="text-sm font-bold text-gray-800 uppercase tracking-wider">Import Supplier Pricing CSV</h3>
<p class="text-sm text-gray-500 mt-1">Upload an Excel-style CSV and replace matching item names within the same supplier list.</p>
</div>
<ChevronDown class={`w-5 h-5 text-gray-400 transition-transform ${isImportOpen() ? 'rotate-180' : ''}`} />
</button>
<Show when={isImportOpen()}>
<div class="border-t border-gray-200 bg-white p-4">
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div class="flex-1">
<p class="text-sm text-gray-600">
Required columns: <span class="font-semibold text-gray-800">Item Name</span>, <span class="font-semibold text-gray-800">Price</span>.
Optional columns: <span class="font-semibold text-gray-800">Category</span>, <span class="font-semibold text-gray-800">Supplier</span>.
Expected row order for your guide sheet: <span class="font-semibold text-gray-800">Category, Item Name, Price, Supplier</span>.
If the file has no supplier column, the supplier entered below will be applied to every imported row.
</p>
</div>
<div class="flex flex-col sm:flex-row gap-3 lg:items-end">
<div class="min-w-[220px]">
<label class="block text-[10px] font-black tracking-widest text-zinc-400 mb-1.5 uppercase pl-1">Supplier Override</label>
<div class="relative group/sel">
<input
type="text"
value={importSupplier()}
onInput={(e) => setImportSupplier(e.currentTarget.value)}
placeholder="Used if CSV has no supplier"
list="supplier-suggestions"
class="w-full bg-zinc-50/50 border border-zinc-200/60 rounded-xl px-4 py-2.5 text-sm focus:bg-white focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 outline-none pr-10 transition-all duration-200 shadow-sm shadow-zinc-200/50 placeholder:text-zinc-300"
/>
<div class="absolute right-3.5 top-1/2 -translate-y-1/2 pointer-events-none text-zinc-300 group-hover/sel:text-blue-500 transition-colors">
<ChevronDown class="w-4 h-4" />
</div>
</div>
</div>
<label class={`flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl font-black text-sm transition-all duration-200 h-[46px] ${isImporting() ? 'bg-gray-100 text-gray-500 border border-gray-200 cursor-wait' : 'bg-blue-600 text-white hover:bg-blue-700 hover:shadow-xl hover:shadow-blue-500/25 active:scale-95 active:shadow-inner cursor-pointer shadow-lg shadow-blue-500/20'}`}>
<Upload class={`w-4 h-4 ${isImporting() ? 'animate-pulse' : ''}`} />
<span>{isImporting() ? 'Importing...' : 'Choose CSV'}</span>
<input
type="file"
accept=".csv,text/csv"
onChange={handleImportFile}
disabled={isImporting()}
class="hidden"
/>
</label>
</div>
</div>
<Show when={importSummary()}>
<div class="mt-4 text-sm font-medium text-gray-700 bg-gray-50 border border-gray-200 rounded-xl px-4 py-3">
{importSummary()}
</div>
</Show>
</div>
</Show>
</div>
{/* List of Prices */}
<div>
<h3 class="text-sm font-bold text-gray-800 mb-4 uppercase tracking-wider">Existing Prices</h3>
+9 -5
View File
@@ -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}
/>