From 865c4a954cbcb28e446ea5fab83cb711c8869ba9 Mon Sep 17 00:00:00 2001 From: Timothy Cardoza Date: Mon, 16 Mar 2026 13:38:22 -0500 Subject: [PATCH] basic module setup --- src/components/EstimateBuilder.tsx | 1 + src/components/ModifierRow.tsx | 106 ++++++++++++++++++ src/components/PrintEstimate.tsx | 25 ++++- src/components/ScopeCard.tsx | 29 ++++- src/components/SubScopeCard.tsx | 66 ++++++++++- .../estimate-builder/ExecutiveSummary.tsx | 9 +- .../useEstimateCalculations.ts | 23 +++- src/store/appStore.ts | 39 ++++++- src/types.ts | 14 +++ src/utils/modifier-registry.ts | 75 +++++++++++++ 10 files changed, 373 insertions(+), 14 deletions(-) create mode 100644 src/components/ModifierRow.tsx create mode 100644 src/utils/modifier-registry.ts diff --git a/src/components/EstimateBuilder.tsx b/src/components/EstimateBuilder.tsx index bf0ffec..fd89597 100644 --- a/src/components/EstimateBuilder.tsx +++ b/src/components/EstimateBuilder.tsx @@ -183,6 +183,7 @@ const EstimateBuilder: Component = (props) => {
) => void; + onDelete: (id: string) => void; +} + +const ModifierRow: Component = (props) => { + const module = () => ModifierRegistry.getModule(props.modifier.moduleId); + + const formatNumber = (num: number) => { + return num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + }; + + return ( +
+
+ {/* Name */} + 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 */} +
+ + + +
+
+ +
+ {/* Value Input */} +
+ 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" + /> + + {props.modifier.unitLabel} + +
+ + {/* Calculated Result */} +
+ + {props.modifier.moduleId === 'man-hours' ? '' : '$'}{formatNumber(props.calculatedValue)} + {props.modifier.moduleId === 'man-hours' ? ' hrs' : ''} + + + {/* Active/Draft Toggle */} + +
+ + {/* Delete */} + +
+
+ ); +}; + +export default ModifierRow; diff --git a/src/components/PrintEstimate.tsx b/src/components/PrintEstimate.tsx index 44534b5..0e776bb 100644 --- a/src/components/PrintEstimate.tsx +++ b/src/components/PrintEstimate.tsx @@ -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 = (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 ( diff --git a/src/components/ScopeCard.tsx b/src/components/ScopeCard.tsx index 6745268..d449b0f 100644 --- a/src/components/ScopeCard.tsx +++ b/src/components/ScopeCard.tsx @@ -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 = (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 = (props) => { Scope Contingency: ${formatNumber(totals().contingency)}
+ +
+ Scope Modifiers: + ${formatNumber(totals().modifiersTotal)} +
+
Scope Grand Total: diff --git a/src/components/SubScopeCard.tsx b/src/components/SubScopeCard.tsx index fac5ba6..f90a6a6 100644 --- a/src/components/SubScopeCard.tsx +++ b/src/components/SubScopeCard.tsx @@ -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 = (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 = (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 = (props) => { )} - + + {/* Modifiers Section */} + 0}> +
+
+ + Sub-scope Fees & Modifiers +
+ + {(modifier) => ( + appStore.updateModifier(props.subScope.id, id, updates)} + onDelete={(id) => appStore.removeModifier(props.subScope.id, id)} + /> + )} + +
+
+ +
Empty Sub-scope
+ + {/* Add Modifier Buttons */} +
+ Add Modifier: + + {(module) => ( + + )} + +
diff --git a/src/components/estimate-builder/ExecutiveSummary.tsx b/src/components/estimate-builder/ExecutiveSummary.tsx index 6ddce45..d7ca9a3 100644 --- a/src/components/estimate-builder/ExecutiveSummary.tsx +++ b/src/components/estimate-builder/ExecutiveSummary.tsx @@ -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 = (props) => { Contingency
${formatNumber(props.totals.contingency)}
+ +
+ Applied Modifiers +
${formatNumber(props.totals.modifiersTotal)}
+
+
Fees & Logistics
${formatNumber(props.totals.fees)}
diff --git a/src/components/estimate-builder/useEstimateCalculations.ts b/src/components/estimate-builder/useEstimateCalculations.ts index 3940369..c970d15 100644 --- a/src/components/estimate-builder/useEstimateCalculations.ts +++ b/src/components/estimate-builder/useEstimateCalculations.ts @@ -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) { const items = appStore.estimateItems; @@ -38,12 +39,32 @@ export function useEstimateCalculations(selectedIds: Accessor) { : 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 }; }); diff --git a/src/store/appStore.ts b/src/store/appStore.ts index fb83f1e..7cf60c5 100644 --- a/src/store/appStore.ts +++ b/src/store/appStore.ts @@ -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([]); @@ -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) => { + 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, diff --git a/src/types.ts b/src/types.ts index 2fc3850..d0424d7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 { diff --git a/src/utils/modifier-registry.ts b/src/utils/modifier-registry.ts new file mode 100644 index 0000000..4609b82 --- /dev/null +++ b/src/utils/modifier-registry.ts @@ -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); + } +};