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:
+22
@@ -32,6 +32,7 @@ import SubContractorDashboard from './pages/subcontractor/SubContractorDashboard
|
|||||||
import CreateLeadPage from './pages/CreateLeadPage';
|
import CreateLeadPage from './pages/CreateLeadPage';
|
||||||
import LeadsListPage from './pages/LeadsListPage';
|
import LeadsListPage from './pages/LeadsListPage';
|
||||||
import LynkDispatchPage from './pages/LynkDispatchPage';
|
import LynkDispatchPage from './pages/LynkDispatchPage';
|
||||||
|
import EstimatesPage from './pages/EstimatesPage';
|
||||||
|
|
||||||
// ... (existing imports)
|
// ... (existing imports)
|
||||||
const ProtectedRoute = ({ children, allowedRoles }) => {
|
const ProtectedRoute = ({ children, allowedRoles }) => {
|
||||||
@@ -101,6 +102,14 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/emp/fa/estimates"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute allowedRoles={['FIELD_AGENT', 'ADMIN', 'OWNER']}>
|
||||||
|
<EstimatesPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/emp/fa/estimate"
|
path="/emp/fa/estimate"
|
||||||
element={
|
element={
|
||||||
@@ -166,6 +175,11 @@ function App() {
|
|||||||
<ProCanvas />
|
<ProCanvas />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
|
<Route path="/owner/estimates" element={
|
||||||
|
<ProtectedRoute allowedRoles={['OWNER']}>
|
||||||
|
<EstimatesPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
<Route path="/owner/estimate" element={
|
<Route path="/owner/estimate" element={
|
||||||
<ProtectedRoute allowedRoles={['OWNER']}>
|
<ProtectedRoute allowedRoles={['OWNER']}>
|
||||||
<EstimateBuilder />
|
<EstimateBuilder />
|
||||||
@@ -230,6 +244,14 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/estimates"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute allowedRoles={['ADMIN', 'OWNER']}>
|
||||||
|
<EstimatesPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/admin/estimate"
|
path="/admin/estimate"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ const Layout = () => {
|
|||||||
icon: LayoutDashboard,
|
icon: LayoutDashboard,
|
||||||
label: <span><span className="text-[#fda913]">Pro</span>Canvas</span>
|
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/schedule", icon: Calendar, label: "Team Schedule" },
|
||||||
{ to: "/admin/leaderboard", icon: Trophy, label: "Leaderboard" },
|
{ to: "/admin/leaderboard", icon: Trophy, label: "Leaderboard" },
|
||||||
...commonItems,
|
...commonItems,
|
||||||
@@ -163,7 +163,7 @@ const Layout = () => {
|
|||||||
icon: LayoutDashboard,
|
icon: LayoutDashboard,
|
||||||
label: <span><span className="text-[#fda913]">Pro</span>Canvas</span>
|
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,
|
...commonItems,
|
||||||
];
|
];
|
||||||
case 'CONTRACTOR':
|
case 'CONTRACTOR':
|
||||||
@@ -189,7 +189,7 @@ const Layout = () => {
|
|||||||
icon: LayoutDashboard,
|
icon: LayoutDashboard,
|
||||||
label: <span><span className="text-[#fda913]">Pro</span>Canvas</span>
|
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,
|
...commonItems,
|
||||||
];
|
];
|
||||||
default: // Customer or Fallback
|
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 React, { useState } from 'react';
|
||||||
|
import { useMockStore } from '../../data/mockStore';
|
||||||
|
|
||||||
const STUBBED_TEMPLATES = [
|
const STUBBED_TEMPLATES = [
|
||||||
{
|
{
|
||||||
@@ -531,10 +532,11 @@ const STUBBED_TEMPLATES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function TemplateSelectionModal({ isOpen, onClose, onSelectTemplate }) {
|
export default function TemplateSelectionModal({ isOpen, onClose, onSelectTemplate }) {
|
||||||
|
const { templates } = useMockStore();
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [selected, setSelected] = useState(null);
|
const [selected, setSelected] = useState(null);
|
||||||
|
|
||||||
const filtered = STUBBED_TEMPLATES.filter(t =>
|
const filtered = templates.filter(t =>
|
||||||
t.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
t.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
t.category.toLowerCase().includes(searchTerm.toLowerCase())
|
t.category.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
);
|
);
|
||||||
@@ -579,7 +581,7 @@ export default function TemplateSelectionModal({ isOpen, onClose, onSelectTempla
|
|||||||
{/* Category Header */}
|
{/* 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">
|
<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">
|
<h3 className="text-sm font-semibold text-blue-600 dark:text-blue-400">
|
||||||
Roofing ({filtered.length})
|
Templates ({filtered.length})
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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 ---
|
// --- CONTEXT SETUP ---
|
||||||
|
|
||||||
const MockStoreContext = createContext();
|
const MockStoreContext = createContext();
|
||||||
@@ -3217,6 +3637,8 @@ export const MockStoreProvider = ({ children }) => {
|
|||||||
const [vendorInvoices, setVendorInvoices] = useState(MOCK_VENDOR_INVOICES);
|
const [vendorInvoices, setVendorInvoices] = useState(MOCK_VENDOR_INVOICES);
|
||||||
const [leads, setLeads] = useState([]);
|
const [leads, setLeads] = useState([]);
|
||||||
const [dispatchLeads, setDispatchLeads] = useState(DISPATCH_LEADS_INITIAL);
|
const [dispatchLeads, setDispatchLeads] = useState(DISPATCH_LEADS_INITIAL);
|
||||||
|
const [templates, setTemplates] = useState(MASTER_TEMPLATES_INITIAL);
|
||||||
|
const [estimates, setEstimates] = useState(MOCK_ESTIMATES_INITIAL);
|
||||||
|
|
||||||
// Initialize properties once
|
// Initialize properties once
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -3317,6 +3739,31 @@ export const MockStoreProvider = ({ children }) => {
|
|||||||
return newLead;
|
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
|
// LynkDispatch
|
||||||
dispatchLeads,
|
dispatchLeads,
|
||||||
assignDispatchLead: (leadId, repId) => {
|
assignDispatchLead: (leadId, repId) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user