Compare commits

...

2 Commits

Author SHA1 Message Date
tcardoza 001d7ad3c1 Breakup of EstimateBuilder.tsx 2026-03-11 14:44:15 -05:00
tcardoza cd6265e2f2 Templates and export tables 2026-03-11 14:34:10 -05:00
16 changed files with 1551 additions and 840 deletions
+157
View File
@@ -0,0 +1,157 @@
import type { Component } from 'solid-js';
import { createSignal, Show, For, createEffect } from 'solid-js';
import { Portal } from 'solid-js/web';
import { X, FileBox, CheckCircle2 } from 'lucide-solid';
import { pb, COLLECTIONS } from '../utils/db';
import { appStore } from '../store/appStore';
import type { EstimateItem, TemplateItem } from '../types';
interface ApplyTemplateModalProps {
show: boolean;
onClose: () => void;
scopeId: string | null;
}
const ApplyTemplateModal: Component<ApplyTemplateModalProps> = (props) => {
const subScopes = () => appStore.subScopes.filter(ss => ss.scopeId === props.scopeId);
const [templates, setTemplates] = createSignal<any[]>([]);
const [isLoading, setIsLoading] = createSignal(false);
const [targetSubScopeId, setTargetSubScopeId] = createSignal<string>('scope'); // 'scope' means no sub-scope selected
const [isApplying, setIsApplying] = createSignal(false);
createEffect(() => {
if (props.show) {
loadTemplates();
setTargetSubScopeId('scope'); // reset selection
}
});
const loadTemplates = async () => {
setIsLoading(true);
try {
const records = await pb.collection(COLLECTIONS.TEMPLATES).getFullList({
sort: '-created'
});
setTemplates(records);
} catch (err) {
console.error(err);
alert('Failed to load templates.');
} finally {
setIsLoading(false);
}
};
const applyTemplate = (record: any) => {
const templateItems: TemplateItem[] = record.items || [];
if (templateItems.length === 0) {
alert('This template has no items.');
return;
}
setIsApplying(true);
const newItems: EstimateItem[] = templateItems.map(item => ({
id: crypto.randomUUID(),
description: item.description,
quantity: item.quantity,
unitPrice: item.unitPrice,
markup: item.markup,
markupType: item.markupType,
contingency: item.contingency,
contingencyType: item.contingencyType,
scopeId: props.scopeId || undefined,
subScopeId: targetSubScopeId() !== 'scope' ? targetSubScopeId() : undefined
}));
// Append to existing items
appStore.setEstimateItems(prev => [...prev, ...newItems]);
setIsApplying(false);
props.onClose();
};
return (
<Show when={props.show && props.scopeId}>
<Portal>
<div class="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-background/80 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<div class="bg-card w-full max-w-2xl max-h-[85vh] flex flex-col rounded-3xl shadow-premium border border-border overflow-hidden animate-in zoom-in-95 duration-200">
<div class="flex items-center justify-between p-6 border-b border-border">
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 text-primary rounded-xl">
<FileBox class="w-5 h-5" />
</div>
<div>
<h3 class="text-xl font-black">Apply Template</h3>
<p class="text-[10px] uppercase font-bold text-muted-foreground tracking-wider mt-0.5">Append items to scope</p>
</div>
</div>
<button onClick={props.onClose} class="p-2 hover:bg-muted rounded-xl transition-colors">
<X class="w-5 h-5 text-muted-foreground" />
</button>
</div>
<div class="flex-1 flex flex-col p-6 gap-6 overflow-hidden">
{/* Target Selection */}
<div class="space-y-3 shrink-0">
<label class="text-xs font-black text-muted-foreground uppercase tracking-wider">Target Destination</label>
<select
value={targetSubScopeId()}
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()}>
{(ss) => (
<option value={ss.id}>Sub-scope: {ss.name}</option>
)}
</For>
</select>
</div>
{/* Templates List */}
<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>
<div class="flex-1 overflow-y-auto space-y-3 bg-muted/10 border border-border/50 rounded-2xl p-4">
<Show
when={!isLoading()}
fallback={<div class="py-12 text-center text-muted-foreground text-sm font-medium">Loading templates...</div>}
>
<For each={templates()}>
{(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>
<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">
<FileBox class="w-8 h-8 mb-3 opacity-50" />
<p class="text-sm font-medium">No saved templates found.</p>
<p class="text-xs mt-1">Create one in the Template Creator first.</p>
</div>
</Show>
</Show>
</div>
</div>
</div>
</div>
</div>
</Portal>
</Show>
);
};
export default ApplyTemplateModal;
File diff suppressed because it is too large Load Diff
+272
View File
@@ -0,0 +1,272 @@
import type { Component } from 'solid-js';
import { createSignal, Show, For, onMount } from 'solid-js';
import { Portal } from 'solid-js/web';
import { X, Table, Copy, Download, CheckCircle2 } from 'lucide-solid';
import { appStore } from '../store/appStore';
interface ExportTableModalProps {
show: boolean;
onClose: () => void;
}
const AVAILABLE_COLUMNS = [
{ id: 'scope', label: 'Scope' },
{ id: 'subscope', label: 'Subscope' },
{ id: 'description', label: 'Description' },
{ id: 'quantity', label: 'Quantity' },
{ id: 'unitPrice', label: 'Unit Price' },
{ id: 'subtotal', label: 'Subtotal' },
{ id: 'markup', label: 'Markup' },
{ id: 'contingency', label: 'Contingency' },
{ id: 'total', label: 'Total' }
];
const ExportTableModal: Component<ExportTableModalProps> = (props) => {
const scopes = () => appStore.scopes;
const subScopes = () => appStore.subScopes;
const items = () => appStore.estimateItems;
const [selectedScopeIds, setSelectedScopeIds] = createSignal<string[]>([]);
const [selectedSubScopeIds, setSelectedSubScopeIds] = createSignal<string[]>([]);
const [selectedColumns, setSelectedColumns] = createSignal<string[]>(AVAILABLE_COLUMNS.map(c => c.id));
const [copyToast, setCopyToast] = createSignal(false);
// Initialize with all scopes and subscopes selected
onMount(() => {
if (props.show) {
setSelectedScopeIds(scopes().map(s => s.id));
setSelectedSubScopeIds(subScopes().map(s => s.id));
}
});
const toggleScope = (scopeId: string) => {
const isSelected = selectedScopeIds().includes(scopeId);
if (isSelected) {
setSelectedScopeIds(prev => prev.filter(id => id !== scopeId));
// Deselect all its subscopes
const subscopeIds = subScopes().filter(ss => ss.scopeId === scopeId).map(ss => ss.id);
setSelectedSubScopeIds(prev => prev.filter(id => !subscopeIds.includes(id)));
} else {
setSelectedScopeIds(prev => [...prev, scopeId]);
// Select all its subscopes
const subscopeIds = subScopes().filter(ss => ss.scopeId === scopeId).map(ss => ss.id);
setSelectedSubScopeIds(prev => [...new Set([...prev, ...subscopeIds])]);
}
};
const toggleSubScope = (subScopeId: string, scopeId: string) => {
const isSelected = selectedSubScopeIds().includes(subScopeId);
if (isSelected) {
setSelectedSubScopeIds(prev => prev.filter(id => id !== subScopeId));
// If we deselected a subscope, also make sure the parent scope stays selected if we want, or deselect it if 0
// Actually, keep it simple. Just toggle the subscope.
} else {
setSelectedSubScopeIds(prev => [...prev, subScopeId]);
// If the parent scope isn't selected, select it
if (!selectedScopeIds().includes(scopeId)) {
setSelectedScopeIds(prev => [...prev, scopeId]);
}
}
};
const toggleColumn = (columnId: string) => {
setSelectedColumns(prev =>
prev.includes(columnId) ? prev.filter(id => id !== columnId) : [...prev, columnId]
);
};
const generateTableData = () => {
const dataRows: Record<string, any>[] = [];
// Filter items that belong to the selected scopes and subscopes (or unassigned if we handled it, but let's stick to assigned)
// Also handle scope-level items (no subscope) if the scope is selected
const filteredItems = items().filter(item => {
if (item.subScopeId) {
return selectedSubScopeIds().includes(item.subScopeId);
} else if (item.scopeId) {
return selectedScopeIds().includes(item.scopeId);
}
return false; // ignore unassigned for this specific scope/subscope export
});
filteredItems.forEach(item => {
const scope = scopes().find(s => s.id === item.scopeId);
const subScope = subScopes().find(ss => ss.id === item.subScopeId);
const base = item.quantity * item.unitPrice;
const markupAmt = item.markupType === 'percent' ? base * (item.markup / 100) : item.markup;
const contingencyAmt = item.contingencyType === 'percent' ? base * (item.contingency / 100) : item.contingency;
const total = base + markupAmt + contingencyAmt;
dataRows.push({
scope: scope?.name || 'Unassigned',
subscope: subScope?.name || 'Scope Level',
description: item.description,
quantity: item.quantity,
unitPrice: item.unitPrice,
subtotal: base,
markup: markupAmt,
contingency: contingencyAmt,
total: total
});
});
return dataRows;
};
const copyToClipboard = async () => {
const data = generateTableData();
const cols = AVAILABLE_COLUMNS.filter(c => selectedColumns().includes(c.id));
let tsv = cols.map(c => c.label).join('\t') + '\n';
data.forEach(row => {
tsv += cols.map(c => {
const val = row[c.id];
if (typeof val === 'number') {
// For quantities don't add $ sign typically, but we will rely on excel to format it or just give raw numbers
return ['unitPrice', 'subtotal', 'markup', 'contingency', 'total'].includes(c.id)
? `$${val.toFixed(2)}`
: val.toString();
}
return String(val || '').replace(/\t/g, ' ');
}).join('\t') + '\n';
});
try {
await navigator.clipboard.writeText(tsv);
setCopyToast(true);
setTimeout(() => setCopyToast(false), 2000);
} catch (err) {
console.error('Failed to copy', err);
}
};
const downloadCSV = () => {
const data = generateTableData();
const cols = AVAILABLE_COLUMNS.filter(c => selectedColumns().includes(c.id));
let csv = cols.map(c => c.label).join(',') + '\n';
data.forEach(row => {
csv += cols.map(c => {
const val = row[c.id];
const strVal = typeof val === 'number' && ['unitPrice', 'subtotal', 'markup', 'contingency', 'total'].includes(c.id)
? `$${val.toFixed(2)}`
: String(val || '');
return `"${strVal.replace(/"/g, '""')}"`;
}).join(',') + '\n';
});
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `exported-table.csv`;
a.click();
URL.revokeObjectURL(url);
};
return (
<Show when={props.show}>
<Portal>
<div class="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-background/80 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<div class="bg-card w-full max-w-4xl max-h-[85vh] flex flex-col rounded-3xl shadow-premium border border-border overflow-hidden animate-in zoom-in-95 duration-200">
<div class="flex items-center justify-between p-6 border-b border-border">
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 text-primary rounded-xl">
<Table class="w-5 h-5" />
</div>
<h3 class="text-xl font-black">Export Table Output</h3>
</div>
<button onClick={props.onClose} class="p-2 hover:bg-muted rounded-xl transition-colors">
<X class="w-5 h-5 text-muted-foreground" />
</button>
</div>
<div class="flex-1 overflow-y-auto p-6 flex flex-col md:flex-row gap-8">
{/* Scopes & Subscopes Column */}
<div class="flex-1 space-y-4">
<h4 class="text-xs font-black text-muted-foreground uppercase tracking-widest">Include Scopes & Subscopes</h4>
<div class="space-y-3 bg-muted/10 p-4 rounded-2xl border border-border/50 max-h-[50vh] overflow-y-auto">
<For each={scopes()}>
{(scope) => (
<div class="space-y-2">
<label class="flex items-center gap-3 cursor-pointer group" onClick={(e) => { e.preventDefault(); toggleScope(scope.id); }}>
<div class={`w-5 h-5 rounded-md border flex items-center justify-center transition-colors ${selectedScopeIds().includes(scope.id) ? 'bg-primary border-primary text-primary-foreground' : 'border-border group-hover:border-primary/50'}`}>
<Show when={selectedScopeIds().includes(scope.id)}>
<CheckCircle2 class="w-3.5 h-3.5" />
</Show>
</div>
<span class="font-bold">{scope.name}</span>
</label>
<div class="pl-8 space-y-2 border-l border-border/50 ml-2.5">
<For each={subScopes().filter(ss => ss.scopeId === scope.id)}>
{(subScope) => (
<label class="flex items-center gap-3 cursor-pointer group" onClick={(e) => { e.preventDefault(); toggleSubScope(subScope.id, scope.id); }}>
<div class={`w-4 h-4 rounded-[4px] border flex items-center justify-center transition-colors ${selectedSubScopeIds().includes(subScope.id) ? 'bg-primary border-primary text-primary-foreground' : 'border-border group-hover:border-primary/50'}`}>
<Show when={selectedSubScopeIds().includes(subScope.id)}>
<CheckCircle2 class="w-3 h-3" />
</Show>
</div>
<span class="text-sm text-foreground/80">{subScope.name}</span>
</label>
)}
</For>
</div>
</div>
)}
</For>
<Show when={scopes().length === 0}>
<div class="text-sm text-muted-foreground italic">No scopes available.</div>
</Show>
</div>
</div>
{/* Columns Column */}
<div class="flex-1 space-y-4">
<h4 class="text-xs font-black text-muted-foreground uppercase tracking-widest">Include Columns</h4>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 bg-muted/10 p-4 rounded-2xl border border-border/50">
<For each={AVAILABLE_COLUMNS}>
{(col) => (
<label class="flex items-center gap-3 cursor-pointer group" onClick={(e) => { e.preventDefault(); toggleColumn(col.id); }}>
<div class={`w-5 h-5 rounded-md border flex items-center justify-center transition-colors ${selectedColumns().includes(col.id) ? 'bg-primary border-primary text-primary-foreground' : 'border-border group-hover:border-primary/50'}`}>
<Show when={selectedColumns().includes(col.id)}>
<CheckCircle2 class="w-3.5 h-3.5" />
</Show>
</div>
<span class="font-bold text-sm">{col.label}</span>
</label>
)}
</For>
</div>
</div>
</div>
<div class="p-6 border-t border-border flex justify-end gap-3 bg-muted/5">
<button
onClick={copyToClipboard}
class="flex items-center gap-2 px-6 py-2.5 bg-background text-foreground border border-border rounded-xl text-sm font-bold shadow-sm hover:border-primary/50 hover:text-primary transition-all relative"
>
<Copy class="w-4 h-4" /> Copy to Clipboard
<Show when={copyToast()}>
<div class="absolute -top-10 left-1/2 -translate-x-1/2 bg-foreground text-background text-xs px-3 py-1 rounded-lg font-bold shadow-lg animate-in slide-in-from-bottom-2">
Copied!
</div>
</Show>
</button>
<button
onClick={downloadCSV}
class="flex items-center gap-2 px-6 py-2.5 bg-primary text-primary-foreground rounded-xl text-sm font-bold shadow-lg shadow-primary/20 hover:bg-primary/90 transition-all"
>
<Download class="w-4 h-4" /> Download CSV
</button>
</div>
</div>
</div>
</Portal>
</Show>
);
};
export default ExportTableModal;
+10 -1
View File
@@ -1,6 +1,6 @@
import type { Component } from 'solid-js';
import { For, createMemo, Show, createSignal } from 'solid-js';
import { Trash2, Plus, Settings2, Percent, DollarSign, ChevronDown, FileText, ClipboardList } from 'lucide-solid';
import { Trash2, Plus, Settings2, Percent, DollarSign, ChevronDown, FileText, ClipboardList, FileBox } from 'lucide-solid';
import type { EstimateItem, Scope, SubScope, PricingType } from '../types';
import ItemRow from './ItemRow';
import SubScopeCard from './SubScopeCard';
@@ -25,6 +25,7 @@ interface ScopeCardProps {
allScopes: Scope[];
allSubScopes: SubScope[];
onCopySubScopeItems: (sourceSubScopeId: string, targetSubScopeId: string) => void;
onOpenApplyTemplate: (scopeId: string) => void;
isSidebarCollapsed: () => boolean;
selectedIds: string[];
onToggleSelection: (id: string, isMulti: boolean, isShift: boolean) => void;
@@ -187,6 +188,14 @@ const ScopeCard: Component<ScopeCardProps> = (props) => {
Apply
</button>
</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>
</Show>
@@ -0,0 +1,90 @@
import type { Component } from 'solid-js';
import { PanelLeftOpen, PanelLeftClose, Calculator, Plus, FileUp, Upload, Table, Download } from 'lucide-solid';
import { appStore } from '../../store/appStore';
interface BuilderHeaderProps {
isSidebarCollapsed: boolean;
onToggleSidebar: () => void;
onAddScope: () => void;
onReset: () => void;
onImportEstimate: (e: Event) => void;
onShowExportTableModal: () => void;
onExportEstimate: () => void;
onShowLoadModal: () => void;
onShowSaveModal: () => void;
}
export const BuilderHeader: Component<BuilderHeaderProps> = (props) => {
const { scopes, estimateItems: items } = appStore;
return (
<div class="h-16 px-6 border-b border-border bg-background/50 flex items-center justify-between sticky top-0 z-50 backdrop-blur-md">
<div class="flex items-center gap-4">
<button
onClick={props.onToggleSidebar}
class="p-2 hover:bg-accent rounded-xl transition-colors text-muted-foreground shrink-0"
title={props.isSidebarCollapsed ? "Expand Sidebar" : "Collapse Sidebar"}
>
{props.isSidebarCollapsed ? <PanelLeftOpen class="w-5 h-5" /> : <PanelLeftClose class="w-5 h-5" />}
</button>
<div class="flex items-center gap-3">
<div class="p-2 bg-primary rounded-xl text-primary-foreground shadow-md shadow-primary/20 shrink-0">
<Calculator class="w-5 h-5" />
</div>
<div>
<h2 class="text-lg font-black text-foreground tracking-tight leading-none">Estimate <span class="text-primary">Builder</span></h2>
<div class="flex items-center gap-2 text-muted-foreground text-[10px] font-bold uppercase tracking-wider mt-0.5">
<span>{scopes.length} Scopes</span>
<span class="text-border"></span>
<span>{items.length} Items</span>
</div>
</div>
</div>
</div>
<div class="flex items-center gap-2">
<button
onClick={props.onAddScope}
class="flex items-center gap-2 px-4 py-2 bg-foreground text-background rounded-xl text-sm font-bold hover:bg-foreground/90 transition-all shadow-lg shadow-foreground/10 mr-2"
>
<Plus class="w-4 h-4" /> New Scope
</button>
<button
onClick={props.onReset}
class="flex items-center gap-2 px-3 py-2 bg-muted/50 text-primary rounded-xl text-xs font-black hover:bg-primary/10 transition-all border border-border"
>
<FileUp class="w-4 h-4" /> New CSV
</button>
<div class="h-6 w-px bg-border mx-1"></div>
<label class="flex items-center gap-2 px-3 py-2 bg-muted text-muted-foreground rounded-xl text-xs font-bold hover:bg-accent hover:text-accent-foreground transition-all border border-border cursor-pointer">
<Upload class="w-4 h-4" /> Import JSON
<input type="file" accept=".json" class="hidden" onChange={props.onImportEstimate} />
</label>
<button
onClick={props.onShowExportTableModal}
class="flex items-center gap-2 px-3 py-2 bg-muted text-muted-foreground rounded-xl text-xs font-bold hover:bg-accent hover:text-accent-foreground transition-all border border-border mr-2"
>
<Table class="w-4 h-4" /> Export Table
</button>
<button
onClick={props.onExportEstimate}
class="flex items-center gap-2 px-3 py-2 bg-muted text-muted-foreground rounded-xl text-xs font-bold hover:bg-accent hover:text-accent-foreground transition-all border border-border mr-2"
>
<Download class="w-4 h-4" /> Export JSON
</button>
<div class="h-6 w-px bg-border mx-1"></div>
<button
onClick={props.onShowLoadModal}
class="flex items-center gap-2 px-3 py-2 bg-blue-50 text-blue-600 rounded-xl text-xs font-bold hover:bg-blue-100 transition-all border border-blue-200"
>
<Download class="w-4 h-4" /> Load
</button>
<button
onClick={props.onShowSaveModal}
class="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-xl text-sm font-bold hover:bg-primary/90 transition-all shadow-lg shadow-primary/10"
>
<Upload class="w-4 h-4" /> Save
</button>
</div>
</div>
);
};
@@ -0,0 +1,162 @@
import type { Component } from 'solid-js';
import { For } from 'solid-js';
import { Plus, ListTree, ChevronRight } from 'lucide-solid';
import { appStore } from '../../store/appStore';
import type { EstimateItem } from '../../types';
interface BuilderSidebarProps {
isCollapsed: boolean;
unassignedItems: EstimateItem[];
grandTotals: { grandTotal: number };
hoveredScopeId: string | null;
setHoveredScopeId: (id: string | null) => void;
onDrop: (e: DragEvent, scopeId?: string, subScopeId?: string) => void;
onAddScope: () => void;
}
export const BuilderSidebar: Component<BuilderSidebarProps> = (props) => {
const { scopes, subScopes, estimateItems: items } = appStore;
const formatNumber = (num: number) => {
return num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
return (
<aside
class={`h-[calc(100vh-var(--spacing-header))] sticky top-header bg-background border-r border-border flex flex-col transition-all duration-500 ease-in-out relative group/sidebar
${props.isCollapsed ? 'w-0 opacity-0 -translate-x-full overflow-hidden' : 'w-72 opacity-100'}`}
>
<div class="flex-1 overflow-y-auto px-4 py-8">
<div class="mb-4">
<div class="flex items-center justify-between mb-4 px-2">
<span class="text-[10px] font-black text-muted-foreground uppercase tracking-[0.2em]">Estimate Navigator</span>
<button
onClick={props.onAddScope}
class="p-1.5 bg-muted hover:bg-primary/10 text-muted-foreground hover:text-primary rounded-lg transition-all"
>
<Plus class="w-3 h-3" />
</button>
</div>
<nav class="space-y-1">
{/* Unassigned Sidebar Link */}
<div
onDragOver={(e) => {
e.preventDefault();
props.setHoveredScopeId('unassigned');
}}
onDragLeave={() => props.setHoveredScopeId(null)}
onDrop={(e) => props.onDrop(e, undefined, undefined)}
onClick={() => {
const el = document.getElementById('unassigned-section');
if (el) {
const headerOffset = 180;
const elementPosition = el.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
window.scrollTo({
top: offsetPosition,
behavior: 'smooth'
});
}
}}
class={`group flex items-center justify-between px-3 py-3 rounded-xl text-xs font-black uppercase tracking-wider transition-all cursor-pointer border-2 ${props.hoveredScopeId === 'unassigned' ? 'bg-primary/10 border-primary scale-[1.02] shadow-premium-lg shadow-primary/10 text-primary' : (props.unassignedItems.length > 0 ? 'bg-muted/30 text-foreground border-border/50 hover:bg-muted/50' : 'text-muted-foreground border-transparent hover:bg-accent')}`}
>
<span class="flex items-center gap-2">
<ListTree class="w-4 h-4" /> Unassigned
</span>
<span class={`px-2 py-0.5 rounded-lg text-[10px] font-black border transition-colors ${props.hoveredScopeId === 'unassigned' ? 'bg-primary text-primary-foreground border-primary' : 'bg-background text-primary border-border'}`}>
{props.unassignedItems.length}
</span>
</div>
<div class="h-4"></div>
{/* Scopes Sidebar Links */}
<For each={scopes}>
{(scope) => (
<div class="space-y-1">
<div
onDragOver={(e) => {
e.preventDefault();
props.setHoveredScopeId(scope.id);
}}
onDragLeave={() => props.setHoveredScopeId(null)}
onDrop={(e) => props.onDrop(e, scope.id)}
class={`group flex items-center justify-between px-4 py-3 rounded-xl text-sm font-bold transition-all cursor-pointer border-2 ${props.hoveredScopeId === scope.id ? 'bg-primary/10 border-primary scale-[1.02] shadow-premium-lg shadow-primary/10 text-primary-foreground' : 'text-muted-foreground border-transparent hover:bg-card hover:shadow-premium hover:border-border'}`}
onClick={() => {
const el = document.getElementById(`scope-${scope.id}`);
if (el) {
const headerOffset = 180;
const elementPosition = el.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
window.scrollTo({
top: offsetPosition,
behavior: 'smooth'
});
}
}}
>
<span class="truncate pr-2">{scope.name}</span>
<div class="flex items-center gap-2">
<span class={`px-2 py-0.5 rounded-lg text-[10px] font-black border transition-colors ${props.hoveredScopeId === scope.id ? 'bg-primary text-primary-foreground border-primary' : 'bg-muted text-muted-foreground border-border'}`}>
{items.filter(i => i.scopeId === scope.id).length}
</span>
<ChevronRight class={`w-4 h-4 text-muted-foreground/40 group-hover:text-primary transition-colors ${props.hoveredScopeId === scope.id ? 'text-primary' : ''}`} />
</div>
</div>
{/* Nested Sub-scopes in Sidebar */}
<div class="pl-4 space-y-0.5 border-l-2 border-gray-50 ml-6">
<For each={subScopes.filter(ss => ss.scopeId === scope.id)}>
{(subScope) => (
<div
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
props.setHoveredScopeId(subScope.id);
}}
onDragLeave={() => props.setHoveredScopeId(null)}
onDrop={(e) => {
e.stopPropagation();
props.onDrop(e, scope.id, subScope.id);
}}
onClick={(e) => {
e.stopPropagation();
const el = document.getElementById(`subscope-${subScope.id}`);
if (el) {
const headerOffset = 180;
const elementPosition = el.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
window.scrollTo({
top: offsetPosition,
behavior: 'smooth'
});
}
}}
class={`flex items-center justify-between px-3 py-1.5 rounded-lg text-[11px] font-black uppercase tracking-wider transition-all cursor-pointer border ${props.hoveredScopeId === subScope.id ? 'bg-primary/5 border-primary/30 text-primary scale-[1.02]' : 'text-muted-foreground/60 border-transparent hover:bg-muted hover:text-foreground'}`}
>
<span class="truncate">{subScope.name}</span>
<span class="text-[10px] opacity-60 font-medium">
{items.filter(i => i.subScopeId === subScope.id).length}
</span>
</div>
)}
</For>
</div>
</div>
)}
</For>
</nav>
</div>
{/* Sidebar Footer */}
<div class="p-4 border-t border-border bg-muted/30">
<div class="bg-card rounded-2xl p-4 border border-border shadow-sm">
<div class="text-[10px] font-black text-muted-foreground uppercase tracking-widest mb-1">Current Total</div>
<div class="text-xl font-black text-foreground">${formatNumber(props.grandTotals.grandTotal)}</div>
</div>
</div>
</div>
</aside>
);
};
@@ -0,0 +1,75 @@
import type { Component } from 'solid-js';
import { Info, Plus } from 'lucide-solid';
interface ExecutiveSummaryProps {
totals: {
subtotal: number;
markup: number;
contingency: number;
fees: number;
grandTotal: number;
};
onFinalizeBid: () => void;
}
export const ExecutiveSummary: Component<ExecutiveSummaryProps> = (props) => {
const formatNumber = (num: number) => {
return num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
return (
<div class="mt-20 p-10 bg-muted/30 rounded-[3rem] border border-border shadow-inner">
<div class="max-w-7xl mx-auto flex flex-col lg:flex-row gap-10 items-center lg:items-end justify-between">
<div class="flex-1 w-full space-y-8">
<div class="flex items-center gap-4">
<div class="px-4 py-1.5 bg-primary/10 text-primary rounded-full text-[10px] font-black uppercase tracking-[0.2em] border border-primary/20 shadow-sm">
Executive Summary
</div>
<div class="flex-1 h-px bg-border/60"></div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
<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">Project Subtotal</span>
<div class="text-2xl xl:text-3xl font-black text-foreground tracking-tight truncate">${formatNumber(props.totals.subtotal)}</div>
</div>
<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">Total Markup</span>
<div class="text-2xl xl:text-3xl font-black text-primary tracking-tight truncate">${formatNumber(props.totals.markup)}</div>
</div>
<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">Contingency</span>
<div class="text-2xl xl:text-3xl font-black text-foreground tracking-tight truncate">${formatNumber(props.totals.contingency)}</div>
</div>
<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>
</div>
</div>
<div class="flex items-start gap-4 px-6 py-4 bg-primary/5 rounded-2xl border border-primary/10 shadow-sm">
<Info class="w-5 h-5 text-primary/60 shrink-0 mt-0.5" />
<p class="text-xs font-medium text-primary/70 leading-relaxed">
Comprehensive project total including all active scopes, sub-scopes, and unassigned takeoff items.
Pricing is calculated dynamically based on current material/labor allocations.
</p>
</div>
</div>
<div class="shrink-0 text-center lg:text-right group bg-card p-10 rounded-[3rem] border border-primary/10 shadow-elevated min-w-[340px] lg:min-w-[380px] max-w-full transition-all hover:shadow-2xl">
<div class="text-[10px] font-black text-muted-foreground uppercase tracking-[0.4em] mb-3 group-hover:text-primary transition-colors">Total Estimate Value</div>
<div class="text-5xl sm:text-6xl xl:text-7xl font-black text-foreground tracking-tighter transition-all group-hover:scale-[1.02] duration-500 break-words drop-shadow-sm">
<span class="text-2xl sm:text-3xl text-primary font-medium align-top mr-1">$</span>
{formatNumber(props.totals.grandTotal)}
</div>
<div class="mt-8">
<button
onClick={props.onFinalizeBid}
class="w-full py-5 bg-primary hover:bg-primary/90 text-primary-foreground rounded-2xl font-black text-xs uppercase tracking-[0.2em] transition-all shadow-xl shadow-primary/20 active:scale-95 flex items-center justify-center gap-3"
>
<Plus class="w-5 h-5" /> Finalize Bid
</button>
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,44 @@
import type { Component } from 'solid-js';
import { FolderKanban } from 'lucide-solid';
import { appStore } from '../../store/appStore';
export const ProjectDetailsForm: Component = () => {
const { clientInfo, setClientInfo, jobInfo, setJobInfo } = appStore;
return (
<div class="bg-card border border-border rounded-[2.5rem] p-8 shadow-premium transition-all">
<div class="flex items-center gap-3 text-foreground mb-8">
<div class="p-2.5 bg-primary/10 text-primary rounded-2xl shadow-sm">
<FolderKanban class="w-5 h-5" />
</div>
<h3 class="font-black text-xl tracking-tight">Client & Project Details</h3>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-8">
<div class="space-y-6">
<h4 class="text-[10px] font-black text-muted-foreground uppercase tracking-[0.2em] px-1 blur-[0.2px]">Client Information</h4>
<div class="grid gap-3">
<input type="text" value={clientInfo.name} onInput={(e) => setClientInfo('name', e.currentTarget.value)} class="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" placeholder="Client Name" />
<input type="text" value={clientInfo.address} onInput={(e) => setClientInfo('address', e.currentTarget.value)} class="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" placeholder="Client Address" />
<div class="grid grid-cols-2 gap-3">
<input type="text" value={clientInfo.phone} onInput={(e) => setClientInfo('phone', e.currentTarget.value)} class="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" placeholder="Phone" />
<input type="text" value={clientInfo.fax} onInput={(e) => setClientInfo('fax', e.currentTarget.value)} class="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" placeholder="Fax" />
</div>
</div>
</div>
<div class="space-y-6">
<h4 class="text-[10px] font-black text-muted-foreground uppercase tracking-[0.2em] px-1 blur-[0.2px]">Project Information</h4>
<div class="grid gap-3">
<div class="grid grid-cols-2 gap-3">
<input type="text" value={jobInfo.number} onInput={(e) => setJobInfo('number', e.currentTarget.value)} class="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" placeholder="Job Number" />
<input type="text" value={jobInfo.name} onInput={(e) => setJobInfo('name', e.currentTarget.value)} class="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" placeholder="Job Name" />
</div>
<input type="text" value={jobInfo.workOrder} onInput={(e) => setJobInfo('workOrder', e.currentTarget.value)} class="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" placeholder="Work Order Number" />
<input type="text" value={jobInfo.address} onInput={(e) => setJobInfo('address', e.currentTarget.value)} class="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" placeholder="Job Site Address" />
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,128 @@
import type { Component } from 'solid-js';
import { For, Show } from 'solid-js';
import { FolderKanban, Plus, FileText, ChevronDown } from 'lucide-solid';
import ScopeCard from '../ScopeCard';
import NoteEditor from '../NoteEditor';
import type { Scope, SubScope, EstimateItem } from '../../types';
interface ScopesListProps {
scopes: Scope[];
subScopes: SubScope[];
items: EstimateItem[];
isNotesExpanded: boolean;
setIsNotesExpanded: (expanded: boolean) => void;
generalEstimateNotes: string;
setGeneralEstimateNotes: (notes: string) => void;
updateScope: (id: string, updates: Partial<Scope>) => void;
deleteScope: (id: string) => void;
updateItem: (id: string, updates: Partial<EstimateItem>) => void;
deleteItem: (id: string) => void;
addSubScope: (scopeId: string) => void;
updateSubScope: (id: string, updates: Partial<SubScope>) => void;
deleteSubScope: (id: string) => void;
handleDragStart: (e: DragEvent, id: string) => void;
handleDrop: (e: DragEvent, scopeId?: string, subScopeId?: string) => void;
handleBulkUpdate: (scopeId: string, subScopeId: string | undefined, updates: any) => void;
duplicateItem: (id: string) => void;
copySubScopeItems: (sourceSubScopeId: string, targetSubScopeId: string) => void;
setApplyTemplateScopeId: (id: string | null) => void;
isSidebarCollapsed: () => boolean;
selectedIds: string[];
toggleSelection: (id: string, isMulti: boolean, isShift: boolean) => void;
clearSelection: (e?: MouseEvent) => void;
anyDescriptionIsLong: () => boolean;
addScope: () => void;
}
export const ScopesList: Component<ScopesListProps> = (props) => {
return (
<div class="space-y-12">
<For each={props.scopes}>
{(scope) => (
<div id={`scope-${scope.id}`}>
<ScopeCard
scope={scope}
subScopes={props.subScopes.filter(ss => ss.scopeId === scope.id)}
items={props.items.filter(i => i.scopeId === scope.id)}
onUpdateScope={props.updateScope}
onDeleteScope={props.deleteScope}
onUpdateItem={props.updateItem}
onDeleteItem={props.deleteItem}
onAddSubScope={props.addSubScope}
onUpdateSubScope={props.updateSubScope}
onDeleteSubScope={props.deleteSubScope}
onDragStart={props.handleDragStart}
onDrop={props.handleDrop}
onBulkUpdate={props.handleBulkUpdate}
onDuplicateItem={props.duplicateItem}
allScopes={props.scopes}
allSubScopes={props.subScopes}
onCopySubScopeItems={props.copySubScopeItems}
onOpenApplyTemplate={props.setApplyTemplateScopeId}
isSidebarCollapsed={props.isSidebarCollapsed}
selectedIds={props.selectedIds}
onToggleSelection={props.toggleSelection}
onClearSelection={props.clearSelection}
hideColumns={props.anyDescriptionIsLong()}
/>
</div>
)}
</For>
<Show when={props.scopes.length > 0}>
<div class="mt-8 bg-card border border-border rounded-3xl overflow-hidden shadow-premium transition-all duration-300">
<button
onClick={() => props.setIsNotesExpanded(!props.isNotesExpanded)}
class="w-full flex items-center justify-between p-6 hover:bg-muted/30 transition-colors"
>
<div class="flex items-center gap-3 text-foreground">
<div class="p-2 bg-muted rounded-xl shadow-sm">
<FileText class="w-5 h-5 text-muted-foreground" />
</div>
<div class="text-left">
<h3 class="font-black text-lg">General Estimate Notes</h3>
<p class="text-muted-foreground text-xs font-bold uppercase tracking-wider">Configure standard project terms and exclusions</p>
</div>
</div>
<div class={`transition-transform duration-300 ${props.isNotesExpanded ? 'rotate-180' : ''}`}>
<ChevronDown class="w-5 h-5 text-muted-foreground" />
</div>
</button>
<div class={`transition-all duration-300 ease-in-out ${props.isNotesExpanded ? 'max-h-[2000px] border-t border-gray-50' : 'max-h-0'}`}>
<div class="p-6 pt-2">
<NoteEditor
label="Inclusions, Exclusions & Terms"
value={props.generalEstimateNotes}
onUpdate={props.setGeneralEstimateNotes}
icon={FileText}
placeholder="Add project-wide notes here..."
highlight={props.generalEstimateNotes.includes('#')}
/>
</div>
</div>
</div>
</Show>
<Show when={props.scopes.length === 0}>
<div class="flex flex-col items-center justify-center py-20 text-center space-y-6">
<div class="p-8 bg-muted rounded-full text-muted-foreground/30 shadow-inner">
<FolderKanban class="w-16 h-16" />
</div>
<div class="space-y-2">
<h3 class="text-2xl font-black text-foreground tracking-tight">No Scopes Defined</h3>
<p class="text-muted-foreground max-w-sm mx-auto font-medium">Click "New Scope" in the header to start building your estimate structure.</p>
</div>
<button
onClick={props.addScope}
class="flex items-center gap-2 px-8 py-4 bg-primary text-primary-foreground rounded-2xl font-black text-xs uppercase tracking-widest hover:bg-primary/90 transition-all shadow-xl shadow-primary/20 active:scale-95"
>
<Plus class="w-5 h-5" /> Start First Scope
</button>
</div>
</Show>
</div>
);
};
@@ -0,0 +1,73 @@
import type { Component } from 'solid-js';
import { For, Show } from 'solid-js';
import { Portal } from 'solid-js/web';
import { X } from 'lucide-solid';
interface SelectionToolbarProps {
selectedIds: string[];
selectionTotals: {
quantity: number;
unitPrice: number;
subtotal: number;
markup: number;
contingency: number;
total: number;
};
onClearSelection: () => void;
onCopyColumnSum: (column: 'quantity'|'unitPrice'|'subtotal'|'markup'|'contingency'|'total') => void;
}
export const SelectionToolbar: Component<SelectionToolbarProps> = (props) => {
const formatNumber = (num: number) => {
return num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
return (
<Show when={props.selectedIds.length >= 2}>
<Portal>
<div class="fixed bottom-8 left-1/2 -translate-x-1/2 z-[100] flex items-center gap-3 bg-card/95 backdrop-blur-md text-foreground px-5 py-4 rounded-[2rem] shadow-elevated border border-border animate-in fade-in slide-in-from-bottom-6 duration-500">
<div class="flex flex-col items-center justify-center pr-2 border-r border-border">
<span class="text-[10px] font-black uppercase tracking-widest text-primary mb-0.5">
{props.selectedIds.length}
</span>
<span class="text-[8px] font-black uppercase tracking-tighter text-muted-foreground">Selected</span>
</div>
<div class="flex items-center gap-2">
<For each={[
{ key: 'quantity', label: 'Qty' },
{ key: 'unitPrice', label: 'Price' },
{ key: 'subtotal', label: 'Sub' },
{ key: 'markup', label: 'Mark' },
{ key: 'contingency', label: 'Cont' },
{ key: 'total', label: 'Total' },
] as const}>
{(item) => (
<button
onClick={() => props.onCopyColumnSum(item.key)}
class="flex flex-col items-center gap-0 px-4 py-2 rounded-2xl bg-muted/40 hover:bg-primary hover:text-primary-foreground text-foreground transition-all border border-border/40 hover:border-primary hover:-translate-y-1 group relative active:scale-95"
>
<span class="text-[8px] font-black uppercase tracking-tighter text-muted-foreground group-hover:text-primary-foreground/60 transition-colors">{item.label}</span>
<span class="text-xs font-black mt-[-1px] tracking-tight">
{item.key === 'quantity' ? props.selectionTotals[item.key] : `$${formatNumber(props.selectionTotals[item.key])}`}
</span>
{/* Simple hover tooltip hint */}
<div class="absolute -top-8 left-1/2 -translate-x-1/2 bg-foreground text-background text-[8px] font-black px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none uppercase tracking-widest">
Copy
</div>
</button>
)}
</For>
</div>
<div class="w-px h-8 bg-border mx-1" />
<button
onClick={props.onClearSelection}
class="p-3 rounded-2xl hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-all active:scale-90"
title="Clear selection"
>
<X class="w-5 h-5" />
</button>
</div>
</Portal>
</Show>
);
};
@@ -0,0 +1,84 @@
import type { Component, Accessor } from 'solid-js';
import { Show, For } from 'solid-js';
import { ListTree } from 'lucide-solid';
import ItemRow from '../ItemRow';
import LazyItem from '../LazyItem';
import type { EstimateItem } from '../../types';
interface UnassignedItemsSectionProps {
unassignedItems: EstimateItem[];
isSidebarCollapsed: Accessor<boolean>;
anyDescriptionIsLong: Accessor<boolean>;
selectedIds: string[];
onDrop: (e: DragEvent, scopeId?: string, subScopeId?: string) => void;
clearSelection: (e: MouseEvent) => void;
updateItem: (id: string, updates: Partial<EstimateItem>) => void;
deleteItem: (id: string) => void;
handleDragStart: (e: DragEvent, id: string) => void;
duplicateItem: (id: string) => void;
toggleSelection: (id: string, isMulti: boolean, isShift: boolean) => void;
}
export const UnassignedItemsSection: Component<UnassignedItemsSectionProps> = (props) => {
return (
<Show when={props.unassignedItems.length > 0}>
<div
id="unassigned-section"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => props.onDrop(e, undefined, undefined)}
onClick={props.clearSelection}
class="bg-muted/10 border-2 border-dashed border-border rounded-3xl p-6 transition-all hover:bg-muted/20"
>
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3 text-foreground">
<div class="p-2 bg-primary/10 rounded-xl text-primary shadow-sm">
<ListTree class="w-5 h-5" />
</div>
<div>
<h3 class="font-black text-lg tracking-tight">Unassigned Takeoff Items</h3>
<p class="text-muted-foreground text-sm">Drag these items into scopes to begin your estimate.</p>
</div>
</div>
<span class="px-4 py-1.5 bg-card border border-border text-primary rounded-full text-[10px] font-black uppercase tracking-widest shadow-sm">
{props.unassignedItems.length} Items Pending
</span>
</div>
{/* Sticky Column Headers for Unassigned */}
<div class="sticky top-header z-10 bg-background/95 backdrop-blur-sm border-b border-border py-3 mb-4 hidden md:flex items-center gap-4 text-[10px] font-black text-muted-foreground/60 uppercase tracking-[0.2em] px-3">
<div class="w-4"></div>
<div class="flex-1">Description</div>
<div class="w-20 text-right">Qty</div>
<div class="w-24 text-right">Unit Price</div>
<div class="w-24 text-right">Subtotal</div>
<Show when={props.isSidebarCollapsed() || !props.anyDescriptionIsLong()}>
<div class="w-32 text-center">Markup</div>
<div class="w-32 text-center">Contingency</div>
</Show>
<div class="w-28 text-right px-2">Total</div>
<div class="w-16"></div>
</div>
<div class="grid gap-2">
<For each={props.unassignedItems}>
{(item) => (
<LazyItem estimatedHeight={60}>
<ItemRow
item={item}
onUpdate={props.updateItem}
onDelete={props.deleteItem}
onDragStart={props.handleDragStart}
onDuplicateItem={props.duplicateItem}
isSidebarCollapsed={props.isSidebarCollapsed}
isSelected={props.selectedIds.includes(item.id)}
anySelected={props.selectedIds.length > 0}
onToggleSelection={(id, isMulti, isShift) => props.toggleSelection(id, isMulti, isShift)}
hideColumns={props.anyDescriptionIsLong()}
/>
</LazyItem>
)}
</For>
</div>
</div>
</Show>
);
};
@@ -0,0 +1,39 @@
import type { Component } from 'solid-js';
import { Show } from 'solid-js';
import { AlertTriangle, CheckCircle2 } from 'lucide-solid';
interface ValidationStatusBarProps {
validationStats: {
hasIncomplete: boolean;
incompleteCount: number;
};
}
export const ValidationStatusBar: Component<ValidationStatusBarProps> = (props) => {
return (
<div class={`mt-12 p-5 rounded-3xl border shadow-premium transition-all duration-500 flex items-center justify-between ${props.validationStats.hasIncomplete ? 'bg-destructive/5 border-destructive/20 text-destructive' : 'bg-primary/5 border-primary/20 text-primary'}`}>
<div class="flex items-center gap-4">
<div class={`p-3 rounded-2xl shadow-sm ${props.validationStats.hasIncomplete ? 'bg-destructive/20' : 'bg-primary/20'}`}>
{props.validationStats.hasIncomplete ? <AlertTriangle class="w-5 h-5" /> : <CheckCircle2 class="w-5 h-5" />}
</div>
<div>
<p class="font-black text-sm uppercase tracking-tight">
{props.validationStats.hasIncomplete
? `Attention Required: ${props.validationStats.incompleteCount} fields contain unfinalized information (#)`
: "All notes have been updated."}
</p>
<p class={`text-[10px] font-bold uppercase tracking-widest opacity-80 mt-0.5`}>
{props.validationStats.hasIncomplete
? "Please replace all '#' markers with final project data before finalizing."
: "Everything looks good to go!"}
</p>
</div>
</div>
<Show when={!props.validationStats.hasIncomplete}>
<div class="px-4 py-2 bg-primary text-primary-foreground rounded-xl text-[10px] font-black uppercase tracking-wider shadow-lg shadow-primary/20 transition-all">
Ready to Print
</div>
</Show>
</div>
);
};
@@ -0,0 +1,32 @@
import { createSignal } from 'solid-js';
export function useDragAndDrop(
selectedIds: () => string[],
setSelectedIds: (ids: string[]) => void,
updateItem: (id: string, updates: any) => void
) {
const [hoveredScopeId, setHoveredScopeId] = createSignal<string | null>(null);
const handleDragStart = (e: DragEvent, id: string) => {
if (!selectedIds().includes(id)) {
setSelectedIds([id]);
}
e.dataTransfer!.setData('text/plain', id);
e.dataTransfer!.effectAllowed = 'move';
};
const handleDrop = (e: DragEvent, scopeId?: string, subScopeId?: string) => {
e.preventDefault();
setHoveredScopeId(null);
const idsToMove = selectedIds();
if (idsToMove.length > 0) {
idsToMove.forEach(id => {
updateItem(id, { scopeId, subScopeId });
});
setSelectedIds([]);
}
};
return { hoveredScopeId, setHoveredScopeId, handleDragStart, handleDrop };
}
@@ -0,0 +1,94 @@
import { createMemo } from 'solid-js';
import type { Accessor } from 'solid-js';
import { appStore } from '../../store/appStore';
export function useEstimateCalculations(selectedIds: Accessor<string[]>) {
const items = appStore.estimateItems;
const scopes = appStore.scopes;
const generalEstimateNotes = appStore.generalEstimateNotes;
const generalPreNotes = appStore.generalPreNotes;
const grandTotals = createMemo(() => {
let subtotal = 0;
let markup = 0;
let contingency = 0;
let fees = 0;
const scopeSubtotals = new Map<string, number>();
items.forEach(item => {
const base = item.quantity * item.unitPrice;
subtotal += base;
markup += item.markupType === 'percent' ? base * (item.markup / 100) : item.markup;
contingency += item.contingencyType === 'percent' ? base * (item.contingency / 100) : item.contingency;
if (item.scopeId) {
scopeSubtotals.set(item.scopeId, (scopeSubtotals.get(item.scopeId) || 0) + base);
}
});
scopes.forEach(scope => {
const scopeSubtotal = scopeSubtotals.get(scope.id) || 0;
fees += scope.useManagementLogic === 'percent'
? scopeSubtotal * (scope.managementFee / 100)
: scope.managementFee;
fees += scope.useDeliveryLogic === 'percent'
? scopeSubtotal * (scope.deliveryFee / 100)
: scope.deliveryFee;
});
return {
subtotal,
markup,
contingency,
fees,
grandTotal: subtotal + markup + contingency + fees
};
});
const validationStats = createMemo(() => {
let count = 0;
if (generalPreNotes().includes('#')) count++;
if (generalEstimateNotes().includes('#')) count++;
scopes.forEach(s => {
if (s.bidDescription?.includes('#')) count++;
if (s.estimatorNotes?.includes('#')) count++;
});
return {
hasIncomplete: count > 0,
incompleteCount: count
};
});
const selectionTotals = createMemo(() => {
const selected = items.filter(i => selectedIds().includes(i.id));
let qty = 0, price = 0, sub = 0, mark = 0, cont = 0, tot = 0;
selected.forEach(item => {
const base = item.quantity * item.unitPrice;
const markupAmt = item.markupType === 'percent' ? base * (item.markup / 100) : item.markup;
const contingencyAmt = item.contingencyType === 'percent' ? base * (item.contingency / 100) : item.contingency;
qty += item.quantity;
price += item.unitPrice;
sub += base;
mark += markupAmt;
cont += contingencyAmt;
tot += base + markupAmt + contingencyAmt;
});
return {
quantity: Math.round(qty * 100) / 100,
unitPrice: Math.round(price * 100) / 100,
subtotal: Math.round(sub * 100) / 100,
markup: Math.round(mark * 100) / 100,
contingency: Math.round(cont * 100) / 100,
total: Math.round(tot * 100) / 100
};
});
return { grandTotals, validationStats, selectionTotals };
}
@@ -0,0 +1,87 @@
import { createSignal } from 'solid-js';
import { appStore } from '../../store/appStore';
import { pb, COLLECTIONS } from '../../utils/db';
export function useEstimateSync(
setShowSaveModal: (show: boolean) => void
) {
const [isCloudLoading, setIsCloudLoading] = createSignal(false);
const { scopes, setScopes, subScopes, setSubScopes, estimateItems: items, setEstimateItems: setItems, clientInfo, setClientInfo, jobInfo, setJobInfo, generalEstimateNotes, setGeneralEstimateNotes, generalPreNotes, setGeneralPreNotes } = appStore;
const exportEstimate = () => {
const data = {
scopes: [...scopes],
subScopes: [...subScopes],
items: [...items],
clientInfo: { ...clientInfo },
jobInfo: { ...jobInfo },
generalEstimateNotes: generalEstimateNotes(),
generalPreNotes: generalPreNotes(),
version: '1.0'
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${jobInfo.number || '0000'}-${jobInfo.name || 'unnamed'}-${clientInfo.name || 'client'}-estimate.json`;
a.click();
URL.revokeObjectURL(url);
};
const importEstimate = (e: Event) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
try {
const data = JSON.parse(event.target?.result as string);
if (data.scopes) setScopes(data.scopes);
if (data.subScopes) setSubScopes(data.subScopes);
if (data.items) setItems(data.items);
if (data.clientInfo) setClientInfo(data.clientInfo);
if (data.jobInfo) setJobInfo(data.jobInfo);
if (data.generalEstimateNotes !== undefined) setGeneralEstimateNotes(data.generalEstimateNotes);
if (data.generalNotes !== undefined) setGeneralEstimateNotes(data.generalNotes);
if (data.generalPreNotes !== undefined) setGeneralPreNotes(data.generalPreNotes);
} catch (err) {
alert('Failed to parse estimate file.');
}
};
reader.readAsText(file);
};
const getFullEstimateData = () => {
return {
scopes: [...scopes],
subScopes: [...subScopes],
items: [...items],
clientInfo: { ...clientInfo },
jobInfo: { ...jobInfo },
generalEstimateNotes: generalEstimateNotes(),
generalPreNotes: generalPreNotes()
};
};
const saveToCloud = async (name: string) => {
setIsCloudLoading(true);
try {
const data = getFullEstimateData();
await pb.collection(COLLECTIONS.ESTIMATES).create({
name,
items: data
});
setShowSaveModal(false);
appStore.setEstimateName(name);
alert('Estimate saved to PocketBase successfully!');
} catch (err) {
console.error(err);
alert('Failed to save. Ensure EstiMaker_Estimates collection permissions are public.');
} finally {
setIsCloudLoading(false);
}
};
return { exportEstimate, importEstimate, saveToCloud, isCloudLoading };
}
@@ -0,0 +1,76 @@
import { createSignal } from 'solid-js';
import { appStore } from '../../store/appStore';
import type { EstimateItem } from '../../types';
export function useSelectionManager() {
const [selectedIds, setSelectedIds] = createSignal<string[]>([]);
const [lastSelectedId, setLastSelectedId] = createSignal<string | null>(null);
const getVisualOrderedItems = () => {
const { estimateItems: items, scopes, subScopes } = appStore;
const ordered: EstimateItem[] = [];
ordered.push(...items.filter(i => !i.scopeId));
scopes.forEach(scope => {
ordered.push(...items.filter(i => i.scopeId === scope.id && !i.subScopeId));
subScopes.filter(ss => ss.scopeId === scope.id).forEach(ss => {
ordered.push(...items.filter(i => i.subScopeId === ss.id));
});
});
return ordered;
};
const toggleSelection = (id: string, isMulti: boolean, isShift: boolean = false) => {
if (isShift && lastSelectedId()) {
const orderedItems = getVisualOrderedItems();
const lastIdx = orderedItems.findIndex(i => i.id === lastSelectedId());
const currentIdx = orderedItems.findIndex(i => i.id === id);
if (lastIdx !== -1 && currentIdx !== -1) {
const start = Math.min(lastIdx, currentIdx);
const end = Math.max(lastIdx, currentIdx);
const rangeIds = orderedItems.slice(start, end + 1).map(i => i.id);
setSelectedIds(prev => {
const newSet = new Set(prev);
rangeIds.forEach(rid => newSet.add(rid));
return Array.from(newSet);
});
}
} else if (isMulti) {
setSelectedIds(prev => prev.includes(id)
? prev.filter(i => i !== id)
: [...prev, id]
);
} else {
if (selectedIds().includes(id) && selectedIds().length === 1) {
setSelectedIds([]);
} else {
setSelectedIds([id]);
}
}
setLastSelectedId(id);
};
const clearSelection = (e?: MouseEvent) => {
if (!e || e.target === e.currentTarget) {
setSelectedIds([]);
setLastSelectedId(null);
}
};
const copyColumnSum = (column: 'quantity'|'unitPrice'|'subtotal'|'markup'|'contingency'|'total', val: number, setCopyToast: (msg: string | null) => void) => {
navigator.clipboard.writeText(String(val)).then(() => {
const labels: Record<string, string> = {
quantity: 'Qty', unitPrice: 'Price', subtotal: 'Subtotal',
markup: 'Markup', contingency: 'Contingency', total: 'Total'
};
setCopyToast(`Copied ${labels[column]}: ${val}`);
setTimeout(() => setCopyToast(null), 2500);
});
};
return { selectedIds, setSelectedIds, lastSelectedId, setLastSelectedId, toggleSelection, clearSelection, copyColumnSum };
}