406 lines
20 KiB
TypeScript
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;
|