Compare commits
3 Commits
f4ad67e097
...
bd65217ddc
| Author | SHA1 | Date | |
|---|---|---|---|
| bd65217ddc | |||
| 229b4b1486 | |||
| 865c4a954c |
@@ -1,10 +1,9 @@
|
|||||||
import type { Component } from 'solid-js';
|
import { createSignal, Show, For, createEffect, createMemo, type Component } from 'solid-js';
|
||||||
import { createSignal, Show, For, createEffect } from 'solid-js';
|
|
||||||
import { Portal } from 'solid-js/web';
|
import { Portal } from 'solid-js/web';
|
||||||
import { X, FileBox, CheckCircle2 } from 'lucide-solid';
|
import { X, FileBox, CheckCircle2, Search } from 'lucide-solid';
|
||||||
import { pb, COLLECTIONS } from '../utils/db';
|
import { pb, COLLECTIONS } from '../utils/db';
|
||||||
import { appStore } from '../store/appStore';
|
import { appStore } from '../store/appStore';
|
||||||
import type { EstimateItem, TemplateItem } from '../types';
|
import type { EstimateItem, TemplateItem, Modifier } from '../types';
|
||||||
|
|
||||||
interface ApplyTemplateModalProps {
|
interface ApplyTemplateModalProps {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
@@ -17,9 +16,36 @@ const ApplyTemplateModal: Component<ApplyTemplateModalProps> = (props) => {
|
|||||||
|
|
||||||
const [templates, setTemplates] = createSignal<any[]>([]);
|
const [templates, setTemplates] = createSignal<any[]>([]);
|
||||||
const [isLoading, setIsLoading] = createSignal(false);
|
const [isLoading, setIsLoading] = createSignal(false);
|
||||||
|
const [searchQuery, setSearchQuery] = createSignal('');
|
||||||
const [targetSubScopeId, setTargetSubScopeId] = createSignal<string>('scope'); // 'scope' means no sub-scope selected
|
const [targetSubScopeId, setTargetSubScopeId] = createSignal<string>('scope'); // 'scope' means no sub-scope selected
|
||||||
const [isApplying, setIsApplying] = createSignal(false);
|
const [isApplying, setIsApplying] = createSignal(false);
|
||||||
|
|
||||||
|
const filteredAndGroupedTemplates = createMemo(() => {
|
||||||
|
const query = searchQuery().toLowerCase();
|
||||||
|
let filtered = templates().filter(t =>
|
||||||
|
t.name.toLowerCase().includes(query) ||
|
||||||
|
(t.defaultSubScopeName || '').toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sort alphabetically by name
|
||||||
|
filtered.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
// Group by defaultSubScopeName
|
||||||
|
const groups: Record<string, any[]> = {};
|
||||||
|
filtered.forEach(t => {
|
||||||
|
const groupName = t.defaultSubScopeName || 'Uncategorized';
|
||||||
|
if (!groups[groupName]) groups[groupName] = [];
|
||||||
|
groups[groupName].push(t);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert to array of groups for sorting groups
|
||||||
|
return Object.entries(groups).sort(([a], [b]) => {
|
||||||
|
if (a === 'Uncategorized') return 1;
|
||||||
|
if (b === 'Uncategorized') return -1;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (props.show) {
|
if (props.show) {
|
||||||
loadTemplates();
|
loadTemplates();
|
||||||
@@ -44,13 +70,21 @@ const ApplyTemplateModal: Component<ApplyTemplateModalProps> = (props) => {
|
|||||||
|
|
||||||
const applyTemplate = (record: any) => {
|
const applyTemplate = (record: any) => {
|
||||||
const templateItems: TemplateItem[] = record.items || [];
|
const templateItems: TemplateItem[] = record.items || [];
|
||||||
if (templateItems.length === 0) {
|
if (templateItems.length === 0 && (record.modifiers || []).length === 0) {
|
||||||
alert('This template has no items.');
|
alert('This template has no items or modifiers.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsApplying(true);
|
setIsApplying(true);
|
||||||
|
|
||||||
|
// Find default sub-scope if target is 'scope' and template has a defaultSubScopeName
|
||||||
|
let finalSubScopeId = targetSubScopeId() !== 'scope' ? targetSubScopeId() : undefined;
|
||||||
|
|
||||||
|
if (!finalSubScopeId && record.defaultSubScopeName) {
|
||||||
|
const match = subScopes().find(ss => ss.name.toLowerCase() === record.defaultSubScopeName.toLowerCase());
|
||||||
|
if (match) finalSubScopeId = match.id;
|
||||||
|
}
|
||||||
|
|
||||||
const newItems: EstimateItem[] = templateItems.map(item => ({
|
const newItems: EstimateItem[] = templateItems.map(item => ({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
description: item.description,
|
description: item.description,
|
||||||
@@ -61,12 +95,27 @@ const ApplyTemplateModal: Component<ApplyTemplateModalProps> = (props) => {
|
|||||||
contingency: item.contingency,
|
contingency: item.contingency,
|
||||||
contingencyType: item.contingencyType,
|
contingencyType: item.contingencyType,
|
||||||
scopeId: props.scopeId || undefined,
|
scopeId: props.scopeId || undefined,
|
||||||
subScopeId: targetSubScopeId() !== 'scope' ? targetSubScopeId() : undefined
|
subScopeId: finalSubScopeId
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Append to existing items
|
// Append to existing items
|
||||||
appStore.setEstimateItems(prev => [...prev, ...newItems]);
|
appStore.setEstimateItems(prev => [...prev, ...newItems]);
|
||||||
|
|
||||||
|
// Copy modifiers if target is (or resolved to) a sub-scope
|
||||||
|
if (finalSubScopeId) {
|
||||||
|
const templateModifiers: Modifier[] = record.modifiers || [];
|
||||||
|
if (templateModifiers.length > 0) {
|
||||||
|
appStore.setSubScopes(
|
||||||
|
ss => ss.id === finalSubScopeId,
|
||||||
|
'modifiers',
|
||||||
|
(existing = []) => [
|
||||||
|
...existing,
|
||||||
|
...templateModifiers.map(m => ({ ...m, id: crypto.randomUUID() }))
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setIsApplying(false);
|
setIsApplying(false);
|
||||||
props.onClose();
|
props.onClose();
|
||||||
};
|
};
|
||||||
@@ -92,50 +141,78 @@ const ApplyTemplateModal: Component<ApplyTemplateModalProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 flex flex-col p-6 gap-6 overflow-hidden">
|
<div class="flex-1 flex flex-col p-6 gap-6 overflow-hidden">
|
||||||
{/* Target Selection */}
|
{/* Target Selection & Search */}
|
||||||
<div class="space-y-3 shrink-0">
|
<div class="flex gap-4 shrink-0">
|
||||||
<label class="text-xs font-black text-muted-foreground uppercase tracking-wider">Target Destination</label>
|
<div class="flex-1 space-y-3">
|
||||||
<select
|
<label class="text-xs font-black text-muted-foreground uppercase tracking-wider">Target Destination</label>
|
||||||
value={targetSubScopeId()}
|
<select
|
||||||
onChange={(e) => setTargetSubScopeId(e.currentTarget.value)}
|
value={targetSubScopeId()}
|
||||||
class="w-full bg-muted/20 border border-border/60 p-3 rounded-2xl text-sm outline-none focus:bg-background focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium appearance-none"
|
onChange={(e) => setTargetSubScopeId(e.currentTarget.value)}
|
||||||
>
|
class="w-full bg-muted/20 border border-border/60 p-3 rounded-2xl text-sm outline-none focus:bg-background focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium appearance-none"
|
||||||
<option value="scope">Scope Level (Default)</option>
|
>
|
||||||
<For each={subScopes()}>
|
<option value="scope">Scope Level (Default)</option>
|
||||||
{(ss) => (
|
<For each={subScopes()}>
|
||||||
<option value={ss.id}>Sub-scope: {ss.name}</option>
|
{(ss) => (
|
||||||
)}
|
<option value={ss.id}>Sub-scope: {ss.name}</option>
|
||||||
</For>
|
)}
|
||||||
</select>
|
</For>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 space-y-3">
|
||||||
|
<label class="text-xs font-black text-muted-foreground uppercase tracking-wider">Search Templates</label>
|
||||||
|
<div class="relative">
|
||||||
|
<Search class="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by name or target..."
|
||||||
|
value={searchQuery()}
|
||||||
|
onInput={(e) => setSearchQuery(e.currentTarget.value)}
|
||||||
|
class="w-full bg-muted/20 border border-border/60 pl-11 pr-4 py-3 rounded-2xl text-sm outline-none focus:bg-background focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all font-medium"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Templates List */}
|
{/* Templates List */}
|
||||||
<div class="flex-1 flex flex-col min-h-0 space-y-3">
|
<div class="flex-1 flex flex-col min-h-0 space-y-3">
|
||||||
<label class="text-xs font-black text-muted-foreground uppercase tracking-wider">Select Template</label>
|
<label class="text-xs font-black text-muted-foreground uppercase tracking-wider">Select Template</label>
|
||||||
<div class="flex-1 overflow-y-auto space-y-3 bg-muted/10 border border-border/50 rounded-2xl p-4">
|
<div class="flex-1 overflow-y-auto space-y-6 bg-muted/10 border border-border/50 rounded-2xl p-4">
|
||||||
<Show
|
<Show
|
||||||
when={!isLoading()}
|
when={!isLoading()}
|
||||||
fallback={<div class="py-12 text-center text-muted-foreground text-sm font-medium">Loading templates...</div>}
|
fallback={<div class="py-12 text-center text-muted-foreground text-sm font-medium">Loading templates...</div>}
|
||||||
>
|
>
|
||||||
<For each={templates()}>
|
<For each={filteredAndGroupedTemplates()}>
|
||||||
{(record) => (
|
{([groupName, groupTemplates]) => (
|
||||||
<div class="group flex items-center justify-between p-4 rounded-xl border border-border/60 bg-card hover:border-primary/50 hover:shadow-premium transition-all">
|
<div class="space-y-3">
|
||||||
<div>
|
<div class="flex items-center gap-2 px-1">
|
||||||
<h4 class="font-bold text-foreground">{record.name}</h4>
|
<span class="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">{groupName}</span>
|
||||||
<p class="text-[10px] text-muted-foreground font-medium uppercase tracking-wider mt-1">
|
<div class="flex-1 h-px bg-border/40"></div>
|
||||||
{record.items?.length || 0} Items • {new Date(record.created).toLocaleDateString()}
|
</div>
|
||||||
</p>
|
<div class="grid gap-3">
|
||||||
|
<For each={groupTemplates}>
|
||||||
|
{(record) => (
|
||||||
|
<div class="group flex items-center justify-between p-4 rounded-xl border border-border/60 bg-card hover:border-primary/50 hover:shadow-premium transition-all">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold text-foreground">{record.name}</h4>
|
||||||
|
<p class="text-[10px] text-muted-foreground font-medium uppercase tracking-wider mt-1">
|
||||||
|
{record.items?.length || 0} Items • {new Date(record.created).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
disabled={isApplying()}
|
||||||
|
onClick={() => applyTemplate(record)}
|
||||||
|
class="flex items-center gap-2 px-4 py-2 bg-primary/10 text-primary border border-primary/20 rounded-lg text-xs font-bold shadow-sm hover:bg-primary hover:text-primary-foreground hover:border-primary transition-all"
|
||||||
|
>
|
||||||
|
<CheckCircle2 class="w-4 h-4" /> Apply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
disabled={isApplying()}
|
|
||||||
onClick={() => applyTemplate(record)}
|
|
||||||
class="flex items-center gap-2 px-4 py-2 bg-primary/10 text-primary border border-primary/20 rounded-lg text-xs font-bold shadow-sm hover:bg-primary hover:text-primary-foreground hover:border-primary transition-all"
|
|
||||||
>
|
|
||||||
<CheckCircle2 class="w-4 h-4" /> Apply
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|
||||||
<Show when={templates().length === 0}>
|
<Show when={templates().length === 0}>
|
||||||
<div class="py-12 flex flex-col items-center justify-center text-center text-muted-foreground/60 border-2 border-dashed border-border/60 rounded-xl bg-background/50 h-full">
|
<div class="py-12 flex flex-col items-center justify-center text-center text-muted-foreground/60 border-2 border-dashed border-border/60 rounded-xl bg-background/50 h-full">
|
||||||
<FileBox class="w-8 h-8 mb-3 opacity-50" />
|
<FileBox class="w-8 h-8 mb-3 opacity-50" />
|
||||||
@@ -143,6 +220,12 @@ const ApplyTemplateModal: Component<ApplyTemplateModalProps> = (props) => {
|
|||||||
<p class="text-xs mt-1">Create one in the Template Creator first.</p>
|
<p class="text-xs mt-1">Create one in the Template Creator first.</p>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
<Show when={templates().length > 0 && filteredAndGroupedTemplates().length === 0}>
|
||||||
|
<div class="py-12 text-center text-muted-foreground/50 italic text-sm">
|
||||||
|
No templates match your search.
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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">
|
<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
|
<PrintEstimate
|
||||||
scopes={scopes}
|
scopes={scopes}
|
||||||
|
subScopes={subScopes}
|
||||||
items={items}
|
items={items}
|
||||||
grandTotals={grandTotals}
|
grandTotals={grandTotals}
|
||||||
clientInfo={clientInfo}
|
clientInfo={clientInfo}
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
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={props.modifier.moduleId === 'man-hours' ? "Factor" : "0.00"}
|
||||||
|
/>
|
||||||
|
<Show when={(props.modifier.valueType === 'unit' && props.modifier.unitLabel) || props.modifier.moduleId === 'man-hours'}>
|
||||||
|
<span class="text-[10px] font-bold text-primary/60">
|
||||||
|
{props.modifier.moduleId === 'man-hours' ? 'factor' : props.modifier.unitLabel}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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 type { Component, Accessor } from 'solid-js';
|
||||||
import { For, Show } 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 { NOTICE_TO_OWNERS } from '../notice-to-owners';
|
||||||
import { COMPANY_NAME, COMPANY_ADDRESS, COMPANY_PHONE, COMPANY_EMAIL } from '../company-info';
|
import { COMPANY_NAME, COMPANY_ADDRESS, COMPANY_PHONE, COMPANY_EMAIL } from '../company-info';
|
||||||
|
import { ModifierRegistry } from '../utils/modifier-registry';
|
||||||
|
|
||||||
interface PrintEstimateProps {
|
interface PrintEstimateProps {
|
||||||
scopes: Scope[];
|
scopes: Scope[];
|
||||||
|
subScopes: SubScope[];
|
||||||
items: EstimateItem[];
|
items: EstimateItem[];
|
||||||
grandTotals: Accessor<{
|
grandTotals: Accessor<{
|
||||||
subtotal: number;
|
subtotal: number;
|
||||||
markup: number;
|
markup: number;
|
||||||
contingency: number;
|
contingency: number;
|
||||||
fees: number;
|
fees: number;
|
||||||
|
modifiersTotal: number;
|
||||||
grandTotal: number;
|
grandTotal: number;
|
||||||
}>;
|
}>;
|
||||||
clientInfo: {
|
clientInfo: {
|
||||||
@@ -51,7 +54,25 @@ const PrintEstimate: Component<PrintEstimateProps> = (props) => {
|
|||||||
? scopeSubtotal * (scope.deliveryFee / 100)
|
? scopeSubtotal * (scope.deliveryFee / 100)
|
||||||
: scope.deliveryFee;
|
: 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 (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { Component } from 'solid-js';
|
import { For, Show, createMemo, createSignal, type Component } from 'solid-js';
|
||||||
import { For, createMemo, Show, createSignal } from 'solid-js';
|
|
||||||
import { Trash2, Plus, Settings2, Percent, DollarSign, ChevronDown, FileText, ClipboardList, FileBox } from 'lucide-solid';
|
import { Trash2, Plus, Settings2, Percent, DollarSign, ChevronDown, FileText, ClipboardList, FileBox } from 'lucide-solid';
|
||||||
import NumericInput from './ui/NumericInput';
|
import NumericInput from './ui/NumericInput';
|
||||||
import type { EstimateItem, Scope, SubScope, PricingType } from '../types';
|
import type { EstimateItem, Scope, SubScope, PricingType } from '../types';
|
||||||
@@ -8,6 +7,7 @@ import SubScopeCard from './SubScopeCard';
|
|||||||
import NoteEditor from './NoteEditor';
|
import NoteEditor from './NoteEditor';
|
||||||
import LazyItem from './LazyItem';
|
import LazyItem from './LazyItem';
|
||||||
import ScopeScaleWrapper from './ui/ScopeScaleWrapper';
|
import ScopeScaleWrapper from './ui/ScopeScaleWrapper';
|
||||||
|
import { ModifierRegistry } from '../utils/modifier-registry';
|
||||||
|
|
||||||
interface ScopeCardProps {
|
interface ScopeCardProps {
|
||||||
scope: Scope;
|
scope: Scope;
|
||||||
@@ -68,14 +68,31 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
|
|||||||
? subtotal * (props.scope.deliveryFee / 100)
|
? subtotal * (props.scope.deliveryFee / 100)
|
||||||
: props.scope.deliveryFee;
|
: 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 {
|
return {
|
||||||
subtotal,
|
subtotal,
|
||||||
markup,
|
markup,
|
||||||
contingency,
|
contingency,
|
||||||
mgmtTotal,
|
mgmtTotal,
|
||||||
deliveryTotal,
|
deliveryTotal,
|
||||||
|
modifiersTotal,
|
||||||
totalQuantity,
|
totalQuantity,
|
||||||
total: subtotal + markup + contingency + mgmtTotal + deliveryTotal
|
total: subtotal + markup + contingency + mgmtTotal + deliveryTotal + modifiersTotal
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -136,12 +153,20 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<div class="flex items-center gap-1.5">
|
||||||
onClick={() => props.onAddSubScope(props.scope.id)}
|
<button
|
||||||
class="flex items-center gap-2 px-4 py-2 bg-primary/10 text-primary rounded-xl text-sm font-bold hover:bg-primary/20 transition-colors border border-primary/20"
|
onClick={() => props.onAddSubScope(props.scope.id)}
|
||||||
>
|
class="flex items-center gap-2 px-3 py-2 bg-primary/10 text-primary rounded-xl text-sm font-bold hover:bg-primary/20 transition-colors border border-primary/20"
|
||||||
<Plus class="mr-2 h-3.5 w-3.5" /> Sub-scope
|
>
|
||||||
</button>
|
<Plus class="h-3.5 w-3.5" /> Sub-scope
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => props.onOpenApplyTemplate(props.scope.id)}
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-indigo-50 text-indigo-600 rounded-xl text-sm font-bold hover:bg-indigo-100 transition-colors border border-indigo-100"
|
||||||
|
>
|
||||||
|
<FileBox class="h-3.5 w-3.5" /> Template
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="h-8 w-px bg-border mx-2"></div>
|
<div class="h-8 w-px bg-border mx-2"></div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowBulkActions(!showBulkActions())}
|
onClick={() => setShowBulkActions(!showBulkActions())}
|
||||||
@@ -190,14 +215,6 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
|
|||||||
Apply
|
Apply
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center ml-auto">
|
|
||||||
<button
|
|
||||||
onClick={() => props.onOpenApplyTemplate(props.scope.id)}
|
|
||||||
class="flex items-center gap-2 px-3 py-1.5 bg-background text-primary border border-primary/30 rounded-lg text-xs font-bold shadow-sm hover:bg-primary hover:text-primary-foreground hover:border-primary transition-all"
|
|
||||||
>
|
|
||||||
<FileBox class="w-3.5 h-3.5" /> Apply Template
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
@@ -377,6 +394,12 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
|
|||||||
<span>Scope Contingency:</span>
|
<span>Scope Contingency:</span>
|
||||||
<span class="w-28 text-right">${formatNumber(totals().contingency)}</span>
|
<span class="w-28 text-right">${formatNumber(totals().contingency)}</span>
|
||||||
</div>
|
</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">
|
<div class="flex justify-end gap-8 font-black text-foreground text-xl mt-4">
|
||||||
<span>Scope Grand Total:</span>
|
<span>Scope Grand Total:</span>
|
||||||
<span class="w-32 text-right border-t-2 border-foreground pt-1">
|
<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, type Component } from 'solid-js';
|
||||||
import { For, createMemo, Show, createSignal } from 'solid-js';
|
import { Trash2, ChevronDown, Copy, ListTree, Plus, Calculator } from 'lucide-solid';
|
||||||
import { Trash2, ChevronDown, Copy, ListTree } from 'lucide-solid';
|
|
||||||
import type { EstimateItem, SubScope, Scope } from '../types';
|
import type { EstimateItem, SubScope, Scope } from '../types';
|
||||||
import ItemRow from './ItemRow';
|
import ItemRow from './ItemRow';
|
||||||
import LazyItem from './LazyItem';
|
import LazyItem from './LazyItem';
|
||||||
|
import ModifierRow from './ModifierRow';
|
||||||
|
import { appStore } from '../store/appStore';
|
||||||
|
import { ModifierRegistry, MODIFIER_MODULES } from '../utils/modifier-registry';
|
||||||
|
|
||||||
interface SubScopeCardProps {
|
interface SubScopeCardProps {
|
||||||
subScope: SubScope;
|
subScope: SubScope;
|
||||||
@@ -30,8 +32,10 @@ const SubScopeCard: Component<SubScopeCardProps> = (props) => {
|
|||||||
const [isOver, setIsOver] = createSignal(false);
|
const [isOver, setIsOver] = createSignal(false);
|
||||||
const [showCopyMenu, setShowCopyMenu] = createSignal(false);
|
const [showCopyMenu, setShowCopyMenu] = createSignal(false);
|
||||||
|
|
||||||
|
const subScopeItems = () => props.items.filter(item => item.subScopeId === props.subScope.id);
|
||||||
|
|
||||||
const totals = createMemo(() => {
|
const totals = createMemo(() => {
|
||||||
return props.items.reduce((acc, item) => {
|
const itemTotals = props.items.reduce((acc, item) => {
|
||||||
const base = item.quantity * item.unitPrice;
|
const base = item.quantity * item.unitPrice;
|
||||||
const markup = item.markupType === 'percent' ? base * (item.markup / 100) : item.markup;
|
const markup = item.markupType === 'percent' ? base * (item.markup / 100) : item.markup;
|
||||||
const contingency = item.contingencyType === 'percent' ? base * (item.contingency / 100) : item.contingency;
|
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
|
quantity: acc.quantity + item.quantity
|
||||||
};
|
};
|
||||||
}, { amount: 0, quantity: 0 });
|
}, { 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) => {
|
const formatNumber = (num: number) => {
|
||||||
@@ -204,11 +222,49 @@ const SubScopeCard: Component<SubScopeCardProps> = (props) => {
|
|||||||
</LazyItem>
|
</LazyItem>
|
||||||
)}
|
)}
|
||||||
</For>
|
</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">
|
<div class="text-center py-4 text-[10px] font-bold text-muted-foreground/40 uppercase tracking-widest italic">
|
||||||
Empty Sub-scope
|
Empty Sub-scope
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Component } from 'solid-js';
|
import { Show, type Component } from 'solid-js';
|
||||||
import { Info, Plus } from 'lucide-solid';
|
import { Info, Plus } from 'lucide-solid';
|
||||||
|
|
||||||
interface ExecutiveSummaryProps {
|
interface ExecutiveSummaryProps {
|
||||||
@@ -7,6 +7,7 @@ interface ExecutiveSummaryProps {
|
|||||||
markup: number;
|
markup: number;
|
||||||
contingency: number;
|
contingency: number;
|
||||||
fees: number;
|
fees: number;
|
||||||
|
modifiersTotal: number;
|
||||||
grandTotal: number;
|
grandTotal: number;
|
||||||
};
|
};
|
||||||
onFinalizeBid: () => void;
|
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>
|
<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 class="text-2xl xl:text-3xl font-black text-foreground tracking-tight truncate">${formatNumber(props.totals.contingency)}</div>
|
||||||
</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">
|
<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>
|
<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>
|
<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 { createMemo } from 'solid-js';
|
||||||
import type { Accessor } from 'solid-js';
|
import type { Accessor } from 'solid-js';
|
||||||
import { appStore } from '../../store/appStore';
|
import { appStore } from '../../store/appStore';
|
||||||
|
import { ModifierRegistry } from '../../utils/modifier-registry';
|
||||||
|
|
||||||
export function useEstimateCalculations(selectedIds: Accessor<string[]>) {
|
export function useEstimateCalculations(selectedIds: Accessor<string[]>) {
|
||||||
const items = appStore.estimateItems;
|
const items = appStore.estimateItems;
|
||||||
@@ -38,12 +39,32 @@ export function useEstimateCalculations(selectedIds: Accessor<string[]>) {
|
|||||||
: scope.deliveryFee;
|
: 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 {
|
return {
|
||||||
subtotal,
|
subtotal,
|
||||||
markup,
|
markup,
|
||||||
contingency,
|
contingency,
|
||||||
fees,
|
fees,
|
||||||
grandTotal: subtotal + markup + contingency + fees
|
modifiersTotal,
|
||||||
|
grandTotal: subtotal + markup + contingency + fees + modifiersTotal
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+72
-2
@@ -1,7 +1,8 @@
|
|||||||
import { createSignal } from 'solid-js';
|
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 { 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
|
// Raw CSV Data
|
||||||
const [activeItems, setActiveItems] = createSignal<ExtractedItem[]>([]);
|
const [activeItems, setActiveItems] = createSignal<ExtractedItem[]>([]);
|
||||||
@@ -39,6 +40,8 @@ const [generalPreNotes, setGeneralPreNotes] = createSignal('');
|
|||||||
// Template Creator State
|
// Template Creator State
|
||||||
const [activeTemplateName, setActiveTemplateName] = createSignal('');
|
const [activeTemplateName, setActiveTemplateName] = createSignal('');
|
||||||
const [activeTemplateItems, setActiveTemplateItems] = createStore<TemplateItem[]>([]);
|
const [activeTemplateItems, setActiveTemplateItems] = createStore<TemplateItem[]>([]);
|
||||||
|
const [activeTemplateModifiers, setActiveTemplateModifiers] = createStore<Modifier[]>([]);
|
||||||
|
const [activeTemplateSubScopeName, setActiveTemplateSubScopeName] = createSignal<string | undefined>(undefined);
|
||||||
const [activeTemplateId, setActiveTemplateId] = createSignal<string | null>(null);
|
const [activeTemplateId, setActiveTemplateId] = createSignal<string | null>(null);
|
||||||
|
|
||||||
export const appStore = {
|
export const appStore = {
|
||||||
@@ -68,6 +71,40 @@ export const appStore = {
|
|||||||
jobInfo, setJobInfo,
|
jobInfo, setJobInfo,
|
||||||
generalEstimateNotes, setGeneralEstimateNotes,
|
generalEstimateNotes, setGeneralEstimateNotes,
|
||||||
generalPreNotes, setGeneralPreNotes,
|
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
|
// Templates
|
||||||
templateId: activeTemplateId,
|
templateId: activeTemplateId,
|
||||||
@@ -76,6 +113,34 @@ export const appStore = {
|
|||||||
setTemplateName: setActiveTemplateName,
|
setTemplateName: setActiveTemplateName,
|
||||||
templateItems: activeTemplateItems,
|
templateItems: activeTemplateItems,
|
||||||
setTemplateItems: setActiveTemplateItems,
|
setTemplateItems: setActiveTemplateItems,
|
||||||
|
templateModifiers: activeTemplateModifiers,
|
||||||
|
setTemplateModifiers: setActiveTemplateModifiers,
|
||||||
|
templateSubScopeName: activeTemplateSubScopeName,
|
||||||
|
setTemplateSubScopeName: setActiveTemplateSubScopeName,
|
||||||
|
|
||||||
|
addTemplateModifier(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,
|
||||||
|
includeInTotal: module.id !== 'man-hours'
|
||||||
|
};
|
||||||
|
setActiveTemplateModifiers(prev => [...prev, newModifier]);
|
||||||
|
},
|
||||||
|
updateTemplateModifier: (modifierId: string, updates: Partial<Modifier>) => {
|
||||||
|
setActiveTemplateModifiers(
|
||||||
|
(m: Modifier) => m.id === modifierId,
|
||||||
|
updates
|
||||||
|
);
|
||||||
|
},
|
||||||
|
removeTemplateModifier: (modifierId: string) => {
|
||||||
|
setActiveTemplateModifiers(prev => prev.filter(mod => mod.id !== modifierId));
|
||||||
|
},
|
||||||
|
|
||||||
resetEstimate: () => {
|
resetEstimate: () => {
|
||||||
setActiveItems([]);
|
setActiveItems([]);
|
||||||
@@ -92,5 +157,10 @@ export const appStore = {
|
|||||||
setJobInfo({ number: '', name: '', address: '', workOrder: '' });
|
setJobInfo({ number: '', name: '', address: '', workOrder: '' });
|
||||||
setGeneralEstimateNotes('');
|
setGeneralEstimateNotes('');
|
||||||
setGeneralPreNotes('');
|
setGeneralPreNotes('');
|
||||||
|
setActiveTemplateName('');
|
||||||
|
setActiveTemplateItems([]);
|
||||||
|
setActiveTemplateModifiers([]);
|
||||||
|
setActiveTemplateSubScopeName(undefined);
|
||||||
|
setActiveTemplateId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,10 +11,24 @@ export interface Scope {
|
|||||||
bidDescription?: string;
|
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 {
|
export interface SubScope {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
scopeId: string;
|
scopeId: string;
|
||||||
|
modifiers?: Modifier[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JobInfo {
|
export interface JobInfo {
|
||||||
@@ -54,4 +68,6 @@ export interface Template {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
items: TemplateItem[];
|
items: TemplateItem[];
|
||||||
|
modifiers?: Modifier[];
|
||||||
|
defaultSubScopeName?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 labor hours by dividing the sub-scope subtotal by a production factor.',
|
||||||
|
defaultLabel: 'Estimated Hours',
|
||||||
|
defaultValue: 30,
|
||||||
|
defaultValueType: 'unit',
|
||||||
|
defaultUnitLabel: 'factor',
|
||||||
|
calculate: () => 0,
|
||||||
|
calculateDisplay: (m, base) => {
|
||||||
|
const factor = m.value || 1;
|
||||||
|
return base / factor;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import type { Component } from 'solid-js';
|
import { For, createSignal, createMemo, type Component } from 'solid-js';
|
||||||
import { FolderOpen, Save } from 'lucide-solid';
|
import { FolderOpen, Save, Calculator, Plus } from 'lucide-solid';
|
||||||
import type { TemplateItem } from '../types';
|
import type { TemplateItem } from '../types';
|
||||||
import TemplateItemList from '../components/template/TemplateItemList';
|
import TemplateItemList from '../components/template/TemplateItemList';
|
||||||
|
import ModifierRow from '../components/ModifierRow';
|
||||||
|
import { ModifierRegistry, MODIFIER_MODULES } from '../utils/modifier-registry';
|
||||||
import TemplateLoadModal from '../components/template/TemplateLoadModal';
|
import TemplateLoadModal from '../components/template/TemplateLoadModal';
|
||||||
import { pb, COLLECTIONS } from '../utils/db';
|
import { pb, COLLECTIONS } from '../utils/db';
|
||||||
import { createSignal } from 'solid-js';
|
|
||||||
|
|
||||||
import { appStore } from '../store/appStore';
|
import { appStore } from '../store/appStore';
|
||||||
|
|
||||||
@@ -15,6 +16,14 @@ const TemplateCreator: Component = () => {
|
|||||||
const setTemplateName = appStore.setTemplateName;
|
const setTemplateName = appStore.setTemplateName;
|
||||||
const items = appStore.templateItems;
|
const items = appStore.templateItems;
|
||||||
const setItems = appStore.setTemplateItems;
|
const setItems = appStore.setTemplateItems;
|
||||||
|
const modifiers = appStore.templateModifiers;
|
||||||
|
const setModifiers = appStore.setTemplateModifiers;
|
||||||
|
const defaultSubScopeName = appStore.templateSubScopeName;
|
||||||
|
const setDefaultSubScopeName = appStore.setTemplateSubScopeName;
|
||||||
|
|
||||||
|
const templateSubtotal = createMemo(() => {
|
||||||
|
return items.reduce((sum, item) => sum + (item.quantity * item.unitPrice), 0);
|
||||||
|
});
|
||||||
|
|
||||||
const [showLoadModal, setShowLoadModal] = createSignal(false);
|
const [showLoadModal, setShowLoadModal] = createSignal(false);
|
||||||
|
|
||||||
@@ -44,21 +53,27 @@ const TemplateCreator: Component = () => {
|
|||||||
setTemplateId(record.id);
|
setTemplateId(record.id);
|
||||||
setTemplateName(record.name);
|
setTemplateName(record.name);
|
||||||
setItems(JSON.parse(JSON.stringify(record.items || [])));
|
setItems(JSON.parse(JSON.stringify(record.items || [])));
|
||||||
|
setModifiers(JSON.parse(JSON.stringify(record.modifiers || [])));
|
||||||
|
setDefaultSubScopeName(record.defaultSubScopeName);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNewTemplate = () => {
|
const handleNewTemplate = () => {
|
||||||
setTemplateId(null);
|
setTemplateId(null);
|
||||||
setTemplateName('');
|
setTemplateName('');
|
||||||
setItems([]);
|
setItems([]);
|
||||||
|
setModifiers([]);
|
||||||
|
setDefaultSubScopeName(undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveTemplate = async () => {
|
const handleSaveTemplate = async () => {
|
||||||
if (!templateName() || items.length === 0) return;
|
if (!templateName() || (items.length === 0 && modifiers.length === 0)) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const record = {
|
const record = {
|
||||||
name: templateName(),
|
name: templateName(),
|
||||||
items: JSON.parse(JSON.stringify(items)) // Ensure plain objects for PocketBase
|
items: JSON.parse(JSON.stringify(items)), // Ensure plain objects for PocketBase
|
||||||
|
modifiers: JSON.parse(JSON.stringify(modifiers)),
|
||||||
|
defaultSubScopeName: defaultSubScopeName()
|
||||||
};
|
};
|
||||||
|
|
||||||
if (templateId()) {
|
if (templateId()) {
|
||||||
@@ -100,7 +115,7 @@ const TemplateCreator: Component = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSaveTemplate}
|
onClick={handleSaveTemplate}
|
||||||
disabled={!templateName() || items.length === 0}
|
disabled={!templateName() || (items.length === 0 && modifiers.length === 0)}
|
||||||
class="flex items-center gap-2 px-6 py-2.5 text-sm font-bold text-white bg-blue-600 rounded-xl hover:bg-blue-700 transition-all shadow-premium hover:shadow-premium-hover disabled:opacity-50 disabled:cursor-not-allowed"
|
class="flex items-center gap-2 px-6 py-2.5 text-sm font-bold text-white bg-blue-600 rounded-xl hover:bg-blue-700 transition-all shadow-premium hover:shadow-premium-hover disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<Save class="w-4 h-4" />
|
<Save class="w-4 h-4" />
|
||||||
@@ -109,17 +124,34 @@ const TemplateCreator: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-8">
|
<div class="flex gap-6 mb-8">
|
||||||
<label class="block text-sm font-semibold text-gray-700 mb-2">
|
<div class="flex-1">
|
||||||
Sub-Scope Name
|
<label class="block text-sm font-semibold text-gray-700 mb-2">
|
||||||
</label>
|
Sub-Scope Name
|
||||||
<input
|
</label>
|
||||||
type="text"
|
<input
|
||||||
value={templateName()}
|
type="text"
|
||||||
onInput={(e) => setTemplateName(e.currentTarget.value)}
|
value={templateName()}
|
||||||
placeholder="e.g. Master Bathroom Rough-in"
|
onInput={(e) => setTemplateName(e.currentTarget.value)}
|
||||||
class="w-full bg-gray-50/50 border border-gray-200 rounded-xl px-4 py-3 text-gray-800 focus:bg-white focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none font-medium transition-all shadow-sm"
|
placeholder="e.g. Master Bathroom Rough-in"
|
||||||
/>
|
class="w-full bg-gray-50/50 border border-gray-200 rounded-xl px-4 py-3 text-gray-800 focus:bg-white focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none font-medium transition-all shadow-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-64">
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 mb-2">
|
||||||
|
Default Target
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={defaultSubScopeName() || ''}
|
||||||
|
onChange={(e) => setDefaultSubScopeName(e.currentTarget.value || undefined)}
|
||||||
|
class="w-full bg-gray-50/50 border border-gray-200 rounded-xl px-4 py-3 text-gray-800 focus:bg-white focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none font-medium transition-all shadow-sm appearance-none"
|
||||||
|
>
|
||||||
|
<option value="">None (Scope Level)</option>
|
||||||
|
<option value="Materials">Materials</option>
|
||||||
|
<option value="Labor">Labor</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TemplateItemList
|
<TemplateItemList
|
||||||
@@ -128,6 +160,54 @@ const TemplateCreator: Component = () => {
|
|||||||
onUpdateItem={handleUpdateItem}
|
onUpdateItem={handleUpdateItem}
|
||||||
onDeleteItem={handleDeleteItem}
|
onDeleteItem={handleDeleteItem}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Modifiers Section */}
|
||||||
|
<div class="mt-12 pt-8 border-t border-gray-100">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="p-2 bg-blue-50 text-blue-600 rounded-xl">
|
||||||
|
<Calculator class="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-bold text-lg text-gray-800">Template Modifiers</h3>
|
||||||
|
<p class="text-xs text-gray-500 font-medium">Standard fees or adjustments for this sub-scope</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<For each={MODIFIER_MODULES}>
|
||||||
|
{(module) => (
|
||||||
|
<button
|
||||||
|
onClick={() => appStore.addTemplateModifier(module.id)}
|
||||||
|
class="flex items-center gap-1.5 px-3 py-1.5 bg-white border border-gray-200 rounded-xl text-[10px] font-black uppercase tracking-wider text-gray-500 hover:text-blue-600 hover:border-blue-300 transition-all group shadow-sm"
|
||||||
|
>
|
||||||
|
<Plus class="w-3.5 h-3.5 transition-transform group-hover:rotate-90" />
|
||||||
|
{module.name}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3 bg-gray-50/50 rounded-2xl p-6 border border-gray-100">
|
||||||
|
<For each={modifiers}>
|
||||||
|
{(modifier) => (
|
||||||
|
<ModifierRow
|
||||||
|
modifier={modifier}
|
||||||
|
calculatedValue={ModifierRegistry.calculateDisplay(modifier, templateSubtotal(), [])}
|
||||||
|
onUpdate={appStore.updateTemplateModifier}
|
||||||
|
onDelete={appStore.removeTemplateModifier}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
|
||||||
|
{modifiers.length === 0 && (
|
||||||
|
<div class="text-center py-8 text-gray-400 text-sm font-medium italic">
|
||||||
|
No modifiers added to this template.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TemplateLoadModal
|
<TemplateLoadModal
|
||||||
|
|||||||
Reference in New Issue
Block a user