Files
EstiMaker/src/components/EstimateBuilder.tsx
T
tcardoza fb7b138371
CI / build (push) Has been skipped
CI / deploy (push) Successful in 44s
management and delivery improvements
2026-03-18 11:42:21 -05:00

406 lines
20 KiB
TypeScript

import type { Component } from 'solid-js';
import { createSignal, createMemo, onMount, Show } from 'solid-js';
import { FileText, ChevronDown, CheckCircle2, X } from 'lucide-solid';
import { Portal } from 'solid-js/web';
import { appStore } from '../store/appStore';
import type { Scope, SubScope, EstimateItem } from '../types';
import type { ExtractedItem } from '../utils/csv-parser';
import { GENERAL_NOTES_DEFAULT, GENERAL_PRE_NOTES_DEFAULT } from '../company-info';
import PrintEstimate from './PrintEstimate';
import NoteEditor from './NoteEditor';
import CalculatorPopover from './CalculatorPopover';
import CloudLoadModal from './CloudLoadModal';
import ExportTableModal from './ExportTableModal';
import ApplyTemplateModal from './ApplyTemplateModal';
import HistoryModal from './estimate-builder/HistoryModal';
import { NewEstimateModal } from './estimate-builder/NewEstimateModal';
// Import extracted components & hooks
import { useEstimateCalculations } from './estimate-builder/useEstimateCalculations';
import { useSelectionManager } from './estimate-builder/useSelectionManager';
import { useDragAndDrop } from './estimate-builder/useDragAndDrop';
import { useEstimateSync } from './estimate-builder/useEstimateSync';
import { BuilderHeader } from './estimate-builder/BuilderHeader';
import { BuilderSidebar } from './estimate-builder/BuilderSidebar';
import { ProjectDetailsForm } from './estimate-builder/ProjectDetailsForm';
import { UnassignedItemsSection } from './estimate-builder/UnassignedItemsSection';
import { ExecutiveSummary } from './estimate-builder/ExecutiveSummary';
import { SelectionToolbar } from './estimate-builder/SelectionToolbar';
import { ValidationStatusBar } from './estimate-builder/ValidationStatusBar';
import { ScopesList } from './estimate-builder/ScopesList';
interface EstimateBuilderProps {
initialItems: ExtractedItem[];
}
const EstimateBuilder: Component<EstimateBuilderProps> = (props) => {
// 1. Initialize State
const { scopes, setScopes, subScopes, setSubScopes, estimateItems: items, setEstimateItems: setItems, clientInfo, jobInfo, generalEstimateNotes, setGeneralEstimateNotes, generalPreNotes, setGeneralPreNotes } = appStore;
const [isSidebarCollapsed, setIsSidebarCollapsed] = createSignal(false);
const [isNotesExpanded, setIsNotesExpanded] = createSignal(true);
const [isPreNotesExpanded, setIsPreNotesExpanded] = createSignal(true);
// Cloud Modals
const [showSaveModal, setShowSaveModal] = createSignal(false);
const [showLoadModal, setShowLoadModal] = createSignal(false);
const [showHistoryModal, setShowHistoryModal] = createSignal(false);
const [showNewEstimateModal, setShowNewEstimateModal] = createSignal(false);
const [showExportTableModal, setShowExportTableModal] = createSignal(false);
const [applyTemplateScopeId, setApplyTemplateScopeId] = createSignal<string | null>(null);
// 2. Initialize custom hooks/primitives
const { selectedIds, setSelectedIds, lastSelectedId, setLastSelectedId, toggleSelection, clearSelection, copyColumnSum } = useSelectionManager();
const { grandTotals, validationStats, selectionTotals } = useEstimateCalculations(selectedIds);
const updateItem = (id: string, updates: Partial<EstimateItem>) => {
setItems(item => item.id === id, updates);
};
const { hoveredScopeId, setHoveredScopeId, handleDragStart, handleDrop } = useDragAndDrop(selectedIds, setSelectedIds, updateItem);
const { exportEstimate, importEstimate, saveToCloud, saveChangesToCloud, saveAsNewestToCloud, checkoutVersion, isCloudLoading } = useEstimateSync(setShowSaveModal);
onMount(() => {
if (!generalEstimateNotes()) setGeneralEstimateNotes(GENERAL_NOTES_DEFAULT);
if (!generalPreNotes()) setGeneralPreNotes(GENERAL_PRE_NOTES_DEFAULT);
if (items.length === 0 && props.initialItems.length > 0) {
const transformed: EstimateItem[] = props.initialItems.map(item => ({
id: item.id,
description: item.description,
quantity: parseFloat(item.quantity.replace(/,/g, '')) || 0,
unitPrice: 0,
markup: 0,
markupType: 'percent',
contingency: 0,
contingencyType: 'percent'
}));
setItems(transformed);
}
});
const unassignedItems = createMemo(() => items.filter(i => !i.scopeId));
const anyDescriptionIsLong = createMemo(() => items.some(item => (item.description || '').length > 120));
const finalizeBid = () => {
window.print();
};
const addScope = () => {
const scopeId = crypto.randomUUID();
const newScope: Scope = {
id: scopeId,
name: 'New Scope',
managementFee: 0,
managementFeeType: 'percent',
managementFeeQuantity: 1,
managementFeeRate: 0,
deliveryFee: 0,
deliveryFeeType: 'percent',
deliveryFeeQuantity: 1,
deliveryFeeRate: 0,
useManagementLogic: 'percent',
useDeliveryLogic: 'percent',
estimatorNotes: '',
bidDescription: '**Material and labor to install #**\n**Includes:**\n- #\n**Does Not Include:**\n- #'
};
setScopes(prev => [...prev, newScope]);
addSubScopeWithName(scopeId, 'Materials');
addSubScopeWithName(scopeId, 'Labor');
return scopeId;
};
const addSubScopeWithName = (scopeId: string, name: string) => {
const newSubScope: SubScope = {
id: crypto.randomUUID(),
name,
scopeId
};
setSubScopes(prev => [...prev, newSubScope]);
return newSubScope.id;
};
const addSubScope = (scopeId: string) => {
addSubScopeWithName(scopeId, 'New Sub-scope');
};
const deleteItem = (id: string) => {
setItems(items.filter(i => i.id !== id));
setSelectedIds(prev => prev.filter(selId => selId !== id));
if (lastSelectedId() === id) setLastSelectedId(null);
};
const duplicateItem = (id: string) => {
const itemIndex = items.findIndex(i => i.id === id);
if (itemIndex !== -1) {
const itemToClone = items[itemIndex];
const newItem: EstimateItem = { ...itemToClone, id: crypto.randomUUID() };
const newItems = [...items];
newItems.splice(itemIndex + 1, 0, newItem);
setItems(newItems);
}
};
const copySubScopeItems = (sourceSubScopeId: string, targetSubScopeId: string) => {
const targetSubScope = subScopes.find(ss => ss.id === targetSubScopeId);
if (!targetSubScope) return;
const itemsToClone = items.filter(i => i.subScopeId === sourceSubScopeId);
const newClonedItems = itemsToClone.map(item => ({
...item, id: crypto.randomUUID(), subScopeId: targetSubScopeId, scopeId: targetSubScope.scopeId
}));
setItems(prev => [...prev, ...newClonedItems]);
};
const updateScope = (id: string, updates: Partial<Scope>) => {
setScopes(s => s.id === id, updates);
};
const updateSubScope = (id: string, updates: Partial<SubScope>) => {
setSubScopes(ss => ss.id === id, updates);
};
const deleteScope = (id: string) => {
setScopes(scopes.filter(s => s.id !== id));
setItems(i => i.scopeId === id, { scopeId: undefined, subScopeId: undefined });
setSubScopes(subScopes.filter(ss => ss.scopeId !== id));
};
const deleteSubScope = (id: string) => {
setSubScopes(subScopes.filter(ss => ss.id !== id));
setItems(i => i.subScopeId === id, { subScopeId: undefined });
};
const handleBulkUpdate = (scopeId: string, subScopeId: string | undefined, updates: any) => {
setItems(item => item.scopeId === scopeId && (subScopeId === undefined || item.subScopeId === subScopeId), updates);
};
const formatNumber = (num: number) => {
return num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
const [copyToast, setCopyToast] = createSignal<string | null>(null);
return (
<div class="w-full flex flex-col min-h-screen bg-white shadow-2xl relative print:shadow-none print:border-none print:rounded-none">
<PrintEstimate
scopes={scopes}
subScopes={subScopes}
items={items}
grandTotals={grandTotals}
clientInfo={clientInfo}
jobInfo={jobInfo}
generalEstimateNotes={generalEstimateNotes()}
generalPreNotes={generalPreNotes()}
formatNumber={formatNumber}
hideColumns={anyDescriptionIsLong()}
/>
<div class="no-print flex flex-col min-h-dvh">
<BuilderHeader
isSidebarCollapsed={isSidebarCollapsed()}
onToggleSidebar={() => setIsSidebarCollapsed(!isSidebarCollapsed())}
onAddScope={addScope}
onShowNewEstimateModal={() => setShowNewEstimateModal(true)}
onImportEstimate={importEstimate}
onShowExportTableModal={() => setShowExportTableModal(true)}
onExportEstimate={exportEstimate}
onShowLoadModal={() => setShowLoadModal(true)}
onShowSaveModal={() => setShowSaveModal(true)}
onShowHistoryModal={() => setShowHistoryModal(true)}
onSaveChanges={() => appStore.isLatestVersion() ? saveChangesToCloud() : saveAsNewestToCloud()}
/>
<div
style={{
display: 'grid',
'grid-template-columns': isSidebarCollapsed() ? '0px 1fr' : '18rem 1fr',
transition: 'grid-template-columns 500ms ease',
'align-items': 'start',
flex: '1',
}}
>
<BuilderSidebar
isCollapsed={isSidebarCollapsed()}
onToggleSidebar={() => setIsSidebarCollapsed(!isSidebarCollapsed())}
unassignedItems={unassignedItems()}
grandTotals={grandTotals()}
hoveredScopeId={hoveredScopeId()}
setHoveredScopeId={setHoveredScopeId}
onDrop={handleDrop}
onAddScope={addScope}
/>
<main onClick={clearSelection} class="min-w-0 p-4 md:p-8 bg-zinc-50/50 space-y-8 min-h-screen">
<ProjectDetailsForm />
<div class="bg-muted/30 border border-border rounded-3xl p-6 mb-8">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3 text-foreground">
<div class="p-2 bg-background rounded-xl shadow-sm">
<FileText class="w-5 h-5 text-primary" />
</div>
<div>
<h3 class="font-black text-lg">General Pre-Notes</h3>
<p class="text-muted-foreground text-[10px] font-bold uppercase tracking-wider">Project-wide preamble & terms</p>
</div>
</div>
<button
onClick={() => setIsPreNotesExpanded(!isPreNotesExpanded())}
class="p-2 hover:bg-background rounded-xl transition-all text-muted-foreground"
>
<ChevronDown class={`w-5 h-5 transition-transform ${isPreNotesExpanded() ? 'rotate-180' : ''}`} />
</button>
</div>
<Show when={isPreNotesExpanded()}>
<div class="p-6 pt-0">
<NoteEditor
label="Pre-Scope Terms"
value={generalPreNotes()}
onUpdate={setGeneralPreNotes}
icon={FileText}
highlight={generalPreNotes().includes('#')}
/>
</div>
</Show>
</div>
<UnassignedItemsSection
unassignedItems={unassignedItems()}
isSidebarCollapsed={isSidebarCollapsed}
anyDescriptionIsLong={anyDescriptionIsLong}
selectedIds={selectedIds()}
onDrop={handleDrop}
clearSelection={clearSelection}
updateItem={updateItem}
deleteItem={deleteItem}
handleDragStart={handleDragStart}
duplicateItem={duplicateItem}
toggleSelection={toggleSelection}
/>
<ScopesList
scopes={scopes}
subScopes={subScopes}
items={items}
isNotesExpanded={isNotesExpanded()}
setIsNotesExpanded={setIsNotesExpanded}
generalEstimateNotes={generalEstimateNotes()}
setGeneralEstimateNotes={setGeneralEstimateNotes}
updateScope={updateScope}
deleteScope={deleteScope}
updateItem={updateItem}
deleteItem={deleteItem}
addSubScope={addSubScope}
updateSubScope={updateSubScope}
deleteSubScope={deleteSubScope}
handleDragStart={handleDragStart}
handleDrop={handleDrop}
handleBulkUpdate={handleBulkUpdate}
duplicateItem={duplicateItem}
copySubScopeItems={copySubScopeItems}
setApplyTemplateScopeId={setApplyTemplateScopeId}
isSidebarCollapsed={isSidebarCollapsed}
selectedIds={selectedIds()}
toggleSelection={toggleSelection}
clearSelection={clearSelection}
anyDescriptionIsLong={anyDescriptionIsLong}
addScope={addScope}
/>
<ValidationStatusBar validationStats={validationStats()} />
<ExecutiveSummary totals={grandTotals()} onFinalizeBid={finalizeBid} />
</main>
</div>
<CalculatorPopover />
</div>
<SelectionToolbar
selectedIds={selectedIds()}
selectionTotals={selectionTotals()}
onClearSelection={() => { setSelectedIds([]); setLastSelectedId(null); }}
onCopyColumnSum={(col) => copyColumnSum(col, selectionTotals()[col], setCopyToast)}
/>
<Show when={copyToast()}>
<div class="fixed bottom-6 right-6 bg-gray-900 border border-gray-800 text-white px-4 py-3 rounded-xl shadow-2xl flex items-center gap-3 animate-in slide-in-from-bottom-5 z-50 pointer-events-none fade-out-90 duration-300">
<CheckCircle2 class="w-5 h-5 text-green-400" />
<span class="font-medium text-sm drop-shadow-sm">{copyToast()}</span>
</div>
</Show>
<Show when={showSaveModal()}>
<Portal>
<div class="fixed inset-0 z-[100] flex 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-md rounded-3xl shadow-premium border border-border p-6 animate-in zoom-in-95 duration-200">
<div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-black">Save to Cloud</h3>
<button onClick={() => setShowSaveModal(false)} class="p-2 hover:bg-muted rounded-xl transition-colors">
<X class="w-5 h-5 text-muted-foreground" />
</button>
</div>
<div class="space-y-4">
<input
type="text"
id="cloud-save-name"
placeholder="Estimate Name (e.g. Smith Master Bath)"
class="w-full bg-muted/30 border border-border/60 rounded-xl px-4 py-3 text-sm focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all font-medium"
value={appStore.estimateName()}
/>
<div class="flex gap-3 justify-end mt-6">
<button onClick={() => setShowSaveModal(false)} class="px-5 py-2.5 rounded-xl font-bold text-sm hover:bg-muted transition-colors">Cancel</button>
<button
disabled={isCloudLoading()}
onClick={() => {
const name = (document.getElementById('cloud-save-name') as HTMLInputElement).value;
if (name) saveToCloud(name);
}}
class="px-5 py-2.5 bg-primary text-primary-foreground rounded-xl font-bold text-sm shadow-premium hover:shadow-premium-hover transition-all disabled:opacity-50"
>
{isCloudLoading() ? 'Saving...' : 'Save Estimate'}
</button>
</div>
</div>
</div>
</div>
</Portal>
</Show>
<CloudLoadModal show={showLoadModal()} onClose={() => setShowLoadModal(false)} />
<ExportTableModal show={showExportTableModal()} onClose={() => setShowExportTableModal(false)} />
<ApplyTemplateModal show={!!applyTemplateScopeId()} scopeId={applyTemplateScopeId()} onClose={() => setApplyTemplateScopeId(null)} />
<HistoryModal show={showHistoryModal()} onClose={() => setShowHistoryModal(false)} onCheckout={checkoutVersion} />
<NewEstimateModal
show={showNewEstimateModal()}
onClose={() => setShowNewEstimateModal(false)}
onResetCSV={() => {
appStore.resetEstimate();
setShowNewEstimateModal(false);
}}
onManualEstimate={() => {
appStore.resetEstimate();
setItems([{
id: crypto.randomUUID(),
description: 'Item',
quantity: 1,
unitPrice: 0,
markup: 0,
markupType: 'percent',
contingency: 0,
contingencyType: 'percent'
}]);
setShowNewEstimateModal(false);
}}
/>
</div>
);
};
export default EstimateBuilder;