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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user