diff --git a/src/App.jsx b/src/App.jsx index 7a6ffad..bbd1b7b 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -32,6 +32,7 @@ import SubContractorDashboard from './pages/subcontractor/SubContractorDashboard import CreateLeadPage from './pages/CreateLeadPage'; import LeadsListPage from './pages/LeadsListPage'; import LynkDispatchPage from './pages/LynkDispatchPage'; +import EstimatesPage from './pages/EstimatesPage'; // ... (existing imports) const ProtectedRoute = ({ children, allowedRoles }) => { @@ -101,6 +102,14 @@ function App() { } /> + + + + } + /> } /> + + + + } /> @@ -230,6 +244,14 @@ function App() { } /> + + + + } + /> { icon: LayoutDashboard, label: ProCanvas }, - { to: "/owner/estimate", icon: Calculator, label: "Estimate Builder" }, + { to: "/owner/estimates", icon: Calculator, label: "Estimates" }, { to: "/admin/schedule", icon: Calendar, label: "Team Schedule" }, { to: "/admin/leaderboard", icon: Trophy, label: "Leaderboard" }, ...commonItems, @@ -163,7 +163,7 @@ const Layout = () => { icon: LayoutDashboard, label: ProCanvas }, - { to: "/admin/estimate", icon: Calculator, label: "Estimate Builder" }, + { to: "/admin/estimates", icon: Calculator, label: "Estimates" }, ...commonItems, ]; case 'CONTRACTOR': @@ -189,7 +189,7 @@ const Layout = () => { icon: LayoutDashboard, label: ProCanvas }, - { to: "/emp/fa/estimate", icon: Calculator, label: "Estimate Builder" }, + { to: "/emp/fa/estimates", icon: Calculator, label: "Estimates" }, ...commonItems, ]; default: // Customer or Fallback diff --git a/src/components/estimates/EstimateDetailModal.jsx b/src/components/estimates/EstimateDetailModal.jsx new file mode 100644 index 0000000..f1ffad6 --- /dev/null +++ b/src/components/estimates/EstimateDetailModal.jsx @@ -0,0 +1,301 @@ +import React, { useMemo } from 'react'; +import ReactDOM from 'react-dom'; +import { motion, AnimatePresence } from 'framer-motion'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../../context/AuthContext'; +import { useMockStore } from '../../data/mockStore'; +import { + X, Calendar, User, Layers, Maximize2, + CheckCircle, Clock, XCircle, AlertCircle, Send, + MapPin, FileText, Package, ChevronRight +} from 'lucide-react'; + +// --------------------------------------------------------------------------- +// Status config +// --------------------------------------------------------------------------- +const ESTIMATE_STATUS = { + Draft: { icon: Clock, bg: 'bg-zinc-100 dark:bg-zinc-700', text: 'text-zinc-600 dark:text-zinc-300' }, + Sent: { icon: Send, bg: 'bg-blue-100 dark:bg-blue-500/20', text: 'text-blue-700 dark:text-blue-400' }, + Approved: { icon: CheckCircle, bg: 'bg-emerald-100 dark:bg-emerald-500/20', text: 'text-emerald-700 dark:text-emerald-400' }, + Rejected: { icon: XCircle, bg: 'bg-red-100 dark:bg-red-500/20', text: 'text-red-700 dark:text-red-400' }, + Expired: { icon: AlertCircle, bg: 'bg-amber-100 dark:bg-amber-500/20', text: 'text-amber-700 dark:text-amber-400' }, +}; + +function StatusBadge({ status }) { + const cfg = ESTIMATE_STATUS[status] ?? ESTIMATE_STATUS.Draft; + const Icon = cfg.icon; + return ( + + {status} + + ); +} + +// --------------------------------------------------------------------------- +// Line item computation — mirrors EstimateBuilder's handleMeasurementsNext logic +// --------------------------------------------------------------------------- +function computeLineItems(template, roofArea) { + if (!template || !roofArea) return { materials: [], labor: [] }; + + const waste = 1.05; + const margin = 1.428; // ~30% margin + const ridgesLF = Math.round(roofArea * 1.6); + const eavesLF = Math.round(roofArea * 4.2); + + const materials = template.materials.map(mat => { + let qty; + if (mat.uom === 'SQ') { + qty = Math.ceil(roofArea * waste); + } else if (mat.uom === 'BD') { + const isRidge = /ridge|hip/i.test(mat.desc); + qty = isRidge + ? Math.max(1, Math.ceil((ridgesLF * waste) / 30)) + : Math.max(1, Math.ceil((eavesLF * waste) / 100)); + } else if (mat.uom === 'RL') { + const isIce = /ice|water/i.test(mat.desc); + qty = isIce + ? Math.max(1, Math.ceil((eavesLF * waste) / 65)) + : Math.max(1, Math.ceil((roofArea * waste) / 10)); + } else if (mat.uom === 'LF') { + qty = Math.ceil(eavesLF * waste); + } else if (mat.uom === 'BX') { + qty = Math.max(1, Math.ceil(roofArea / 20)); + } else if (mat.uom === 'EA') { + qty = 2; + } else { + qty = 1; + } + const clientPrice = Math.round(qty * mat.baseUnitCost * margin * 100) / 100; + return { desc: mat.desc, qty, uom: mat.uom, unitCost: mat.baseUnitCost, clientPrice }; + }); + + const laborQty = Math.ceil(roofArea * waste); + const laborUnitCost = 80.00; + const labor = [{ + desc: `Tear Off & Install — ${template.name}`, + qty: laborQty, + uom: 'SQ', + unitCost: laborUnitCost, + clientPrice: Math.round(laborQty * laborUnitCost * margin * 100) / 100, + }]; + + return { materials, labor }; +} + +// --------------------------------------------------------------------------- +// Section table component +// --------------------------------------------------------------------------- +function LineItemTable({ title, items, accentClass }) { + const subtotal = items.reduce((s, i) => s + i.clientPrice, 0); + return ( +
+
+ {title} +
+
+
+ Description + Qty + UOM + Unit + Client Price +
+
+ {items.map((item, i) => ( +
+ {item.desc} + {item.qty} + {item.uom} + ${item.unitCost.toFixed(2)} + ${item.clientPrice.toLocaleString('en-US', { minimumFractionDigits: 2 })} +
+ ))} +
+
+ + Subtotal: ${subtotal.toLocaleString('en-US', { minimumFractionDigits: 2 })} + +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// EstimateDetailModal +// Props: isOpen, onClose, estimate +// --------------------------------------------------------------------------- +export default function EstimateDetailModal({ isOpen, onClose, estimate }) { + const navigate = useNavigate(); + const { user } = useAuth(); + const { templates } = useMockStore(); + + const basePath = user?.role === 'OWNER' ? '/owner' : user?.role === 'ADMIN' ? '/admin' : '/emp/fa'; + + const template = useMemo( + () => templates.find(t => t.id === estimate?.templateId), + [templates, estimate?.templateId] + ); + + const { materials, labor } = useMemo( + () => computeLineItems(template, estimate?.roofArea), + [template, estimate?.roofArea] + ); + + const matSubtotal = materials.reduce((s, i) => s + i.clientPrice, 0); + const laborSubtotal = labor.reduce((s, i) => s + i.clientPrice, 0); + const grandTotal = matSubtotal + laborSubtotal; + + return ReactDOM.createPortal( + + {isOpen && estimate && ( + <> + {/* Backdrop */} + + + {/* Drawer */} + e.stopPropagation()} + > + {/* Header */} +
+
+
+

