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
+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>