import React, { useState, useEffect } from 'react'; import { Plus, Trash2, Calculator, Settings, AlertCircle, Save, ArrowLeft, FileText, User, MapPin, Phone, Mail, CheckCircle2, MoreHorizontal, ChevronDown, ChevronRight, DollarSign, Maximize, Minimize, UploadCloud, GripVertical, Tag } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import ImageUploadModal from '../components/estimates/ImageUploadModal'; import TemplateSelectionModal from '../components/estimates/TemplateSelectionModal'; import MeasurementsModal from '../components/estimates/MeasurementsModal'; import MaterialDetailsModal from '../components/estimates/MaterialDetailsModal'; import InitialChoiceModal from '../components/estimates/InitialChoiceModal'; const generateId = () => Math.random().toString(36).substr(2, 9); const UOM_OPTIONS = ['SQ', 'EA', 'BD', 'RL', 'LF', 'HR', 'LS', 'PC', 'BX']; export default function EstimateBuilder() { const navigate = useNavigate(); // --- Zone E: Workflow State --- // Workflow: 'upload' → 'initialChoice' → 'template' (or 'none') → 'measurements' → 'none' const [workflowStep, setWorkflowStep] = useState('upload'); const [selectedImage, setSelectedImage] = useState(null); const [selectedTemplate, setSelectedTemplate] = useState(null); const [measurementsData, setMeasurementsData] = useState(null); const [activeDetailsItem, setActiveDetailsItem] = useState(null); // --- Zone A: Client Info --- const [clientInfo, setClientInfo] = useState({ name: '', phone: '', email: '', address: '', zip: '' }); // Basic Validation states const [errors, setErrors] = useState({}); const validateEmail = (email) => { if (!email) return true; return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); }; const validatePhone = (phone) => { if (!phone) return true; return phone.length >= 10; }; const handleClientChange = (e) => { const { name, value } = e.target; setClientInfo(prev => ({ ...prev, [name]: value })); // Clear error on change if (name === 'email' && validateEmail(value)) { setErrors(prev => ({ ...prev, email: null })); } else if (name === 'email') { setErrors(prev => ({ ...prev, email: 'Invalid email address' })); } if (name === 'phone' && validatePhone(value)) { setErrors(prev => ({ ...prev, phone: null })); } else if (name === 'phone' && value.length > 0) { setErrors(prev => ({ ...prev, phone: 'Invalid phone (min 10 chars)' })); } }; // --- Zone B: Scope of Work --- const [scopeOfWork, setScopeOfWork] = useState(''); // --- Zone C: Costing Engine --- const [sections, setSections] = useState([ { id: generateId(), name: 'Roofing Section', description: [], marginPercent: 30, groups: [ { id: generateId(), name: 'Materials', items: [ { id: generateId(), desc: 'CertainTeed Landmark AR', qty: 98.41, uom: 'SQ', unitCost: 42.09, clientPrice: 19182.12 } ] }, { id: generateId(), name: 'Labor', items: [ { id: generateId(), desc: 'Tear off and Install Laminated Shingles', qty: 98.41, uom: 'SQ', unitCost: 80.00, clientPrice: 12121.46 } ] } ] } ]); // Collapsed state for section descriptions const [collapsedDescriptions, setCollapsedDescriptions] = useState({}); const toggleDescription = (sectionId) => { setCollapsedDescriptions(prev => ({ ...prev, [sectionId]: !prev[sectionId] })); }; // --- Zone D: Financials --- const [financials, setFinancials] = useState({ taxRate: 0, ohp: 0, }); const [isFullViewMode, setIsFullViewMode] = useState(false); useEffect(() => { const handleKeyDown = (e) => { if (e.key === 'Escape' && isFullViewMode) { setIsFullViewMode(false); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [isFullViewMode]); // --- Workflow Handlers --- const handleImageSelect = (imageName) => { setSelectedImage(imageName); setWorkflowStep('initialChoice'); }; const handleTemplateSelect = (template) => { setSelectedTemplate(template); setWorkflowStep('measurements'); }; const handleMeasurementsNext = (measurements) => { setMeasurementsData(measurements); // Auto-generate sections based on template if (selectedTemplate) { const newSection = { id: generateId(), name: `${selectedTemplate.name} Section`, description: selectedTemplate.description || [], marginPercent: 30, groups: [ { id: generateId(), name: 'Materials', items: selectedTemplate.materials.map(mat => { let baseQty = 0; let conversionFactor = 1; let measurementUom = mat.uom; // Logic to handle different UOMs based on typical roofing measurements if (mat.uom === 'SQ') { baseQty = measurements.totalRoofArea || 0; measurementUom = 'SQ'; } else if (mat.uom === 'BD') { if (mat.desc.toLowerCase().includes('ridge')) { baseQty = measurements.ridges || 0; conversionFactor = 30; // 30 LF per Bundle of Ridge } else { baseQty = measurements.eaves || 0; conversionFactor = 100; // 100 LF per Bundle of Starter } measurementUom = 'LF'; } else if (mat.uom === 'RL') { if (mat.desc.toLowerCase().includes('ice')) { baseQty = measurements.eaves || 0; conversionFactor = 65; // 65 LF per Roll of Ice & Water measurementUom = 'LF'; } else { baseQty = measurements.totalRoofArea || 0; conversionFactor = 10; // 10 SQ per Roll of Synthetic measurementUom = 'SQ'; } } else if (mat.uom === 'BX') { baseQty = 1; measurementUom = 'BX'; } else if (mat.uom === 'EA') { baseQty = 1; measurementUom = 'EA'; } else if (mat.uom === 'PC') { baseQty = 1; measurementUom = 'PC'; } else { baseQty = measurements.totalRoofArea || 0; } const calculatedPreCeil = (baseQty * 1.05) / conversionFactor; // 5% flat waste for initial generation const finalQty = Math.ceil(calculatedPreCeil); return { id: generateId(), desc: mat.desc, uom: mat.uom, unitCost: mat.baseUnitCost, qty: finalQty, clientPrice: finalQty * mat.baseUnitCost * 1.428, // ~30% margin default baseMeasurement: baseQty, wastePercent: 5, conversionFactor: conversionFactor, marginPercent: 30, measurementUom: measurementUom }; }) } ] }; // Replace the dummy default section with the generated one setSections([newSection]); } setWorkflowStep('none'); }; const handleItemClick = (sectionId, groupId, item) => { setActiveDetailsItem({ sectionId, groupId, item }); }; const handleSaveMaterialDetails = (updatedItem) => { if (!activeDetailsItem) return; const { sectionId, groupId } = activeDetailsItem; setSections(sections.map(s => { if (s.id === sectionId) { return { ...s, groups: s.groups.map(g => { if (g.id === groupId) { return { ...g, items: g.items.map(i => i.id === updatedItem.id ? updatedItem : i) }; } return g; }) }; } return s; })); }; // Handlers for Zone C const addSection = () => { setSections([...sections, { id: generateId(), name: `New Section ${sections.length + 1}`, description: [], marginPercent: 30, groups: [] }]); }; const addGroup = (sectionId) => { setSections(sections.map(s => { if (s.id === sectionId) { return { ...s, groups: [...s.groups, { id: generateId(), name: `New Group`, items: [] }] }; } return s; })); }; const addItem = (sectionId, groupId) => { setSections(sections.map(s => { if (s.id === sectionId) { return { ...s, groups: s.groups.map(g => { if (g.id === groupId) { return { ...g, items: [...g.items, { id: generateId(), desc: '', qty: 0, uom: 'EA', unitCost: 0, clientPrice: 0 }] }; } return g; }) }; } return s; })); }; const updateItem = (sectionId, groupId, itemId, field, value) => { setSections(sections.map(s => { if (s.id === sectionId) { return { ...s, groups: s.groups.map(g => { if (g.id === groupId) { return { ...g, items: g.items.map(i => { if (i.id === itemId) { const parsedVal = (field === 'qty' || field === 'unitCost' || field === 'clientPrice') ? parseFloat(value) || 0 : value; return { ...i, [field]: parsedVal }; } return i; }) }; } return g; }) }; } return s; })); }; const removeItem = (sectionId, groupId, itemId) => { setSections(sections.map(s => { if (s.id === sectionId) { return { ...s, groups: s.groups.map(g => { if (g.id === groupId) { return { ...g, items: g.items.filter(i => i.id !== itemId) }; } return g; }) }; } return s; })); }; const updateSectionName = (sectionId, name) => { setSections(sections.map(s => s.id === sectionId ? { ...s, name } : s)); }; const updateSectionMargin = (sectionId, newMarginPercent) => { setSections(sections.map(s => { if (s.id === sectionId) { return { ...s, margin: newMarginPercent, marginPercent: newMarginPercent, groups: s.groups.map(g => ({ ...g, items: g.items.map(i => { const cost = i.qty * i.unitCost; const clientPrice = newMarginPercent < 100 ? cost / (1 - (newMarginPercent / 100)) : cost; return { ...i, marginPercent: newMarginPercent, clientPrice }; }) })) }; } return s; })); }; const updateGroupName = (sectionId, groupId, name) => { setSections(sections.map(s => { if (s.id === sectionId) { return { ...s, groups: s.groups.map(g => g.id === groupId ? { ...g, name } : g) }; } return s; })); }; // Calculations const calculateTotals = () => { let internalCost = 0; let clientRevenue = 0; sections.forEach(s => { s.groups.forEach(g => { g.items.forEach(i => { internalCost += (i.qty * i.unitCost); clientRevenue += i.clientPrice; }); }); }); const subtotal = clientRevenue; const tax = subtotal * (financials.taxRate / 100); const ohpAmount = subtotal * (financials.ohp / 100); const grandTotal = subtotal + tax + ohpAmount; const totalRevenueNoTax = subtotal + ohpAmount; const netProfit = totalRevenueNoTax - internalCost; const grossMarginPercent = totalRevenueNoTax > 0 ? (netProfit / totalRevenueNoTax) * 100 : 0; return { internalCost, clientRevenue, subtotal, tax, ohpAmount, grandTotal, netProfit, grossMarginPercent }; }; const totals = calculateTotals(); // Helper calculation for a particular group const calcGroupTotals = (group) => { let cost = 0; let rev = 0; group.items.forEach(i => { cost += (i.qty * i.unitCost); rev += i.clientPrice; }); return { cost, rev }; }; // Helper calculation for an entire section const calcSectionTotals = (section) => { let cost = 0; let rev = 0; section.groups.forEach(g => { g.items.forEach(i => { cost += (i.qty * i.unitCost); rev += i.clientPrice; }); }); const margin = rev > 0 ? ((rev - cost) / rev) * 100 : 0; const profitAmount = rev - cost; return { cost, rev, margin, profitAmount }; }; // Bottom action bar component const BottomActionBar = () => (
); return (
{/* --- Page Header --- */}

Estimate Builder

Create and configure project estimates for clients.

{/* === MAIN CONTENT (Left Col - 8) === */}
{/* --- Zone A: Client & Project Metadata --- */}

Client Details

{errors.phone &&

{errors.phone}

}
{errors.email &&

{errors.email}

}
{/* --- Zone B: Scope of Work --- */}

Scope of Work