{estimate.clientName}

+ +
+

+ {estimate.address} +

+
+
+ + +
+
+ + {/* Scrollable body */} +
+
+ + {/* Meta chips */} +
+ {[ + { icon: Calendar, label: estimate.date }, + { icon: User, label: estimate.estimatedBy }, + { icon: Layers, label: estimate.templateName || 'No template' }, + { icon: FileText, label: estimate.roofArea ? `${estimate.roofArea} SQ roof area` : null }, + ].filter(m => m.label).map(m => { + const Icon = m.icon; + return ( +
+ + {m.label} +
+ ); + })} +
+ + {/* Notes */} + {estimate.notes && ( +
+ {estimate.notes} +
+ )} + + {/* Scope of work */} + {template?.description?.length > 0 && ( +
+

Scope of Work

+
    + {template.description.map((line, i) => ( +
  • + + {line} +
  • + ))} +
+
+ )} + + {/* Line items */} + {materials.length > 0 && ( + + )} + + {labor.length > 0 && ( + + )} + + {/* No template fallback */} + {!template && ( +
+ +

No template linked — open in Builder to view line items.

+
+ )} + +
+
+ + {/* Footer — financial summary */} + {(materials.length > 0 || labor.length > 0) && ( +
+
+
+ Materials subtotal + ${matSubtotal.toLocaleString('en-US', { minimumFractionDigits: 2 })} +
+
+ Labor subtotal + ${laborSubtotal.toLocaleString('en-US', { minimumFractionDigits: 2 })} +
+
+ Total (Client Price) + ${grandTotal.toLocaleString('en-US', { minimumFractionDigits: 2 })} +
+ {estimate.total !== grandTotal && ( +

+ Saved estimate total: ${estimate.total.toLocaleString('en-US', { minimumFractionDigits: 2 })} +

+ )} +
+
+ )} +
+ + )} +
, + document.body + ); +} diff --git a/src/components/estimates/TemplateEditorModal.jsx b/src/components/estimates/TemplateEditorModal.jsx new file mode 100644 index 0000000..0acd17b --- /dev/null +++ b/src/components/estimates/TemplateEditorModal.jsx @@ -0,0 +1,360 @@ +import React, { useState, useEffect } from 'react'; +import ReactDOM from 'react-dom'; +import { X, Plus, Trash2, GripVertical, Copy } from 'lucide-react'; +import { TEMPLATE_CATEGORIES } from '../../data/mockStore'; + +const UOM_OPTIONS = ['SQ', 'EA', 'BD', 'RL', 'LF', 'HR', 'LS', 'PC', 'BX']; + +const genId = () => Math.random().toString(36).substr(2, 9); + +function emptyMaterial() { + return { id: genId(), desc: '', uom: 'SQ', baseUnitCost: '' }; +} + +function initForm(template) { + if (!template) { + return { + name: '', + category: 'Roofing', + brand: '', + description: [''], + materials: [emptyMaterial()], + }; + } + return { + name: template.name, + category: template.category || 'Roofing', + brand: template.brand || '', + description: template.description?.length ? [...template.description] : [''], + materials: template.materials?.length + ? template.materials.map(m => ({ id: genId(), ...m })) + : [emptyMaterial()], + }; +} + +// --------------------------------------------------------------------------- +// TemplateEditorModal +// Props: +// isOpen boolean +// onClose () => void +// onSave (templateData, saveMode) => void +// saveMode: 'new' | 'replace' (only relevant for duplicate mode) +// mode 'create' | 'edit' | 'duplicate' +// template object | null +// --------------------------------------------------------------------------- +export default function TemplateEditorModal({ isOpen, onClose, onSave, mode = 'create', template = null }) { + const [form, setForm] = useState(() => initForm(mode === 'create' ? null : template)); + const [saveMode, setSaveMode] = useState('new'); // 'new' | 'replace' + const [errors, setErrors] = useState({}); + + // Re-init when template/mode changes + useEffect(() => { + if (!isOpen) return; + const base = mode === 'create' ? null : template; + const initialForm = initForm(base); + if (mode === 'duplicate') { + initialForm.name = `Copy of ${template?.name || ''}`; + } + setForm(initialForm); + setSaveMode('new'); + setErrors({}); + }, [isOpen, mode, template]); + + if (!isOpen) return null; + + // ---- helpers ---- + const setField = (field, value) => setForm(f => ({ ...f, [field]: value })); + + // Description rows + const setDesc = (i, val) => { + const d = [...form.description]; + d[i] = val; + setField('description', d); + }; + const addDesc = () => setField('description', [...form.description, '']); + const removeDesc = (i) => { + if (form.description.length <= 1) return; + setField('description', form.description.filter((_, idx) => idx !== i)); + }; + + // Material rows + const setMat = (id, field, val) => { + setForm(f => ({ + ...f, + materials: f.materials.map(m => m.id === id ? { ...m, [field]: val } : m), + })); + }; + const addMat = () => setField('materials', [...form.materials, emptyMaterial()]); + const removeMat = (id) => { + if (form.materials.length <= 1) return; + setField('materials', form.materials.filter(m => m.id !== id)); + }; + + // ---- validation & submit ---- + const validate = () => { + const e = {}; + if (!form.name.trim()) e.name = 'Template name is required'; + if (!form.brand.trim()) e.brand = 'Brand is required'; + if (form.description.every(d => !d.trim())) e.description = 'At least one description line is required'; + if (form.materials.some(m => !m.desc.trim())) e.materials = 'All material rows need a description'; + if (form.materials.some(m => m.baseUnitCost === '' || isNaN(Number(m.baseUnitCost)))) e.cost = 'All material rows need a valid unit cost'; + return e; + }; + + const handleSave = () => { + const e = validate(); + if (Object.keys(e).length) { setErrors(e); return; } + const data = { + name: form.name.trim(), + category: form.category, + brand: form.brand.trim(), + description: form.description.filter(d => d.trim()), + materials: form.materials.map(({ id, ...rest }) => ({ + ...rest, + baseUnitCost: parseFloat(rest.baseUnitCost), + })), + }; + onSave(data, mode === 'duplicate' ? saveMode : 'new'); + }; + + const title = mode === 'create' ? 'New Template' : mode === 'edit' ? 'Edit Template' : 'Duplicate Template'; + + return ReactDOM.createPortal( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+

