diff --git a/src/components/EstimateBuilder.tsx b/src/components/EstimateBuilder.tsx index 2602614..bbf875b 100644 --- a/src/components/EstimateBuilder.tsx +++ b/src/components/EstimateBuilder.tsx @@ -1,220 +1,91 @@ import type { Component } from 'solid-js'; -import { createSignal, For, createMemo, onMount, Show } 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 { Plus, ChevronRight, ListTree, Calculator, FolderKanban, Info, PanelLeftOpen, PanelLeftClose, FileUp, Download, Upload, FileText, ChevronDown, AlertTriangle, CheckCircle2, X, Table } from 'lucide-solid'; -import { GENERAL_NOTES_DEFAULT, GENERAL_PRE_NOTES_DEFAULT } from '../company-info'; + +import { appStore } from '../store/appStore'; import type { Scope, SubScope, EstimateItem } from '../types'; import type { ExtractedItem } from '../utils/csv-parser'; -import ScopeCard from './ScopeCard'; -import ItemRow from './ItemRow'; +import { GENERAL_NOTES_DEFAULT, GENERAL_PRE_NOTES_DEFAULT } from '../company-info'; + import PrintEstimate from './PrintEstimate'; import NoteEditor from './NoteEditor'; -import LazyItem from './LazyItem'; import CalculatorPopover from './CalculatorPopover'; -import { appStore } from '../store/appStore'; -import { pb, COLLECTIONS } from '../utils/db'; import CloudLoadModal from './CloudLoadModal'; import ExportTableModal from './ExportTableModal'; import ApplyTemplateModal from './ApplyTemplateModal'; +// 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[]; onReset: () => void; } const EstimateBuilder: Component = (props) => { - // Use Global Store State - const scopes = appStore.scopes; - const setScopes = appStore.setScopes; - const subScopes = appStore.subScopes; - const setSubScopes = appStore.setSubScopes; - const items = appStore.estimateItems; - const setItems = appStore.setEstimateItems; - const clientInfo = appStore.clientInfo; - const setClientInfo = appStore.setClientInfo; - const jobInfo = appStore.jobInfo; - const setJobInfo = appStore.setJobInfo; - const generalEstimateNotes = appStore.generalEstimateNotes; - const setGeneralEstimateNotes = appStore.setGeneralEstimateNotes; - const generalPreNotes = appStore.generalPreNotes; - const setGeneralPreNotes = appStore.setGeneralPreNotes; + // 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); - const [hoveredScopeId, setHoveredScopeId] = createSignal(null); - const [selectedIds, setSelectedIds] = createSignal([]); - const [lastSelectedId, setLastSelectedId] = createSignal(null); - + // Cloud Modals const [showSaveModal, setShowSaveModal] = createSignal(false); const [showLoadModal, setShowLoadModal] = createSignal(false); - const [isCloudLoading, setIsCloudLoading] = createSignal(false); const [showExportTableModal, setShowExportTableModal] = createSignal(false); const [applyTemplateScopeId, setApplyTemplateScopeId] = createSignal(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) => { + setItems(item => item.id === id, updates); + }; + + const { hoveredScopeId, setHoveredScopeId, handleDragStart, handleDrop } = useDragAndDrop(selectedIds, setSelectedIds, updateItem); + const { exportEstimate, importEstimate, saveToCloud, isCloudLoading } = useEstimateSync(setShowSaveModal); + onMount(() => { - // Initialize general notes if not already set by an imported estimate if (!generalEstimateNotes()) setGeneralEstimateNotes(GENERAL_NOTES_DEFAULT); if (!generalPreNotes()) setGeneralPreNotes(GENERAL_PRE_NOTES_DEFAULT); - // Convert initial items to Estimate items only if we are creating a new estimate - // (i.e. we don't already have estimateItems loaded) 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 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 grandTotals = createMemo(() => { - let subtotal = 0; - let markup = 0; - let contingency = 0; - let fees = 0; - const scopeSubtotals = new Map(); - - 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 anyDescriptionIsLong = createMemo(() => { - return items.some(item => (item.description || '').length > 120); - }); + const anyDescriptionIsLong = createMemo(() => items.some(item => (item.description || '').length > 120)); const finalizeBid = () => { window.print(); }; - 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); // Support old files - 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); // update header - 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); - } - }; - const addScope = () => { const scopeId = crypto.randomUUID(); const newScope: Scope = { @@ -230,8 +101,6 @@ const EstimateBuilder: Component = (props) => { bidDescription: '**Material and labor to install #**\n**Includes:**\n- #\n**Does Not Include:**\n- #' }; setScopes(prev => [...prev, newScope]); - - // Add default sub-scopes addSubScopeWithName(scopeId, 'Materials'); addSubScopeWithName(scopeId, 'Labor'); }; @@ -249,10 +118,6 @@ const EstimateBuilder: Component = (props) => { addSubScopeWithName(scopeId, 'New Sub-scope'); }; - const updateItem = (id: string, updates: Partial) => { - setItems(item => item.id === id, updates); - }; - const deleteItem = (id: string) => { setItems(items.filter(i => i.id !== id)); setSelectedIds(prev => prev.filter(selId => selId !== id)); @@ -263,11 +128,7 @@ const EstimateBuilder: Component = (props) => { const itemIndex = items.findIndex(i => i.id === id); if (itemIndex !== -1) { const itemToClone = items[itemIndex]; - const newItem: EstimateItem = { - ...itemToClone, - id: crypto.randomUUID() - }; - // Insert immediately after the original item + const newItem: EstimateItem = { ...itemToClone, id: crypto.randomUUID() }; const newItems = [...items]; newItems.splice(itemIndex + 1, 0, newItem); setItems(newItems); @@ -277,15 +138,10 @@ const EstimateBuilder: Component = (props) => { 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 + ...item, id: crypto.randomUUID(), subScopeId: targetSubScopeId, scopeId: targetSubScope.scopeId })); - setItems(prev => [...prev, ...newClonedItems]); }; @@ -299,105 +155,17 @@ const EstimateBuilder: Component = (props) => { const deleteScope = (id: string) => { setScopes(scopes.filter(s => s.id !== id)); - // Move items back to unassigned setItems(i => i.scopeId === id, { scopeId: undefined, subScopeId: undefined }); - // Delete related sub-scopes setSubScopes(subScopes.filter(ss => ss.scopeId !== id)); }; const deleteSubScope = (id: string) => { setSubScopes(subScopes.filter(ss => ss.id !== id)); - // Move items back to parent scope setItems(i => i.subScopeId === id, { subScopeId: undefined }); }; - const getVisualOrderedItems = () => { - const ordered: EstimateItem[] = []; - - // 1. Unassigned - ordered.push(...items.filter(i => !i.scopeId)); - - // 2. Scopes - scopes.forEach(scope => { - // Scope level items - ordered.push(...items.filter(i => i.scopeId === scope.id && !i.subScopeId)); - - // Sub-scope items - 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.target === e.currentTarget) { - setSelectedIds([]); - setLastSelectedId(null); - } - }; - - const handleDragStart = (e: DragEvent, id: string) => { - // If the dragged item isn't selected, make it the only selection - 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 }); - }); - // Clear selection after move to avoid confusion - setSelectedIds([]); - } - }; - const handleBulkUpdate = (scopeId: string, subScopeId: string | undefined, updates: any) => { - setItems( - item => item.scopeId === scopeId && (subScopeId === undefined || item.subScopeId === subScopeId), - updates - ); + setItems(item => item.scopeId === scopeId && (subScopeId === undefined || item.subScopeId === subScopeId), updates); }; const formatNumber = (num: number) => { @@ -406,48 +174,8 @@ const EstimateBuilder: Component = (props) => { const [copyToast, setCopyToast] = createSignal(null); - 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 - }; - }); - - const copyColumnSum = (column: keyof ReturnType) => { - const val = selectionTotals()[column]; - navigator.clipboard.writeText(String(val)).then(() => { - const labels: Record = { - quantity: 'Qty', unitPrice: 'Price', subtotal: 'Subtotal', - markup: 'Markup', contingency: 'Contingency', total: 'Total' - }; - setCopyToast(`Copied ${labels[column]}: ${val}`); - setTimeout(() => setCopyToast(null), 2500); - }); - }; - return (
- {/* Print Summary View */} = (props) => { hideColumns={anyDescriptionIsLong()} /> - {/* Main Application UI */}
- {/* Header Area - Sticky & Slim */} -
-
- -
-
- -
-
-

Estimate Builder

-
- {scopes.length} Scopes - - {items.length} Items -
-
-
-
-
- - -
- -