basic module setup
This commit is contained in:
@@ -183,6 +183,7 @@ const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
|
||||
<div class="w-full flex flex-col min-h-screen bg-white shadow-2xl relative print:shadow-none print:border-none print:rounded-none">
|
||||
<PrintEstimate
|
||||
scopes={scopes}
|
||||
subScopes={subScopes}
|
||||
items={items}
|
||||
grandTotals={grandTotals}
|
||||
clientInfo={clientInfo}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { Show } 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';
|
||||
|
||||
interface ModifierRowProps {
|
||||
modifier: Modifier;
|
||||
calculatedValue: number;
|
||||
onUpdate: (id: string, updates: Partial<Modifier>) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
const ModifierRow: Component<ModifierRowProps> = (props) => {
|
||||
const module = () => ModifierRegistry.getModule(props.modifier.moduleId);
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
return num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
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}
|
||||
value={props.modifier.name}
|
||||
onInput={(e) => props.onUpdate(props.modifier.id, { name: e.currentTarget.value })}
|
||||
class="bg-transparent border-none outline-none text-sm font-medium text-muted-foreground focus:text-foreground focus:ring-0 py-0.5 w-48"
|
||||
placeholder="Modifier Name"
|
||||
/>
|
||||
|
||||
{/* 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' })}
|
||||
class={`p-1 rounded-md transition-colors text-[10px] font-bold ${props.modifier.valueType === 'percent' ? 'bg-primary/10 text-primary' : 'hover:bg-muted text-muted-foreground'}`}
|
||||
>
|
||||
%
|
||||
</button>
|
||||
<button
|
||||
onClick={() => props.onUpdate(props.modifier.id, { valueType: 'amount' })}
|
||||
class={`p-1 rounded-md transition-colors text-[10px] font-bold ${props.modifier.valueType === 'amount' ? 'bg-primary/10 text-primary' : 'hover:bg-muted text-muted-foreground'}`}
|
||||
>
|
||||
$
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const unit = props.modifier.unitLabel || 'hrs';
|
||||
props.onUpdate(props.modifier.id, { valueType: 'unit', unitLabel: unit });
|
||||
}}
|
||||
class={`px-1.5 py-1 rounded-md transition-colors text-[9px] font-black tracking-tighter ${props.modifier.valueType === 'unit' ? 'bg-primary/10 text-primary' : 'hover:bg-muted text-muted-foreground'}`}
|
||||
>
|
||||
UNIT
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
{/* 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="0.00"
|
||||
/>
|
||||
<Show when={props.modifier.valueType === 'unit' && props.modifier.unitLabel}>
|
||||
<span class="text-[10px] font-bold text-primary/60">{props.modifier.unitLabel}</span>
|
||||
</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'}`}
|
||||
title={props.modifier.includeInTotal ? 'Included' : 'Excluded'}
|
||||
>
|
||||
<Show when={props.modifier.includeInTotal} fallback={<EyeOff class="w-3.5 h-3.5" />}>
|
||||
<Calculator class="w-3.5 h-3.5" />
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 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"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModifierRow;
|
||||
@@ -1,17 +1,20 @@
|
||||
import type { Component, Accessor } from 'solid-js';
|
||||
import { For, Show } from 'solid-js';
|
||||
import type { Scope, EstimateItem, JobInfo } from '../types';
|
||||
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';
|
||||
|
||||
interface PrintEstimateProps {
|
||||
scopes: Scope[];
|
||||
subScopes: SubScope[];
|
||||
items: EstimateItem[];
|
||||
grandTotals: Accessor<{
|
||||
subtotal: number;
|
||||
markup: number;
|
||||
contingency: number;
|
||||
fees: number;
|
||||
modifiersTotal: number;
|
||||
grandTotal: number;
|
||||
}>;
|
||||
clientInfo: {
|
||||
@@ -51,7 +54,25 @@ const PrintEstimate: Component<PrintEstimateProps> = (props) => {
|
||||
? scopeSubtotal * (scope.deliveryFee / 100)
|
||||
: scope.deliveryFee;
|
||||
|
||||
return scopeSubtotal + scopeMarkup + scopeContingency + mgmtFee + deliveryFee;
|
||||
let modifiersTotal = 0;
|
||||
const scopeSubScopes = props.subScopes.filter(ss => ss.scopeId === scopeId);
|
||||
|
||||
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);
|
||||
|
||||
(ss.modifiers || []).forEach(m => {
|
||||
if (!m.includeInTotal) return;
|
||||
modifiersTotal += ModifierRegistry.calculate(m, ssTotalWithMarkup, ssItems);
|
||||
});
|
||||
});
|
||||
|
||||
return scopeSubtotal + scopeMarkup + scopeContingency + mgmtFee + deliveryFee + modifiersTotal;
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { For, createMemo, Show, createSignal } from 'solid-js';
|
||||
import { For, Show, createMemo, createSignal, type Component } from 'solid-js';
|
||||
import { Trash2, Plus, Settings2, Percent, DollarSign, ChevronDown, FileText, ClipboardList, FileBox } from 'lucide-solid';
|
||||
import NumericInput from './ui/NumericInput';
|
||||
import type { EstimateItem, Scope, SubScope, PricingType } from '../types';
|
||||
@@ -8,6 +7,7 @@ import SubScopeCard from './SubScopeCard';
|
||||
import NoteEditor from './NoteEditor';
|
||||
import LazyItem from './LazyItem';
|
||||
import ScopeScaleWrapper from './ui/ScopeScaleWrapper';
|
||||
import { ModifierRegistry } from '../utils/modifier-registry';
|
||||
|
||||
interface ScopeCardProps {
|
||||
scope: Scope;
|
||||
@@ -68,14 +68,31 @@ 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,
|
||||
contingency,
|
||||
mgmtTotal,
|
||||
deliveryTotal,
|
||||
modifiersTotal,
|
||||
totalQuantity,
|
||||
total: subtotal + markup + contingency + mgmtTotal + deliveryTotal
|
||||
total: subtotal + markup + contingency + mgmtTotal + deliveryTotal + modifiersTotal
|
||||
};
|
||||
});
|
||||
|
||||
@@ -377,6 +394,12 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
|
||||
<span>Scope Contingency:</span>
|
||||
<span class="w-28 text-right">${formatNumber(totals().contingency)}</span>
|
||||
</div>
|
||||
<Show when={totals().modifiersTotal !== 0}>
|
||||
<div class="flex justify-end gap-8 text-indigo-600">
|
||||
<span>Scope Modifiers:</span>
|
||||
<span class="w-28 text-right">${formatNumber(totals().modifiersTotal)}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex justify-end gap-8 font-black text-foreground text-xl mt-4">
|
||||
<span>Scope Grand Total:</span>
|
||||
<span class="w-32 text-right border-t-2 border-foreground pt-1">
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { For, createMemo, Show, createSignal } from 'solid-js';
|
||||
import { Trash2, ChevronDown, Copy, ListTree } from 'lucide-solid';
|
||||
import { For, createMemo, Show, createSignal, type Component } from 'solid-js';
|
||||
import { Trash2, ChevronDown, Copy, ListTree, Plus, Calculator } from 'lucide-solid';
|
||||
import type { EstimateItem, SubScope, Scope } from '../types';
|
||||
import ItemRow from './ItemRow';
|
||||
import LazyItem from './LazyItem';
|
||||
import ModifierRow from './ModifierRow';
|
||||
import { appStore } from '../store/appStore';
|
||||
import { ModifierRegistry, MODIFIER_MODULES } from '../utils/modifier-registry';
|
||||
|
||||
interface SubScopeCardProps {
|
||||
subScope: SubScope;
|
||||
@@ -30,8 +32,10 @@ const SubScopeCard: Component<SubScopeCardProps> = (props) => {
|
||||
const [isOver, setIsOver] = createSignal(false);
|
||||
const [showCopyMenu, setShowCopyMenu] = createSignal(false);
|
||||
|
||||
const subScopeItems = () => props.items.filter(item => item.subScopeId === props.subScope.id);
|
||||
|
||||
const totals = createMemo(() => {
|
||||
return props.items.reduce((acc, item) => {
|
||||
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;
|
||||
@@ -40,6 +44,20 @@ const SubScopeCard: Component<SubScopeCardProps> = (props) => {
|
||||
quantity: acc.quantity + item.quantity
|
||||
};
|
||||
}, { amount: 0, quantity: 0 });
|
||||
|
||||
let modifiedAmount = itemTotals.amount;
|
||||
const modifiers = props.subScope.modifiers || [];
|
||||
|
||||
modifiers.forEach(m => {
|
||||
if (!m.includeInTotal) return;
|
||||
modifiedAmount += ModifierRegistry.calculate(m, itemTotals.amount, subScopeItems());
|
||||
});
|
||||
|
||||
return {
|
||||
amount: modifiedAmount,
|
||||
quantity: itemTotals.quantity,
|
||||
baseAmount: itemTotals.amount
|
||||
};
|
||||
});
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
@@ -204,11 +222,49 @@ const SubScopeCard: Component<SubScopeCardProps> = (props) => {
|
||||
</LazyItem>
|
||||
)}
|
||||
</For>
|
||||
<Show when={props.items.length === 0}>
|
||||
|
||||
{/* Modifiers Section */}
|
||||
<Show when={(props.subScope.modifiers?.length || 0) > 0}>
|
||||
<div class="pt-4 pb-2 space-y-1.5">
|
||||
<div class="px-1 text-[10px] font-black text-primary/40 uppercase tracking-[0.2em] mb-3 flex items-center gap-2">
|
||||
<Calculator class="w-3.5 h-3.5" />
|
||||
<span>Sub-scope Fees & Modifiers</span>
|
||||
</div>
|
||||
<For each={props.subScope.modifiers}>
|
||||
{(modifier) => (
|
||||
<ModifierRow
|
||||
modifier={modifier}
|
||||
calculatedValue={ModifierRegistry.calculateDisplay(modifier, totals().baseAmount, subScopeItems())}
|
||||
onUpdate={(id, updates) => appStore.updateModifier(props.subScope.id, id, updates)}
|
||||
onDelete={(id) => appStore.removeModifier(props.subScope.id, id)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.items.length === 0 && (props.subScope.modifiers?.length || 0) === 0}>
|
||||
<div class="text-center py-4 text-[10px] font-bold text-muted-foreground/40 uppercase tracking-widest italic">
|
||||
Empty Sub-scope
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Add Modifier Buttons */}
|
||||
<div class="flex flex-wrap items-center gap-2 mt-4 pt-4 border-t border-border/40">
|
||||
<span class="text-[9px] font-black uppercase tracking-[0.2em] text-muted-foreground/60 ml-1 mr-2">Add Modifier:</span>
|
||||
<For each={MODIFIER_MODULES}>
|
||||
{(module) => (
|
||||
<button
|
||||
onClick={() => appStore.addModifier(props.subScope.id, module.id)}
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 bg-background border border-border rounded-xl text-[10px] font-black uppercase tracking-wider text-muted-foreground hover:text-primary hover:border-primary/30 transition-all group shadow-sm bg-white"
|
||||
title={module.description}
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5 transition-transform group-hover:rotate-90" />
|
||||
{module.name}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { Show, type Component } from 'solid-js';
|
||||
import { Info, Plus } from 'lucide-solid';
|
||||
|
||||
interface ExecutiveSummaryProps {
|
||||
@@ -7,6 +7,7 @@ interface ExecutiveSummaryProps {
|
||||
markup: number;
|
||||
contingency: number;
|
||||
fees: number;
|
||||
modifiersTotal: number;
|
||||
grandTotal: number;
|
||||
};
|
||||
onFinalizeBid: () => void;
|
||||
@@ -40,6 +41,12 @@ export const ExecutiveSummary: Component<ExecutiveSummaryProps> = (props) => {
|
||||
<span class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest truncate">Contingency</span>
|
||||
<div class="text-2xl xl:text-3xl font-black text-foreground tracking-tight truncate">${formatNumber(props.totals.contingency)}</div>
|
||||
</div>
|
||||
<Show when={props.totals.modifiersTotal !== 0}>
|
||||
<div class="bg-card p-6 rounded-3xl border border-border shadow-premium space-y-2 flex flex-col justify-center min-w-0 transition-transform hover:-translate-y-1">
|
||||
<span class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest truncate">Applied Modifiers</span>
|
||||
<div class="text-2xl xl:text-3xl font-black text-indigo-600 tracking-tight truncate">${formatNumber(props.totals.modifiersTotal)}</div>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="bg-card p-6 rounded-3xl border border-border shadow-premium space-y-2 flex flex-col justify-center min-w-0 transition-transform hover:-translate-y-1">
|
||||
<span class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest truncate">Fees & Logistics</span>
|
||||
<div class="text-2xl xl:text-3xl font-black text-foreground tracking-tight truncate">${formatNumber(props.totals.fees)}</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createMemo } from 'solid-js';
|
||||
import type { Accessor } from 'solid-js';
|
||||
import { appStore } from '../../store/appStore';
|
||||
import { ModifierRegistry } from '../../utils/modifier-registry';
|
||||
|
||||
export function useEstimateCalculations(selectedIds: Accessor<string[]>) {
|
||||
const items = appStore.estimateItems;
|
||||
@@ -38,12 +39,32 @@ export function useEstimateCalculations(selectedIds: Accessor<string[]>) {
|
||||
: scope.deliveryFee;
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
(subScope.modifiers || []).forEach(m => {
|
||||
if (!m.includeInTotal) return;
|
||||
modifiersTotal += ModifierRegistry.calculate(m, subScopeTotalWithMarkup, subScopeItems);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
subtotal,
|
||||
markup,
|
||||
contingency,
|
||||
fees,
|
||||
grandTotal: subtotal + markup + contingency + fees
|
||||
modifiersTotal,
|
||||
grandTotal: subtotal + markup + contingency + fees + modifiersTotal
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
+37
-2
@@ -1,7 +1,8 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import { createStore } from 'solid-js/store';
|
||||
import { createStore, produce } from 'solid-js/store';
|
||||
import type { ExtractedItem } from '../utils/csv-parser';
|
||||
import type { TemplateItem, Scope, SubScope, EstimateItem, JobInfo } from '../types';
|
||||
import type { TemplateItem, Scope, SubScope, EstimateItem, JobInfo, Modifier } from '../types';
|
||||
import { ModifierRegistry } from '../utils/modifier-registry';
|
||||
|
||||
// Raw CSV Data
|
||||
const [activeItems, setActiveItems] = createSignal<ExtractedItem[]>([]);
|
||||
@@ -68,6 +69,40 @@ export const appStore = {
|
||||
jobInfo, setJobInfo,
|
||||
generalEstimateNotes, setGeneralEstimateNotes,
|
||||
generalPreNotes, setGeneralPreNotes,
|
||||
// Scopes
|
||||
addModifier(subScopeId: string, moduleId: string) {
|
||||
const module = ModifierRegistry.getModule(moduleId);
|
||||
const newModifier: Modifier = {
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
moduleId: module.id,
|
||||
name: module.defaultLabel,
|
||||
value: module.defaultValue,
|
||||
valueType: module.defaultValueType,
|
||||
unitLabel: module.defaultUnitLabel,
|
||||
type: module.id as any, // Temporary cast
|
||||
includeInTotal: module.id !== 'man-hours'
|
||||
};
|
||||
|
||||
setSubScopes(s => s.id === subScopeId, produce((ss) => {
|
||||
if (!ss.modifiers) ss.modifiers = [];
|
||||
ss.modifiers.push(newModifier);
|
||||
}));
|
||||
},
|
||||
updateModifier: (subScopeId: string, modifierId: string, updates: Partial<Modifier>) => {
|
||||
setSubScopes(
|
||||
s => s.id === subScopeId,
|
||||
'modifiers',
|
||||
(m: Modifier) => m.id === modifierId,
|
||||
updates
|
||||
);
|
||||
},
|
||||
removeModifier: (subScopeId: string, modifierId: string) => {
|
||||
setSubScopes(
|
||||
s => s.id === subScopeId,
|
||||
'modifiers',
|
||||
(m = []) => m.filter(mod => mod.id !== modifierId)
|
||||
);
|
||||
},
|
||||
|
||||
// Templates
|
||||
templateId: activeTemplateId,
|
||||
|
||||
@@ -11,10 +11,24 @@ export interface Scope {
|
||||
bidDescription?: string;
|
||||
}
|
||||
|
||||
export type ModifierType = 'tax' | 'man-hours' | 'custom';
|
||||
|
||||
export interface Modifier {
|
||||
id: string;
|
||||
moduleId: string;
|
||||
name: string;
|
||||
value: number;
|
||||
valueType: 'percent' | 'amount' | 'unit';
|
||||
type: ModifierType;
|
||||
unitLabel?: string; // e.g. "hrs" for man-hours
|
||||
includeInTotal: boolean;
|
||||
}
|
||||
|
||||
export interface SubScope {
|
||||
id: string;
|
||||
name: string;
|
||||
scopeId: string;
|
||||
modifiers?: Modifier[];
|
||||
}
|
||||
|
||||
export interface JobInfo {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Modifier, EstimateItem } from '../types';
|
||||
|
||||
export interface ModifierModule {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
defaultLabel: string;
|
||||
defaultValue: number;
|
||||
defaultValueType: 'percent' | 'amount' | 'unit';
|
||||
defaultUnitLabel?: string;
|
||||
calculate: (modifier: Modifier, subScopeTotalWithMarkup: number, subScopeItems: EstimateItem[]) => number;
|
||||
calculateDisplay?: (modifier: Modifier, subScopeTotalWithMarkup: number, subScopeItems: EstimateItem[]) => number;
|
||||
}
|
||||
|
||||
const TaxModule: ModifierModule = {
|
||||
id: 'tax',
|
||||
name: 'Tax',
|
||||
description: 'Calculates percentage tax on the sub-scope total.',
|
||||
defaultLabel: 'Sales Tax',
|
||||
defaultValue: 0,
|
||||
defaultValueType: 'percent',
|
||||
calculate: (m, base) => {
|
||||
if (m.valueType === 'percent') return base * (m.value / 100);
|
||||
return m.value;
|
||||
}
|
||||
};
|
||||
|
||||
const ManHoursModule: ModifierModule = {
|
||||
id: 'man-hours',
|
||||
name: 'Man-Hours',
|
||||
description: 'Calculates estimated labor hours. Does not affect monetary total.',
|
||||
defaultLabel: 'Estimated Hours',
|
||||
defaultValue: 0,
|
||||
defaultValueType: 'unit',
|
||||
defaultUnitLabel: 'hrs',
|
||||
calculate: () => 0,
|
||||
calculateDisplay: (m, base) => {
|
||||
if (m.valueType === 'percent') return base * (m.value / 100);
|
||||
return m.value;
|
||||
}
|
||||
};
|
||||
|
||||
const SimpleAdjustmentModule: ModifierModule = {
|
||||
id: 'adjustment',
|
||||
name: 'Fixed adjustment',
|
||||
description: 'Adds or subtracts a fixed amount or percentage.',
|
||||
defaultLabel: 'Adjustment',
|
||||
defaultValue: 0,
|
||||
defaultValueType: 'amount',
|
||||
calculate: (m, base) => {
|
||||
if (m.valueType === 'percent') return base * (m.value / 100);
|
||||
return m.value;
|
||||
}
|
||||
};
|
||||
|
||||
export const MODIFIER_MODULES: ModifierModule[] = [
|
||||
TaxModule,
|
||||
ManHoursModule,
|
||||
SimpleAdjustmentModule
|
||||
];
|
||||
|
||||
export const ModifierRegistry = {
|
||||
getModule: (id: string) => MODIFIER_MODULES.find(m => m.id === id) || SimpleAdjustmentModule,
|
||||
|
||||
calculate: (modifier: Modifier, baseTotal: number, items: EstimateItem[]) => {
|
||||
const module = ModifierRegistry.getModule(modifier.moduleId);
|
||||
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);
|
||||
return module.calculate(modifier, baseTotal, items);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user