{title}

+ {mode === 'duplicate' && ( +

Choose whether to save as a new copy or replace the original.

+ )} +
+ +
+ +
+
+ + {/* Duplicate mode toggle */} + {mode === 'duplicate' && ( +
+ {[ + { value: 'new', label: 'Save as new copy' }, + { value: 'replace', label: 'Replace original' }, + ].map(opt => ( + + ))} +
+ )} + + {/* Basic Info */} +
+
+ + { + setField('name', e.target.value); + if (errors.name) setErrors(p => ({ ...p, name: null })); + }} + disabled={mode === 'duplicate' && saveMode === 'replace'} + placeholder="e.g. GAF Timberline HDZ — Premium" + className={`w-full px-3.5 py-2.5 rounded-xl border text-sm bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 outline-none focus:ring-2 transition-shadow disabled:opacity-50 disabled:cursor-not-allowed ${ + errors.name + ? 'border-red-400 focus:ring-red-400/30' + : 'border-zinc-200 dark:border-zinc-700 focus:ring-blue-500/30 focus:border-blue-500' + }`} + /> + {errors.name &&

{errors.name}

} +
+
+ + +
+
+ + { + setField('brand', e.target.value); + if (errors.brand) setErrors(p => ({ ...p, brand: null })); + }} + placeholder="e.g. GAF, Atlas, SRS" + className={`w-full px-3.5 py-2.5 rounded-xl border text-sm bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 outline-none focus:ring-2 transition-shadow ${ + errors.brand + ? 'border-red-400 focus:ring-red-400/30' + : 'border-zinc-200 dark:border-zinc-700 focus:ring-blue-500/30 focus:border-blue-500' + }`} + /> + {errors.brand &&

{errors.brand}

} +
+
+ + {/* Scope / Description */} +
+
+ + +
+ {errors.description &&

{errors.description}

} +
+ {form.description.map((line, i) => ( +
+ {i + 1}. + { + setDesc(i, e.target.value); + if (errors.description) setErrors(p => ({ ...p, description: null })); + }} + placeholder="Describe one step of the scope..." + className="flex-1 px-3 py-2 rounded-lg border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 text-sm outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500 transition-shadow" + /> + +
+ ))} +
+
+ + {/* Materials */} +
+
+ + +
+ {(errors.materials || errors.cost) && ( +

{errors.materials || errors.cost}

+ )} + + {/* Table header */} +
+ Description + UOM + Base Cost + +
+ +
+ {form.materials.map((mat) => ( +
+ { + setMat(mat.id, 'desc', e.target.value); + if (errors.materials) setErrors(p => ({ ...p, materials: null })); + }} + placeholder="Material description" + className="flex-1 px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 text-sm outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500 transition-shadow" + /> + +
+ $ + { + setMat(mat.id, 'baseUnitCost', e.target.value); + if (errors.cost) setErrors(p => ({ ...p, cost: null })); + }} + placeholder="0.00" + className="w-full pl-6 pr-2 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 text-sm outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500 transition-shadow" + /> +
+ +
+ ))} +
+
+ +
+
+ + {/* Footer */} +
+ + +
+
+
, + document.body + ); +} diff --git a/src/components/estimates/TemplateSelectionModal.jsx b/src/components/estimates/TemplateSelectionModal.jsx index 58dc849..350e39e 100644 --- a/src/components/estimates/TemplateSelectionModal.jsx +++ b/src/components/estimates/TemplateSelectionModal.jsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import { useMockStore } from '../../data/mockStore'; const STUBBED_TEMPLATES = [ { @@ -531,10 +532,11 @@ const STUBBED_TEMPLATES = [ ]; export default function TemplateSelectionModal({ isOpen, onClose, onSelectTemplate }) { + const { templates } = useMockStore(); const [searchTerm, setSearchTerm] = useState(''); const [selected, setSelected] = useState(null); - const filtered = STUBBED_TEMPLATES.filter(t => + const filtered = templates.filter(t => t.name.toLowerCase().includes(searchTerm.toLowerCase()) || t.category.toLowerCase().includes(searchTerm.toLowerCase()) ); @@ -579,7 +581,7 @@ export default function TemplateSelectionModal({ isOpen, onClose, onSelectTempla {/* Category Header */}

- Roofing ({filtered.length}) + Templates ({filtered.length})

diff --git a/src/data/mockStore.jsx b/src/data/mockStore.jsx index 8826914..8039efa 100644 --- a/src/data/mockStore.jsx +++ b/src/data/mockStore.jsx @@ -3197,6 +3197,426 @@ export const DISPATCH_WEEKLY_STATS = [ }, ]; +// --------------------------------------------------------------------------- +// MASTER TEMPLATES +// --------------------------------------------------------------------------- + +export const TEMPLATE_CATEGORIES = ['Roofing', 'Siding', 'Gutters', 'Windows', 'Insulation', 'Other']; + +const MASTER_TEMPLATES_INITIAL = [ + { + id: 'atlas_glassmaster', + name: 'Atlas GlassMaster', + category: 'Roofing', + brand: 'Atlas', + description: [ + "Remove existing shingles down to deck.", + "Re-nail any loose wood. If bad or rotten wood is discovered, it will be replaced at a price of $50 per sheet.", + "Install 3' of Atlas WeatherMaster ice and water shield at all gutter lines, rakes edges, and valleys.", + "Install Atlas Summit 60 Synthetic underlayment.", + "Install Atlas Glassmaster AR Shingles." + ], + materials: [ + { desc: 'Atlas Glassmaster AR', baseUnitCost: 37.67, uom: 'SQ' }, + { desc: 'Atlas Pro-Cut Hip & Ridge', baseUnitCost: 78.50, uom: 'BD' }, + { desc: 'Atlas 6X Pro-Cut Starter', baseUnitCost: 70.80, uom: 'BD' }, + { desc: 'Atlas Summit 60 Synthetic Underlayment', baseUnitCost: 116.00, uom: 'RL' }, + { desc: 'Atlas WeatherMaster Ice & Water', baseUnitCost: 65.00, uom: 'RL' }, + { desc: 'Coil Nails 1 1/4"', baseUnitCost: 35.00, uom: 'BX' }, + { desc: 'Plastic Cap Nails', baseUnitCost: 28.50, uom: 'BX' }, + { desc: 'Geocel 2300 Clear Sealant', baseUnitCost: 8.95, uom: 'EA' } + ] + }, + { + id: 'atlas_glassmaster_srs', + name: 'Atlas GlassMaster - SRS', + category: 'Roofing', + brand: 'SRS', + description: [ + "Remove existing shingles down to deck.", + "Re-nail any loose wood.", + "Install 3' of Atlas WeatherMaster ice and water shield at all gutter lines, rakes edges, and valleys.", + "Install Atlas Summit 60 Synthetic underlayment.", + "Install Atlas Glassmaster AR Shingles (SRS distribution)." + ], + materials: [ + { desc: 'Atlas Glassmaster AR', baseUnitCost: 37.67, uom: 'SQ' }, + { desc: 'Atlas Pro-Cut Hip & Ridge', baseUnitCost: 78.50, uom: 'BD' }, + { desc: 'Atlas 6X Pro-Cut Starter', baseUnitCost: 70.80, uom: 'BD' }, + { desc: 'Atlas Summit 60 Synthetic Underlayment', baseUnitCost: 116.00, uom: 'RL' }, + { desc: 'Atlas WeatherMaster Ice & Water', baseUnitCost: 65.00, uom: 'RL' }, + { desc: 'Coil Nails 1 1/4"', baseUnitCost: 35.00, uom: 'BX' }, + { desc: 'Plastic Cap Nails', baseUnitCost: 28.50, uom: 'BX' }, + { desc: 'Geocel 2300 Clear Sealant', baseUnitCost: 8.95, uom: 'EA' } + ] + }, + { + id: 'atlas_pinnacle_pristine', + name: 'Atlas Pinnacle Pristine', + category: 'Roofing', + brand: 'Atlas', + description: [ + "Remove existing shingles down to deck.", + "Re-nail any loose wood.", + "Install 3' of Atlas WeatherMaster ice and water shield.", + "Install Atlas Summit 60 Synthetic underlayment.", + "Install Atlas Pinnacle Pristine Lifetime Dimensional Shingles." + ], + materials: [ + { desc: 'Atlas Pinnacle Pristine', baseUnitCost: 42.50, uom: 'SQ' }, + { desc: 'Atlas Pro-Cut High Profile Hip & Ridge', baseUnitCost: 85.00, uom: 'BD' }, + { desc: 'Atlas Pro-Cut Starter', baseUnitCost: 72.00, uom: 'BD' }, + { desc: 'Atlas Summit 60 Synthetic Underlayment', baseUnitCost: 116.00, uom: 'RL' }, + { desc: 'Atlas WeatherMaster Ice & Water', baseUnitCost: 65.00, uom: 'RL' }, + { desc: 'Coil Nails 1 1/4"', baseUnitCost: 35.00, uom: 'BX' }, + { desc: 'Plastic Cap Nails', baseUnitCost: 28.50, uom: 'BX' }, + { desc: 'Geocel 2300 Clear Sealant', baseUnitCost: 8.95, uom: 'EA' } + ] + }, + { + id: 'atlas_stormmaster_shake', + name: 'Atlas StormMaster Shake', + category: 'Roofing', + brand: 'Atlas', + description: [ + "Remove existing shingles down to deck.", + "Re-nail any loose wood.", + "Install 3' of Atlas WeatherMaster ice and water shield at all eaves and valleys.", + "Install Atlas Summit 60 Synthetic underlayment.", + "Install Atlas StormMaster Shake Impact Resistant Shingles." + ], + materials: [ + { desc: 'Atlas StormMaster Shake', baseUnitCost: 55.00, uom: 'SQ' }, + { desc: 'Atlas Pro-Cut Hip & Ridge', baseUnitCost: 78.50, uom: 'BD' }, + { desc: 'Atlas 6X Pro-Cut Starter', baseUnitCost: 70.80, uom: 'BD' }, + { desc: 'Atlas Summit 60 Synthetic Underlayment', baseUnitCost: 116.00, uom: 'RL' }, + { desc: 'Atlas WeatherMaster Ice & Water', baseUnitCost: 65.00, uom: 'RL' }, + { desc: 'Coil Nails 1 1/4"', baseUnitCost: 35.00, uom: 'BX' }, + { desc: 'Plastic Cap Nails', baseUnitCost: 28.50, uom: 'BX' }, + { desc: 'Geocel 2300 Clear Sealant', baseUnitCost: 8.95, uom: 'EA' } + ] + }, + { + id: 'certainteed_landmark', + name: 'CertainTeed Landmark AR', + category: 'Roofing', + brand: 'CertainTeed', + description: [ + "Remove existing shingles down to deck.", + "Re-nail any loose wood.", + "Install CertainTeed WinterGuard ice and water shield.", + "Install CertainTeed RoofRunner Synthetic underlayment.", + "Install CertainTeed Landmark AR Shingles." + ], + materials: [ + { desc: 'CertainTeed Landmark AR', baseUnitCost: 44.00, uom: 'SQ' }, + { desc: 'CertainTeed ShadowRidge', baseUnitCost: 82.00, uom: 'BD' }, + { desc: 'CertainTeed SwiftStart', baseUnitCost: 75.00, uom: 'BD' }, + { desc: 'CertainTeed RoofRunner', baseUnitCost: 125.00, uom: 'RL' }, + { desc: 'CertainTeed WinterGuard', baseUnitCost: 70.00, uom: 'RL' }, + { desc: 'Coil Nails 1 1/4"', baseUnitCost: 35.00, uom: 'BX' }, + { desc: 'Plastic Cap Nails', baseUnitCost: 28.50, uom: 'BX' }, + { desc: 'Geocel 2300 Clear Sealant', baseUnitCost: 8.95, uom: 'EA' } + ] + }, + { + id: 'certainteed_presidential_shake', + name: 'CertainTeed Presidential Shake TL', + category: 'Roofing', + brand: 'CertainTeed', + description: [ + "Remove existing shingles down to deck.", + "Re-nail any loose wood.", + "Install CertainTeed WinterGuard ice and water shield at all eaves and valleys.", + "Install CertainTeed RoofRunner Synthetic underlayment.", + "Install CertainTeed Presidential Shake TL triple-layer shingles." + ], + materials: [ + { desc: 'CertainTeed Presidential Shake TL', baseUnitCost: 72.00, uom: 'SQ' }, + { desc: 'CertainTeed Presidential Ridge', baseUnitCost: 92.00, uom: 'BD' }, + { desc: 'CertainTeed SwiftStart', baseUnitCost: 75.00, uom: 'BD' }, + { desc: 'CertainTeed RoofRunner', baseUnitCost: 125.00, uom: 'RL' }, + { desc: 'CertainTeed WinterGuard', baseUnitCost: 70.00, uom: 'RL' }, + { desc: 'Coil Nails 1 1/4"', baseUnitCost: 35.00, uom: 'BX' }, + { desc: 'Plastic Cap Nails', baseUnitCost: 28.50, uom: 'BX' }, + { desc: 'Geocel 2300 Clear Sealant', baseUnitCost: 8.95, uom: 'EA' } + ] + }, + { + id: 'gaf_timberline_hdz', + name: 'GAF Timberline HDZ', + category: 'Roofing', + brand: 'GAF', + description: [ + "Remove existing shingles down to deck.", + "Re-nail any loose wood.", + "Install GAF WeatherWatch ice and water shield at eaves, rakes, and valleys.", + "Install GAF FeltBuster Synthetic underlayment.", + "Install GAF Timberline HDZ Laminated Architectural Shingles." + ], + materials: [ + { desc: 'GAF Timberline HDZ', baseUnitCost: 45.00, uom: 'SQ' }, + { desc: 'GAF TimberTex Premium Ridge Cap', baseUnitCost: 85.00, uom: 'BD' }, + { desc: 'GAF Pro-Start Starter Strip', baseUnitCost: 68.00, uom: 'BD' }, + { desc: 'GAF FeltBuster Synthetic Underlayment', baseUnitCost: 110.00, uom: 'RL' }, + { desc: 'GAF WeatherWatch Ice & Water', baseUnitCost: 68.00, uom: 'RL' }, + { desc: 'Coil Nails 1 1/4"', baseUnitCost: 35.00, uom: 'BX' }, + { desc: 'Plastic Cap Nails', baseUnitCost: 28.50, uom: 'BX' }, + { desc: 'Geocel 2300 Clear Sealant', baseUnitCost: 8.95, uom: 'EA' } + ] + }, + { + id: 'gaf_timberline_ultra_hd', + name: 'GAF Timberline Ultra HD', + category: 'Roofing', + brand: 'GAF', + description: [ + "Remove existing shingles down to deck.", + "Re-nail any loose wood.", + "Install GAF WeatherWatch ice and water shield at eaves, rakes, and valleys.", + "Install GAF FeltBuster Synthetic underlayment.", + "Install GAF Timberline Ultra HD extra-thick dimensional shingles." + ], + materials: [ + { desc: 'GAF Timberline Ultra HD', baseUnitCost: 52.00, uom: 'SQ' }, + { desc: 'GAF TimberTex Premium Ridge Cap', baseUnitCost: 85.00, uom: 'BD' }, + { desc: 'GAF Pro-Start Starter Strip', baseUnitCost: 68.00, uom: 'BD' }, + { desc: 'GAF FeltBuster Synthetic Underlayment', baseUnitCost: 110.00, uom: 'RL' }, + { desc: 'GAF WeatherWatch Ice & Water', baseUnitCost: 68.00, uom: 'RL' }, + { desc: 'Coil Nails 1 1/4"', baseUnitCost: 35.00, uom: 'BX' }, + { desc: 'Plastic Cap Nails', baseUnitCost: 28.50, uom: 'BX' }, + { desc: 'Geocel 2300 Clear Sealant', baseUnitCost: 8.95, uom: 'EA' } + ] + }, + { + id: 'iko_dynasty', + name: 'IKO Dynasty', + category: 'Roofing', + brand: 'IKO', + description: [ + "Remove existing shingles down to deck.", + "Re-nail any loose wood.", + "Install IKO ArmourGard ice and water shield at eaves, rakes, and valleys.", + "Install IKO RoofGard-SB Synthetic underlayment.", + "Install IKO Dynasty Impact Resistant shingles." + ], + materials: [ + { desc: 'IKO Dynasty', baseUnitCost: 54.00, uom: 'SQ' }, + { desc: 'IKO Hip & Ridge 12', baseUnitCost: 80.00, uom: 'BD' }, + { desc: 'IKO Leading Edge Plus Starter Strip', baseUnitCost: 71.00, uom: 'BD' }, + { desc: 'IKO RoofGard-SB Synthetic Underlayment', baseUnitCost: 118.00, uom: 'RL' }, + { desc: 'IKO ArmourGard Ice & Water', baseUnitCost: 67.00, uom: 'RL' }, + { desc: 'Coil Nails 1 1/4"', baseUnitCost: 35.00, uom: 'BX' }, + { desc: 'Plastic Cap Nails', baseUnitCost: 28.50, uom: 'BX' }, + { desc: 'Geocel 2300 Clear Sealant', baseUnitCost: 8.95, uom: 'EA' } + ] + }, + { + id: 'malarkey_vista_ar', + name: 'Malarkey Vista AR', + category: 'Roofing', + brand: 'Malarkey', + description: [ + "Remove existing shingles down to deck.", + "Re-nail any loose wood.", + "Install Malarkey EverGuard ice and water shield at eaves and valleys.", + "Install Malarkey UDL 40 Synthetic underlayment.", + "Install Malarkey Vista AR Architectural Shingles." + ], + materials: [ + { desc: 'Malarkey Vista AR', baseUnitCost: 46.00, uom: 'SQ' }, + { desc: 'Malarkey Ridglass Hip & Ridge', baseUnitCost: 83.00, uom: 'BD' }, + { desc: 'Malarkey Windsor Starter Strip', baseUnitCost: 73.00, uom: 'BD' }, + { desc: 'Malarkey UDL 40 Synthetic Underlayment', baseUnitCost: 122.00, uom: 'RL' }, + { desc: 'Malarkey EverGuard Ice & Water', baseUnitCost: 69.00, uom: 'RL' }, + { desc: 'Coil Nails 1 1/4"', baseUnitCost: 35.00, uom: 'BX' }, + { desc: 'Plastic Cap Nails', baseUnitCost: 28.50, uom: 'BX' }, + { desc: 'Geocel 2300 Clear Sealant', baseUnitCost: 8.95, uom: 'EA' } + ] + }, + { + id: 'owens_corning_duration', + name: 'Owens Corning Duration', + category: 'Roofing', + brand: 'Owens Corning', + description: [ + "Remove existing shingles down to deck.", + "Inspect and re-nail decking.", + "Install Owens Corning WeatherLock G ice and water shield.", + "Install Owens Corning ProArmor Synthetic underlayment.", + "Install Owens Corning TruDefinition Duration Shingles." + ], + materials: [ + { desc: 'OC TruDefinition Duration', baseUnitCost: 46.50, uom: 'SQ' }, + { desc: 'OC ProEdge Hip & Ridge', baseUnitCost: 88.00, uom: 'BD' }, + { desc: 'OC Starter Strip Plus', baseUnitCost: 78.00, uom: 'BD' }, + { desc: 'OC ProArmor Underlayment', baseUnitCost: 130.00, uom: 'RL' }, + { desc: 'OC WeatherLock G', baseUnitCost: 75.00, uom: 'RL' }, + { desc: 'Coil Nails 1 1/4"', baseUnitCost: 35.00, uom: 'BX' }, + { desc: 'Plastic Cap Nails', baseUnitCost: 28.50, uom: 'BX' }, + { desc: 'Geocel 2300 Clear Sealant', baseUnitCost: 8.95, uom: 'EA' } + ] + }, + { + id: 'mulehide_epdm', + name: 'Mulehide .060 EPDM System', + category: 'Roofing', + brand: 'Mulehide', + description: [ + "Remove existing flat roof membrane down to deck.", + "Inspect and repair decking as needed.", + "Install Mulehide .060 EPDM membrane fully adhered system.", + "Install Mulehide bonding adhesive.", + "Install drip edge and termination bars at perimeter." + ], + materials: [ + { desc: 'Mulehide .060 EPDM Membrane', baseUnitCost: 85.00, uom: 'SQ' }, + { desc: 'Mulehide Bonding Adhesive', baseUnitCost: 48.00, uom: 'EA' }, + { desc: 'Mulehide EPDM Lap Sealant', baseUnitCost: 12.00, uom: 'EA' }, + { desc: 'Mulehide Termination Bar', baseUnitCost: 4.50, uom: 'LF' }, + { desc: 'Coil Nails 1 1/4"', baseUnitCost: 35.00, uom: 'BX' }, + { desc: 'Geocel 2300 Clear Sealant', baseUnitCost: 8.95, uom: 'EA' } + ] + }, +]; + +// --------------------------------------------------------------------------- +// MOCK ESTIMATES +// --------------------------------------------------------------------------- + +const MOCK_ESTIMATES_INITIAL = [ + { + id: 'est_001', + clientName: 'Robert Chambers', + address: '2612 Dunwick Dr, Plano, TX 75023', + date: '2026-03-28', + status: 'Approved', + total: 18450.00, + templateId: 'certainteed_landmark', + templateName: 'CertainTeed Landmark AR', + estimatedBy: 'Marcus Webb', + roofArea: 28.4, + notes: 'Hail damage from March 15 storm. Insurance claim approved.', + }, + { + id: 'est_002', + clientName: 'Sandra Kim', + address: '6613 Phoenix Pl, Plano, TX 75023', + date: '2026-03-25', + status: 'Sent', + total: 22100.00, + templateId: 'gaf_timberline_hdz', + templateName: 'GAF Timberline HDZ', + estimatedBy: 'Kenji Flores', + roofArea: 32.1, + notes: 'Full replacement. Customer wants upgraded ridge cap.', + }, + { + id: 'est_003', + clientName: 'David Okonkwo', + address: '3913 Arizona Pl, Plano, TX 75023', + date: '2026-03-22', + status: 'Draft', + total: 14800.00, + templateId: 'atlas_glassmaster', + templateName: 'Atlas GlassMaster', + estimatedBy: 'Marcus Webb', + roofArea: 24.6, + notes: null, + }, + { + id: 'est_004', + clientName: 'Patricia Hollis', + address: '3905 Sailmaker Ln, Plano, TX 75023', + date: '2026-03-18', + status: 'Approved', + total: 31200.00, + templateId: 'certainteed_presidential_shake', + templateName: 'CertainTeed Presidential Shake TL', + estimatedBy: 'Aisha Patel', + roofArea: 38.7, + notes: 'Premium upgrade. Customer requested Presidential Shake for curb appeal.', + }, + { + id: 'est_005', + clientName: 'James Thornton', + address: '6909 Custer Rd, Plano, TX 75023', + date: '2026-03-14', + status: 'Rejected', + total: 28750.00, + templateId: 'gaf_timberline_ultra_hd', + templateName: 'GAF Timberline Ultra HD', + estimatedBy: 'Kenji Flores', + roofArea: 35.2, + notes: 'Customer rejected — going with competitor. Follow up in 30 days.', + }, + { + id: 'est_006', + clientName: 'Maria Gonzalez', + address: '2608 Dunwick Dr, Plano, TX 75023', + date: '2026-03-10', + status: 'Approved', + total: 16900.00, + templateId: 'atlas_stormmaster_shake', + templateName: 'Atlas StormMaster Shake', + estimatedBy: 'Marcus Webb', + roofArea: 26.8, + notes: 'Impact resistant upgrade. HOA approved color: Weathered Wood.', + }, + { + id: 'est_007', + clientName: 'Thomas Reeves', + address: '7224 Independence Pkwy, Plano, TX 75025', + date: '2026-03-05', + status: 'Sent', + total: 54200.00, + templateId: 'mulehide_epdm', + templateName: 'Mulehide .060 EPDM System', + estimatedBy: 'Aisha Patel', + roofArea: 62.0, + notes: 'Commercial flat roof. 3 HVAC curb flashings included.', + }, + { + id: 'est_008', + clientName: 'Denise Watkins', + address: '3901 Sailmaker Ln, Plano, TX 75023', + date: '2026-02-28', + status: 'Expired', + total: 19350.00, + templateId: 'iko_dynasty', + templateName: 'IKO Dynasty', + estimatedBy: 'Kenji Flores', + roofArea: 29.1, + notes: 'Estimate expired — no response after 3 follow-ups. Reactivate on request.', + }, + { + id: 'est_009', + clientName: 'Calvin Monroe', + address: '2100 Legacy Dr, Plano, TX 75023', + date: '2026-02-20', + status: 'Approved', + total: 11600.00, + templateId: 'owens_corning_duration', + templateName: 'Owens Corning Duration', + estimatedBy: 'Marcus Webb', + roofArea: 18.9, + notes: 'Apartment unit — common area roof section only.', + }, + { + id: 'est_010', + clientName: 'Brenda Castillo', + address: '6601 Phoenix Pl, Plano, TX 75023', + date: '2026-02-14', + status: 'Draft', + total: 17250.00, + templateId: 'malarkey_vista_ar', + templateName: 'Malarkey Vista AR', + estimatedBy: 'Aisha Patel', + roofArea: 27.3, + notes: null, + }, +]; + // --- CONTEXT SETUP --- const MockStoreContext = createContext(); @@ -3217,6 +3637,8 @@ export const MockStoreProvider = ({ children }) => { const [vendorInvoices, setVendorInvoices] = useState(MOCK_VENDOR_INVOICES); const [leads, setLeads] = useState([]); const [dispatchLeads, setDispatchLeads] = useState(DISPATCH_LEADS_INITIAL); + const [templates, setTemplates] = useState(MASTER_TEMPLATES_INITIAL); + const [estimates, setEstimates] = useState(MOCK_ESTIMATES_INITIAL); // Initialize properties once useEffect(() => { @@ -3317,6 +3739,31 @@ export const MockStoreProvider = ({ children }) => { return newLead; }, + // Templates + templates, + addTemplate: (tpl) => { + const newTpl = { ...tpl, id: `tpl_${Date.now()}` }; + setTemplates(prev => [...prev, newTpl]); + return newTpl; + }, + updateTemplate: (id, data) => { + setTemplates(prev => prev.map(t => t.id === id ? { ...t, ...data } : t)); + }, + deleteTemplate: (id) => { + setTemplates(prev => prev.filter(t => t.id !== id)); + }, + + // Estimates + estimates, + addEstimate: (est) => { + const newEst = { ...est, id: `est_${Date.now()}`, date: new Date().toISOString().slice(0, 10) }; + setEstimates(prev => [newEst, ...prev]); + return newEst; + }, + updateEstimate: (id, data) => { + setEstimates(prev => prev.map(e => e.id === id ? { ...e, ...data } : e)); + }, + // LynkDispatch dispatchLeads, assignDispatchLead: (leadId, repId) => { diff --git a/src/pages/EstimatesPage.jsx b/src/pages/EstimatesPage.jsx new file mode 100644 index 0000000..32ac7ba --- /dev/null +++ b/src/pages/EstimatesPage.jsx @@ -0,0 +1,553 @@ +import React, { useState, useMemo } from 'react'; +import ReactDOM from 'react-dom'; +import { useNavigate } from 'react-router-dom'; +import { motion, AnimatePresence } from 'framer-motion'; +import { useTheme } from '../context/ThemeContext'; +import { useAuth, ROLES } from '../context/AuthContext'; +import { useMockStore } from '../data/mockStore'; +import { + Plus, Search, FileText, Layers, Calendar, DollarSign, + User, CheckCircle, Clock, XCircle, AlertCircle, Send, + Pencil, Trash2, Copy, ChevronRight, Tag, Package +} from 'lucide-react'; +import { toast } from 'sonner'; +import TemplateEditorModal from '../components/estimates/TemplateEditorModal'; +import EstimateDetailModal from '../components/estimates/EstimateDetailModal'; + +// --------------------------------------------------------------------------- +// Status config — estimates +// --------------------------------------------------------------------------- +const ESTIMATE_STATUS = { + Draft: { label: 'Draft', icon: Clock, bg: 'bg-zinc-100 dark:bg-zinc-700', text: 'text-zinc-600 dark:text-zinc-300' }, + Sent: { label: 'Sent', icon: Send, bg: 'bg-blue-100 dark:bg-blue-500/20', text: 'text-blue-700 dark:text-blue-400' }, + Approved: { label: 'Approved', icon: CheckCircle, bg: 'bg-emerald-100 dark:bg-emerald-500/20',text: 'text-emerald-700 dark:text-emerald-400' }, + Rejected: { label: 'Rejected', icon: XCircle, bg: 'bg-red-100 dark:bg-red-500/20', text: 'text-red-700 dark:text-red-400' }, + Expired: { label: 'Expired', icon: AlertCircle, bg: 'bg-amber-100 dark:bg-amber-500/20', text: 'text-amber-700 dark:text-amber-400' }, +}; + +function StatusBadge({ status }) { + const cfg = ESTIMATE_STATUS[status] ?? ESTIMATE_STATUS.Draft; + const Icon = cfg.icon; + return ( + + + {cfg.label} + + ); +} + +// --------------------------------------------------------------------------- +// Category color +// --------------------------------------------------------------------------- +const CATEGORY_COLOR = { + Roofing: { bg: 'bg-blue-100 dark:bg-blue-500/20', text: 'text-blue-700 dark:text-blue-400' }, + Siding: { bg: 'bg-amber-100 dark:bg-amber-500/20', text: 'text-amber-700 dark:text-amber-400' }, + Gutters: { bg: 'bg-cyan-100 dark:bg-cyan-500/20', text: 'text-cyan-700 dark:text-cyan-400' }, + Windows: { bg: 'bg-purple-100 dark:bg-purple-500/20',text: 'text-purple-700 dark:text-purple-400' }, + Insulation: { bg: 'bg-emerald-100 dark:bg-emerald-500/20',text: 'text-emerald-700 dark:text-emerald-400' }, + Other: { bg: 'bg-zinc-100 dark:bg-zinc-700', text: 'text-zinc-600 dark:text-zinc-300' }, +}; + +function CategoryBadge({ category }) { + const cfg = CATEGORY_COLOR[category] ?? CATEGORY_COLOR.Other; + return ( + + {category} + + ); +} + +// --------------------------------------------------------------------------- +// Estimate Card +// --------------------------------------------------------------------------- +function EstimateCard({ estimate, index, onClick }) { + return ( + +
+
+

{estimate.clientName}

+

{estimate.address}

+
+ +
+ +
+ + + {estimate.date} + + + + {estimate.estimatedBy} + + {estimate.templateName && ( + + + {estimate.templateName} + + )} +
+ +
+ + ${estimate.total.toLocaleString('en-US', { minimumFractionDigits: 2 })} + + + View + +
+ + {estimate.notes && ( +

+ {estimate.notes} +

+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Template Card (Owner: editable; others: read-only) +// --------------------------------------------------------------------------- +function TemplateCard({ template, isOwner, onEdit, onDuplicate, onDelete, index }) { + const catCfg = CATEGORY_COLOR[template.category] ?? CATEGORY_COLOR.Other; + + return ( + + {/* Header */} +
+
+ +
+
+

{template.name}

+
+ + + {template.brand} + +
+
+
+ + {/* Description preview */} + {template.description?.length > 0 && ( +

+ {template.description[0]} +

+ )} + + {/* Material count */} +
+ + {template.materials?.length || 0} material line items +
+ + {/* Actions — Owner only */} + {isOwner && ( +
+ + + +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Delete confirm modal +// --------------------------------------------------------------------------- +function DeleteConfirmModal({ template, onConfirm, onCancel }) { + if (!template) return null; + return ReactDOM.createPortal( +
+ e.stopPropagation()} + > +
+ +
+

Delete Template

+

+ Are you sure you want to delete {template.name}? This cannot be undone. +

+
+ + +
+
+
, + document.body + ); +} + +// --------------------------------------------------------------------------- +// Main Page +// --------------------------------------------------------------------------- +export default function EstimatesPage() { + const navigate = useNavigate(); + const { theme } = useTheme(); + const { user } = useAuth(); + const { estimates, templates, addTemplate, updateTemplate, deleteTemplate } = useMockStore(); + + const isOwner = user?.role === ROLES.OWNER; + const basePath = user?.role === 'OWNER' ? '/owner' : user?.role === 'ADMIN' ? '/admin' : '/emp/fa'; + + const [activeTab, setActiveTab] = useState('estimates'); // 'estimates' | 'templates' + const [estimateSearch, setEstimateSearch] = useState(''); + const [estimateStatusFilter, setEstimateStatusFilter] = useState('All'); + const [templateSearch, setTemplateSearch] = useState(''); + const [templateCategoryFilter, setTemplateCategoryFilter] = useState('All'); + + // Estimate detail modal + const [detailEstimate, setDetailEstimate] = useState(null); + + // Template editor modal state + const [editorOpen, setEditorOpen] = useState(false); + const [editorMode, setEditorMode] = useState('create'); + const [editorTemplate, setEditorTemplate] = useState(null); + + // Delete confirm + const [deleteTarget, setDeleteTarget] = useState(null); + + // ---- filtered estimates ---- + const filteredEstimates = useMemo(() => { + return estimates.filter(e => { + const matchesSearch = + e.clientName.toLowerCase().includes(estimateSearch.toLowerCase()) || + e.address.toLowerCase().includes(estimateSearch.toLowerCase()) || + (e.templateName || '').toLowerCase().includes(estimateSearch.toLowerCase()); + const matchesStatus = estimateStatusFilter === 'All' || e.status === estimateStatusFilter; + return matchesSearch && matchesStatus; + }); + }, [estimates, estimateSearch, estimateStatusFilter]); + + // ---- filtered templates ---- + const filteredTemplates = useMemo(() => { + return templates.filter(t => { + const matchesSearch = + t.name.toLowerCase().includes(templateSearch.toLowerCase()) || + (t.brand || '').toLowerCase().includes(templateSearch.toLowerCase()); + const matchesCat = templateCategoryFilter === 'All' || t.category === templateCategoryFilter; + return matchesSearch && matchesCat; + }); + }, [templates, templateSearch, templateCategoryFilter]); + + // ---- template editor actions ---- + const openCreate = () => { setEditorMode('create'); setEditorTemplate(null); setEditorOpen(true); }; + const openEdit = (tpl) => { setEditorMode('edit'); setEditorTemplate(tpl); setEditorOpen(true); }; + const openDuplicate = (tpl) => { setEditorMode('duplicate'); setEditorTemplate(tpl); setEditorOpen(true); }; + + const handleEditorSave = (data, saveMode) => { + if (editorMode === 'create') { + addTemplate(data); + toast.success('Template created successfully'); + } else if (editorMode === 'edit') { + updateTemplate(editorTemplate.id, data); + toast.success('Template updated'); + } else if (editorMode === 'duplicate') { + if (saveMode === 'replace') { + updateTemplate(editorTemplate.id, data); + toast.success('Template replaced'); + } else { + addTemplate(data); + toast.success('Template saved as new copy'); + } + } + setEditorOpen(false); + }; + + const handleDeleteConfirm = () => { + if (deleteTarget) { + deleteTemplate(deleteTarget.id); + toast.success(`"${deleteTarget.name}" deleted`); + setDeleteTarget(null); + } + }; + + // ---- stats ---- + const stats = useMemo(() => { + const total = estimates.length; + const approved = estimates.filter(e => e.status === 'Approved').length; + const totalValue = estimates.filter(e => e.status === 'Approved').reduce((s, e) => s + e.total, 0); + const pending = estimates.filter(e => e.status === 'Sent' || e.status === 'Draft').length; + return { total, approved, totalValue, pending }; + }, [estimates]); + + const TABS = [ + { key: 'estimates', label: 'Estimates', count: estimates.length }, + { key: 'templates', label: 'Templates', count: templates.length }, + ]; + + const ESTIMATE_STATUSES = ['All', 'Draft', 'Sent', 'Approved', 'Rejected', 'Expired']; + const TEMPLATE_CATEGORIES_FILTER = ['All', 'Roofing', 'Siding', 'Gutters', 'Windows', 'Insulation', 'Other']; + + return ( +
+
+ + {/* Page header */} +
+
+

+ Estimates +

+

Manage estimates and master templates

+
+ +
+ + {/* KPI strip */} +
+ {[ + { label: 'Total Estimates', value: stats.total, icon: FileText, color: 'text-blue-600 dark:text-blue-400', bg: 'bg-blue-50 dark:bg-blue-500/10' }, + { label: 'Approved', value: stats.approved, icon: CheckCircle, color: 'text-emerald-600 dark:text-emerald-400', bg: 'bg-emerald-50 dark:bg-emerald-500/10' }, + { label: 'Pending', value: stats.pending, icon: Clock, color: 'text-amber-600 dark:text-amber-400', bg: 'bg-amber-50 dark:bg-amber-500/10' }, + { label: 'Approved Value', value: `$${(stats.totalValue / 1000).toFixed(0)}k`, icon: DollarSign, color: 'text-purple-600 dark:text-purple-400', bg: 'bg-purple-50 dark:bg-purple-500/10' }, + ].map(s => { + const Icon = s.icon; + return ( +
+
+ +
+
+

{s.label}

+

{s.value}

+
+
+ ); + })} +
+ + {/* Tabs */} +
+ {TABS.map(tab => ( + + ))} +
+ + {/* ===== ESTIMATES TAB ===== */} + + {activeTab === 'estimates' && ( + + {/* Filters */} +
+
+ + setEstimateSearch(e.target.value)} + placeholder="Search by client, address, or template..." + className="w-full pl-9 pr-4 py-2.5 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 text-sm outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500 transition-shadow" + /> +
+
+ {ESTIMATE_STATUSES.map(s => ( + + ))} +
+
+ + {filteredEstimates.length === 0 ? ( +
+ +

No estimates found

+

Try adjusting your search or filters

+
+ ) : ( +
+ {filteredEstimates.map((est, i) => ( + setDetailEstimate(est)} /> + ))} +
+ )} +
+ )} + + {/* ===== TEMPLATES TAB ===== */} + {activeTab === 'templates' && ( + + {/* Filters + create button */} +
+
+ + setTemplateSearch(e.target.value)} + placeholder="Search templates by name or brand..." + className="w-full pl-9 pr-4 py-2.5 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 text-sm outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500 transition-shadow" + /> +
+
+ {TEMPLATE_CATEGORIES_FILTER.map(c => ( + + ))} + {isOwner && ( + + )} +
+
+ + {!isOwner && ( +
+ Templates are managed by the Owner. You can apply these when creating a new estimate. +
+ )} + + {filteredTemplates.length === 0 ? ( +
+ +

No templates found

+ {isOwner && ( + + )} +
+ ) : ( +
+ {filteredTemplates.map((tpl, i) => ( + + ))} +
+ )} +
+ )} +
+
+ + {/* Estimate detail drawer */} + setDetailEstimate(null)} + estimate={detailEstimate} + /> + + {/* Template editor modal */} + setEditorOpen(false)} + onSave={handleEditorSave} + mode={editorMode} + template={editorTemplate} + /> + + {/* Delete confirm */} + + {deleteTarget && ( + setDeleteTarget(null)} + /> + )} + +
+ ); +}