feat(estimates): add EstimatesPage hub with templates tab, estimate detail drawer, and template editor

- EstimatesPage: tabbed hub with Estimates and Templates tabs, KPI strip, search + filter
- EstimateDetailModal: right-side drawer showing scope of work, computed line items, and financial summary from template + roof area
- TemplateEditorModal: create/edit/duplicate templates with segmented mode toggle
- Owner-only template management (create, edit, duplicate, delete); Admin/FA read-only view
- Duplicate mode: toggle between 'Save as new copy' (default, editable name) and 'Replace original'
- MASTER_TEMPLATES and MOCK_ESTIMATES moved into mockStore with full CRUD helpers
- TemplateSelectionModal now reads from mockStore instead of local stub
- Sidebar nav updated to point to /estimates hub for all roles
- Routes added: /emp/fa/estimates, /admin/estimates, /owner/estimates
This commit is contained in:
Satyam-Rastogi
2026-04-01 16:13:36 +05:30
parent 9be8ec911f
commit 8f9cb9dd48
7 changed files with 1690 additions and 5 deletions
+22
View File
@@ -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() {
</ProtectedRoute>
}
/>
<Route
path="/emp/fa/estimates"
element={
<ProtectedRoute allowedRoles={['FIELD_AGENT', 'ADMIN', 'OWNER']}>
<EstimatesPage />
</ProtectedRoute>
}
/>
<Route
path="/emp/fa/estimate"
element={
@@ -166,6 +175,11 @@ function App() {
<ProCanvas />
</ProtectedRoute>
} />
<Route path="/owner/estimates" element={
<ProtectedRoute allowedRoles={['OWNER']}>
<EstimatesPage />
</ProtectedRoute>
} />
<Route path="/owner/estimate" element={
<ProtectedRoute allowedRoles={['OWNER']}>
<EstimateBuilder />
@@ -230,6 +244,14 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/admin/estimates"
element={
<ProtectedRoute allowedRoles={['ADMIN', 'OWNER']}>
<EstimatesPage />
</ProtectedRoute>
}
/>
<Route
path="/admin/estimate"
element={
+3 -3
View File
@@ -145,7 +145,7 @@ const Layout = () => {
icon: LayoutDashboard,
label: <span><span className="text-[#fda913]">Pro</span>Canvas</span>
},
{ 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: <span><span className="text-[#fda913]">Pro</span>Canvas</span>
},
{ 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: <span><span className="text-[#fda913]">Pro</span>Canvas</span>
},
{ to: "/emp/fa/estimate", icon: Calculator, label: "Estimate Builder" },
{ to: "/emp/fa/estimates", icon: Calculator, label: "Estimates" },
...commonItems,
];
default: // Customer or Fallback
@@ -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 (
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-bold uppercase tracking-wider ${cfg.bg} ${cfg.text}`}>
<Icon size={11} /> {status}
</span>
);
}
// ---------------------------------------------------------------------------
// 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 (
<div className="mb-5">
<div className={`px-3 py-2 rounded-t-xl border-b border-zinc-100 dark:border-white/5 ${accentClass}`}>
<span className="text-xs font-bold uppercase tracking-widest text-zinc-700 dark:text-zinc-200">{title}</span>
</div>
<div className="border border-zinc-100 dark:border-white/[0.06] rounded-b-xl overflow-hidden">
<div className="hidden sm:grid grid-cols-[1fr_50px_60px_90px_90px] gap-0 px-3 py-1.5 bg-zinc-50 dark:bg-zinc-800/30 text-[10px] font-semibold text-zinc-400 uppercase tracking-wider">
<span>Description</span>
<span className="text-right">Qty</span>
<span className="text-right">UOM</span>
<span className="text-right">Unit</span>
<span className="text-right">Client Price</span>
</div>
<div className="divide-y divide-zinc-100 dark:divide-white/[0.04]">
{items.map((item, i) => (
<div key={i} className="flex flex-col sm:grid sm:grid-cols-[1fr_50px_60px_90px_90px] gap-0 px-3 py-2 text-xs hover:bg-zinc-50 dark:hover:bg-white/[0.02] transition-colors">
<span className="text-zinc-700 dark:text-zinc-200 font-medium">{item.desc}</span>
<span className="text-right text-zinc-500 dark:text-zinc-400">{item.qty}</span>
<span className="text-right text-zinc-400">{item.uom}</span>
<span className="text-right text-zinc-500 dark:text-zinc-400">${item.unitCost.toFixed(2)}</span>
<span className="text-right font-semibold text-zinc-800 dark:text-zinc-100">${item.clientPrice.toLocaleString('en-US', { minimumFractionDigits: 2 })}</span>
</div>
))}
</div>
<div className="flex justify-end px-3 py-2 bg-zinc-50 dark:bg-zinc-800/30 border-t border-zinc-100 dark:border-white/[0.06]">
<span className="text-xs font-bold text-zinc-700 dark:text-zinc-200">
Subtotal: ${subtotal.toLocaleString('en-US', { minimumFractionDigits: 2 })}
</span>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// 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(
<AnimatePresence>
{isOpen && estimate && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-[60] bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Drawer */}
<motion.div
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', stiffness: 320, damping: 32 }}
className="fixed right-0 top-0 bottom-0 z-[61] w-full max-w-2xl bg-white dark:bg-zinc-900 shadow-2xl flex flex-col"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="shrink-0 flex items-start justify-between gap-4 px-5 py-4 border-b border-zinc-100 dark:border-white/[0.07]" style={{ borderTopColor: '#3B82F6', borderTopWidth: 3 }}>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1">
<h2 className="text-base font-bold text-zinc-900 dark:text-white">{estimate.clientName}</h2>
<StatusBadge status={estimate.status} />
</div>
<p className="text-xs text-zinc-400 flex items-center gap-1">
<MapPin size={11} className="shrink-0" /> {estimate.address}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => { onClose(); navigate(`${basePath}/estimate`); }}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-xs font-semibold transition-colors"
>
<Maximize2 size={12} /> Open in Builder
</button>
<button
onClick={onClose}
className="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors"
>
<X size={16} />
</button>
</div>
</div>
{/* Scrollable body */}
<div className="flex-1 overflow-y-auto custom-scrollbar">
<div className="p-5 space-y-5">
{/* Meta chips */}
<div className="flex flex-wrap gap-3">
{[
{ 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 (
<div key={m.label} className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-zinc-100 dark:bg-zinc-800 text-xs text-zinc-600 dark:text-zinc-300">
<Icon size={11} className="text-zinc-400 shrink-0" />
{m.label}
</div>
);
})}
</div>
{/* Notes */}
{estimate.notes && (
<div className="px-4 py-3 rounded-xl bg-amber-50 dark:bg-amber-500/10 border border-amber-100 dark:border-amber-500/20 text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
{estimate.notes}
</div>
)}
{/* Scope of work */}
{template?.description?.length > 0 && (
<div>
<h3 className="text-xs font-bold uppercase tracking-widest text-zinc-400 dark:text-zinc-500 mb-2">Scope of Work</h3>
<ul className="space-y-1.5">
{template.description.map((line, i) => (
<li key={i} className="flex items-start gap-2 text-xs text-zinc-600 dark:text-zinc-300">
<ChevronRight size={12} className="shrink-0 mt-0.5 text-blue-400" />
{line}
</li>
))}
</ul>
</div>
)}
{/* Line items */}
{materials.length > 0 && (
<LineItemTable
title="Materials"
items={materials}
accentClass="bg-blue-50 dark:bg-blue-500/10"
/>
)}
{labor.length > 0 && (
<LineItemTable
title="Labor"
items={labor}
accentClass="bg-purple-50 dark:bg-purple-500/10"
/>
)}
{/* No template fallback */}
{!template && (
<div className="text-center py-8 text-zinc-400 dark:text-zinc-600">
<Package size={28} className="mx-auto mb-2 opacity-40" />
<p className="text-sm">No template linked open in Builder to view line items.</p>
</div>
)}
</div>
</div>
{/* Footer — financial summary */}
{(materials.length > 0 || labor.length > 0) && (
<div className="shrink-0 border-t border-zinc-100 dark:border-white/[0.07] px-5 py-4 bg-white dark:bg-zinc-900">
<div className="flex flex-col gap-1.5">
<div className="flex justify-between text-xs text-zinc-500 dark:text-zinc-400">
<span>Materials subtotal</span>
<span>${matSubtotal.toLocaleString('en-US', { minimumFractionDigits: 2 })}</span>
</div>
<div className="flex justify-between text-xs text-zinc-500 dark:text-zinc-400">
<span>Labor subtotal</span>
<span>${laborSubtotal.toLocaleString('en-US', { minimumFractionDigits: 2 })}</span>
</div>
<div className="flex justify-between text-sm font-bold text-zinc-900 dark:text-white pt-1.5 border-t border-zinc-100 dark:border-white/[0.07]">
<span>Total (Client Price)</span>
<span>${grandTotal.toLocaleString('en-US', { minimumFractionDigits: 2 })}</span>
</div>
{estimate.total !== grandTotal && (
<p className="text-[10px] text-zinc-400 dark:text-zinc-600 mt-0.5">
Saved estimate total: ${estimate.total.toLocaleString('en-US', { minimumFractionDigits: 2 })}
</p>
)}
</div>
</div>
)}
</motion.div>
</>
)}
</AnimatePresence>,
document.body
);
}
@@ -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(
<div className="fixed inset-0 z-[70] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div
className="bg-white dark:bg-zinc-900 w-full max-w-2xl rounded-2xl shadow-2xl border border-zinc-200 dark:border-white/10 flex flex-col max-h-[92vh]"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-white/10 shrink-0">
<div>
<h2 className="text-lg font-bold text-zinc-900 dark:text-white tracking-tight">{title}</h2>
{mode === 'duplicate' && (
<p className="text-xs text-zinc-400 mt-0.5">Choose whether to save as a new copy or replace the original.</p>
)}
</div>
<button onClick={onClose} className="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors">
<X size={18} />
</button>
</div>
<div className="overflow-y-auto flex-1 custom-scrollbar">
<div className="p-6 space-y-6">
{/* Duplicate mode toggle */}
{mode === 'duplicate' && (
<div className="flex items-center gap-1 p-1 bg-zinc-100 dark:bg-zinc-800 rounded-xl w-fit">
{[
{ value: 'new', label: 'Save as new copy' },
{ value: 'replace', label: 'Replace original' },
].map(opt => (
<button
key={opt.value}
onClick={() => {
setSaveMode(opt.value);
if (opt.value === 'replace') {
setField('name', template?.name || '');
} else {
setField('name', `Copy of ${template?.name || ''}`);
}
}}
className={`px-4 py-1.5 rounded-lg text-sm font-medium transition-all ${
saveMode === opt.value
? 'bg-white dark:bg-zinc-700 text-zinc-900 dark:text-white shadow-sm'
: 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200'
}`}
>
{opt.label}
</button>
))}
</div>
)}
{/* Basic Info */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="sm:col-span-2">
<label className="block text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider mb-1.5">
Template Name <span className="text-red-500">*</span>
</label>
<input
value={form.name}
onChange={e => {
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 && <p className="text-xs text-red-500 mt-1">{errors.name}</p>}
</div>
<div>
<label className="block text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider mb-1.5">Category</label>
<select
value={form.category}
onChange={e => setField('category', e.target.value)}
className="w-full px-3.5 py-2.5 rounded-xl 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"
>
{TEMPLATE_CATEGORIES.map(c => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider mb-1.5">
Brand <span className="text-red-500">*</span>
</label>
<input
value={form.brand}
onChange={e => {
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 && <p className="text-xs text-red-500 mt-1">{errors.brand}</p>}
</div>
</div>
{/* Scope / Description */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
Scope of Work <span className="text-red-500">*</span>
</label>
<button
onClick={addDesc}
className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium transition-colors"
>
<Plus size={13} /> Add line
</button>
</div>
{errors.description && <p className="text-xs text-red-500 mb-2">{errors.description}</p>}
<div className="space-y-2">
{form.description.map((line, i) => (
<div key={i} className="flex items-center gap-2">
<span className="text-zinc-300 dark:text-zinc-600 text-sm font-mono shrink-0">{i + 1}.</span>
<input
value={line}
onChange={e => {
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"
/>
<button
onClick={() => removeDesc(i)}
disabled={form.description.length <= 1}
className="p-1.5 rounded-lg text-zinc-300 dark:text-zinc-600 hover:text-red-500 dark:hover:text-red-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
</div>
{/* Materials */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
Material Line Items <span className="text-red-500">*</span>
</label>
<button
onClick={addMat}
className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium transition-colors"
>
<Plus size={13} /> Add material
</button>
</div>
{(errors.materials || errors.cost) && (
<p className="text-xs text-red-500 mb-2">{errors.materials || errors.cost}</p>
)}
{/* Table header */}
<div className="hidden sm:grid grid-cols-[1fr_80px_90px_36px] gap-2 px-2 mb-1">
<span className="text-[10px] font-semibold text-zinc-400 uppercase tracking-wider">Description</span>
<span className="text-[10px] font-semibold text-zinc-400 uppercase tracking-wider">UOM</span>
<span className="text-[10px] font-semibold text-zinc-400 uppercase tracking-wider">Base Cost</span>
<span />
</div>
<div className="space-y-2">
{form.materials.map((mat) => (
<div key={mat.id} className="flex flex-col sm:grid sm:grid-cols-[1fr_80px_90px_36px] gap-2 p-2 rounded-xl bg-zinc-50 dark:bg-zinc-800/50 border border-zinc-100 dark:border-zinc-700/50">
<input
value={mat.desc}
onChange={e => {
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"
/>
<select
value={mat.uom}
onChange={e => setMat(mat.id, 'uom', e.target.value)}
className="px-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"
>
{UOM_OPTIONS.map(u => <option key={u} value={u}>{u}</option>)}
</select>
<div className="relative">
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-400 text-sm">$</span>
<input
type="number"
min="0"
step="0.01"
value={mat.baseUnitCost}
onChange={e => {
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"
/>
</div>
<button
onClick={() => removeMat(mat.id)}
disabled={form.materials.length <= 1}
className="flex items-center justify-center p-1.5 rounded-lg text-zinc-300 dark:text-zinc-600 hover:text-red-500 dark:hover:text-red-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center gap-3 px-6 py-4 border-t border-zinc-200 dark:border-white/10 shrink-0 bg-white dark:bg-zinc-900">
<button
onClick={onClose}
className="px-5 py-2 text-sm font-medium text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-white/10 rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
className="ml-auto px-6 py-2 text-sm font-semibold rounded-xl bg-blue-600 hover:bg-blue-700 text-white transition-colors shadow-sm"
>
{mode === 'create' ? 'Create Template' : mode === 'edit' ? 'Save Changes' : saveMode === 'replace' ? 'Replace Original' : 'Save as Copy'}
</button>
</div>
</div>
</div>,
document.body
);
}
@@ -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 */}
<div className="px-4 py-3 bg-blue-50/50 dark:bg-blue-900/10 border-b border-zinc-200 dark:border-white/10">
<h3 className="text-sm font-semibold text-blue-600 dark:text-blue-400">
Roofing ({filtered.length})
Templates ({filtered.length})
</h3>
</div>
+447
View File
@@ -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) => {
+553
View File
@@ -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 (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider ${cfg.bg} ${cfg.text}`}>
<Icon size={10} />
{cfg.label}
</span>
);
}
// ---------------------------------------------------------------------------
// 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 (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider ${cfg.bg} ${cfg.text}`}>
{category}
</span>
);
}
// ---------------------------------------------------------------------------
// Estimate Card
// ---------------------------------------------------------------------------
function EstimateCard({ estimate, index, onClick }) {
return (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.04 }}
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/[0.07] rounded-2xl p-5 hover:shadow-md dark:hover:shadow-black/20 hover:border-zinc-300 dark:hover:border-white/[0.12] transition-all group cursor-pointer"
onClick={onClick}
>
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-zinc-900 dark:text-white text-sm truncate">{estimate.clientName}</h3>
<p className="text-xs text-zinc-400 truncate mt-0.5">{estimate.address}</p>
</div>
<StatusBadge status={estimate.status} />
</div>
<div className="flex flex-wrap gap-x-4 gap-y-2 text-xs text-zinc-500 dark:text-zinc-400 mb-3">
<span className="flex items-center gap-1">
<Calendar size={11} className="shrink-0" />
{estimate.date}
</span>
<span className="flex items-center gap-1">
<User size={11} className="shrink-0" />
{estimate.estimatedBy}
</span>
{estimate.templateName && (
<span className="flex items-center gap-1">
<Layers size={11} className="shrink-0" />
{estimate.templateName}
</span>
)}
</div>
<div className="flex items-center justify-between">
<span className="text-lg font-bold text-zinc-900 dark:text-white">
${estimate.total.toLocaleString('en-US', { minimumFractionDigits: 2 })}
</span>
<span className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 font-medium opacity-0 group-hover:opacity-100 transition-opacity">
View <ChevronRight size={13} />
</span>
</div>
{estimate.notes && (
<p className="mt-2 text-xs text-zinc-400 dark:text-zinc-500 line-clamp-1 border-t border-zinc-100 dark:border-white/5 pt-2">
{estimate.notes}
</p>
)}
</motion.div>
);
}
// ---------------------------------------------------------------------------
// 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 (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.04 }}
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/[0.07] rounded-2xl p-5 flex flex-col gap-3 hover:shadow-md dark:hover:shadow-black/20 transition-all"
>
{/* Header */}
<div className="flex items-start gap-3">
<div className={`w-9 h-9 rounded-xl flex items-center justify-center shrink-0 ${catCfg.bg}`}>
<Package size={16} className={catCfg.text} />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-zinc-900 dark:text-white text-sm leading-tight">{template.name}</h3>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<CategoryBadge category={template.category} />
<span className="text-[10px] font-bold text-zinc-400 dark:text-zinc-500 bg-zinc-100 dark:bg-zinc-800 px-2 py-0.5 rounded-full uppercase tracking-wider">
{template.brand}
</span>
</div>
</div>
</div>
{/* Description preview */}
{template.description?.length > 0 && (
<p className="text-xs text-zinc-500 dark:text-zinc-400 line-clamp-2 leading-relaxed">
{template.description[0]}
</p>
)}
{/* Material count */}
<div className="flex items-center gap-1.5 text-xs text-zinc-400">
<Tag size={11} />
<span>{template.materials?.length || 0} material line items</span>
</div>
{/* Actions — Owner only */}
{isOwner && (
<div className="flex items-center gap-2 pt-2 border-t border-zinc-100 dark:border-white/5">
<button
onClick={() => onEdit(template)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-zinc-600 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors"
>
<Pencil size={12} /> Edit
</button>
<button
onClick={() => onDuplicate(template)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-zinc-600 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors"
>
<Copy size={12} /> Duplicate
</button>
<button
onClick={() => onDelete(template)}
className="ml-auto flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors"
>
<Trash2 size={12} /> Delete
</button>
</div>
)}
</motion.div>
);
}
// ---------------------------------------------------------------------------
// Delete confirm modal
// ---------------------------------------------------------------------------
function DeleteConfirmModal({ template, onConfirm, onCancel }) {
if (!template) return null;
return ReactDOM.createPortal(
<div className="fixed inset-0 z-[80] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
className="bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl border border-zinc-200 dark:border-white/10 w-full max-w-sm p-6"
onClick={e => e.stopPropagation()}
>
<div className="w-10 h-10 rounded-xl bg-red-100 dark:bg-red-500/20 flex items-center justify-center mb-4">
<Trash2 size={18} className="text-red-600 dark:text-red-400" />
</div>
<h3 className="font-bold text-zinc-900 dark:text-white text-base mb-1">Delete Template</h3>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mb-5">
Are you sure you want to delete <strong className="text-zinc-700 dark:text-zinc-200">{template.name}</strong>? This cannot be undone.
</p>
<div className="flex gap-3">
<button
onClick={onCancel}
className="flex-1 px-4 py-2 rounded-xl border border-zinc-200 dark:border-zinc-700 text-sm font-medium text-zinc-600 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors"
>
Cancel
</button>
<button
onClick={onConfirm}
className="flex-1 px-4 py-2 rounded-xl bg-red-600 hover:bg-red-700 text-white text-sm font-semibold transition-colors"
>
Delete
</button>
</div>
</motion.div>
</div>,
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 (
<div className={`min-h-screen bg-zinc-50 dark:bg-[#09090b] ${theme === 'dark' ? 'dark' : ''}`}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-6 sm:py-8">
{/* Page header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div>
<h1 className="text-2xl font-black uppercase tracking-tight text-zinc-900 dark:text-white" style={{ fontFamily: 'Barlow Condensed, sans-serif' }}>
Estimates
</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-0.5">Manage estimates and master templates</p>
</div>
<button
onClick={() => navigate(`${basePath}/estimate`)}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold transition-colors shadow-sm"
>
<Plus size={16} /> New Estimate
</button>
</div>
{/* KPI strip */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
{[
{ 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 (
<div key={s.label} className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/[0.07] rounded-2xl px-4 py-3 flex items-center gap-3">
<div className={`w-9 h-9 rounded-xl flex items-center justify-center shrink-0 ${s.bg}`}>
<Icon size={16} className={s.color} />
</div>
<div>
<p className="text-xs text-zinc-400">{s.label}</p>
<p className="text-lg font-bold text-zinc-900 dark:text-white leading-none mt-0.5">{s.value}</p>
</div>
</div>
);
})}
</div>
{/* Tabs */}
<div className="flex items-center gap-1 border-b border-zinc-200 dark:border-white/[0.07] mb-5">
{TABS.map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`relative pb-3 px-4 text-sm font-semibold transition-colors ${
activeTab === tab.key
? 'text-blue-600 dark:text-blue-400'
: 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200'
}`}
>
{tab.label}
<span className={`ml-1.5 text-[10px] font-bold px-1.5 py-0.5 rounded-full ${
activeTab === tab.key
? 'bg-blue-100 dark:bg-blue-500/20 text-blue-600 dark:text-blue-400'
: 'bg-zinc-100 dark:bg-zinc-800 text-zinc-400'
}`}>
{tab.count}
</span>
{activeTab === tab.key && (
<motion.div
layoutId="tab-underline"
className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600 dark:bg-blue-400 rounded-full"
/>
)}
</button>
))}
</div>
{/* ===== ESTIMATES TAB ===== */}
<AnimatePresence mode="wait">
{activeTab === 'estimates' && (
<motion.div key="estimates" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-3 mb-5">
<div className="relative flex-1">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400 pointer-events-none" />
<input
value={estimateSearch}
onChange={e => 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"
/>
</div>
<div className="flex gap-1.5 flex-wrap">
{ESTIMATE_STATUSES.map(s => (
<button
key={s}
onClick={() => setEstimateStatusFilter(s)}
className={`px-3 py-2 rounded-xl text-xs font-semibold transition-colors ${
estimateStatusFilter === s
? 'bg-blue-600 text-white'
: 'bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:border-zinc-300 dark:hover:border-zinc-600'
}`}
>
{s}
</button>
))}
</div>
</div>
{filteredEstimates.length === 0 ? (
<div className="text-center py-16 text-zinc-400 dark:text-zinc-600">
<FileText size={32} className="mx-auto mb-3 opacity-40" />
<p className="text-sm font-medium">No estimates found</p>
<p className="text-xs mt-1">Try adjusting your search or filters</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{filteredEstimates.map((est, i) => (
<EstimateCard key={est.id} estimate={est} index={i} onClick={() => setDetailEstimate(est)} />
))}
</div>
)}
</motion.div>
)}
{/* ===== TEMPLATES TAB ===== */}
{activeTab === 'templates' && (
<motion.div key="templates" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
{/* Filters + create button */}
<div className="flex flex-col sm:flex-row gap-3 mb-5">
<div className="relative flex-1">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400 pointer-events-none" />
<input
value={templateSearch}
onChange={e => 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"
/>
</div>
<div className="flex gap-1.5 flex-wrap items-center">
{TEMPLATE_CATEGORIES_FILTER.map(c => (
<button
key={c}
onClick={() => setTemplateCategoryFilter(c)}
className={`px-3 py-2 rounded-xl text-xs font-semibold transition-colors ${
templateCategoryFilter === c
? 'bg-blue-600 text-white'
: 'bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:border-zinc-300 dark:hover:border-zinc-600'
}`}
>
{c}
</button>
))}
{isOwner && (
<button
onClick={openCreate}
className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 text-white text-xs font-semibold transition-colors shadow-sm ml-1"
>
<Plus size={13} /> New Template
</button>
)}
</div>
</div>
{!isOwner && (
<div className="mb-4 px-4 py-3 rounded-xl bg-zinc-100 dark:bg-zinc-800/50 border border-zinc-200 dark:border-zinc-700 text-xs text-zinc-500 dark:text-zinc-400">
Templates are managed by the Owner. You can apply these when creating a new estimate.
</div>
)}
{filteredTemplates.length === 0 ? (
<div className="text-center py-16 text-zinc-400 dark:text-zinc-600">
<Layers size={32} className="mx-auto mb-3 opacity-40" />
<p className="text-sm font-medium">No templates found</p>
{isOwner && (
<button
onClick={openCreate}
className="mt-4 flex items-center gap-1.5 mx-auto px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 text-white text-xs font-semibold transition-colors"
>
<Plus size={13} /> Create Template
</button>
)}
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{filteredTemplates.map((tpl, i) => (
<TemplateCard
key={tpl.id}
template={tpl}
isOwner={isOwner}
onEdit={openEdit}
onDuplicate={openDuplicate}
onDelete={setDeleteTarget}
index={i}
/>
))}
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
{/* Estimate detail drawer */}
<EstimateDetailModal
isOpen={!!detailEstimate}
onClose={() => setDetailEstimate(null)}
estimate={detailEstimate}
/>
{/* Template editor modal */}
<TemplateEditorModal
isOpen={editorOpen}
onClose={() => setEditorOpen(false)}
onSave={handleEditorSave}
mode={editorMode}
template={editorTemplate}
/>
{/* Delete confirm */}
<AnimatePresence>
{deleteTarget && (
<DeleteConfirmModal
template={deleteTarget}
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteTarget(null)}
/>
)}
</AnimatePresence>
</div>
);
}