Files
LynkedUpPro_CRM/src/pages/EstimatesPage.jsx
T
Satyam-Rastogi c927f4c44d feat(estimates): template access control — owner can grant edit rights by role or individual
- Add TemplateAccessModal: role-level toggles (All Admins / All Field Agents) + per-person overrides
- Three-state access logic: role grant, individual direct grant, individual exclusion override
- Excluded people (role ON but toggled off) pinned to top with amber 'Excluded' chip
- Direct grants (role OFF but toggled on) show green 'Direct' chip
- canEditTemplates replaces isOwner for all template CRUD guards in EstimatesPage
- Owner retains exclusive access to Manage Access button
- State persisted in mockStore: templateAccessRoles, templateAccessUsers, templateAccessExcluded
2026-04-06 18:33:48 +05:30

661 lines
36 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
Pencil, Trash2, Copy, ChevronRight, Tag, Package,
ArrowDownUp, ArrowUp, ArrowDown, X as XIcon, Shield
} from 'lucide-react';
import { toast } from 'sonner';
import TemplateEditorModal from '../components/estimates/TemplateEditorModal';
import EstimateDetailModal from '../components/estimates/EstimateDetailModal';
import TemplateAccessModal from '../components/estimates/TemplateAccessModal';
import { ALL_STATUS_CONFIG } from '../components/estimates/EstimateDetailModal';
import { computeGrandTotal } from '../utils/estimateExport';
// ---------------------------------------------------------------------------
// Status badge — uses shared ALL_STATUS_CONFIG from EstimateDetailModal
// ---------------------------------------------------------------------------
function StatusBadge({ status }) {
const cfg = ALL_STATUS_CONFIG[status] ?? ALL_STATUS_CONFIG['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} />
{status}
</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, computedTotal, index, onClick }) {
const displayTotal = computedTotal ?? estimate.total;
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">
${displayTotal.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,
templateAccessRoles, templateAccessUsers, templateAccessExcluded,
} = useMockStore();
const isOwner = user?.role === ROLES.OWNER;
const basePath = user?.role === 'OWNER' ? '/owner' : user?.role === 'ADMIN' ? '/admin' : '/emp/fa';
// Effective template edit permission (Owner always; others via role/individual grant minus exclusions)
const canEditTemplates = isOwner ||
(templateAccessRoles.includes(user?.role) && !templateAccessExcluded.includes(user?.id)) ||
templateAccessUsers.includes(user?.id);
const [activeTab, setActiveTab] = useState('estimates'); // 'estimates' | 'templates'
const [estimateSearch, setEstimateSearch] = useState('');
const [estimateStatusFilter, setEstimateStatusFilter] = useState('All');
const [sortBy, setSortBy] = useState('newest'); // 'newest' | 'oldest' | 'status'
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
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);
// Access management modal (Owner only)
const [accessModalOpen, setAccessModalOpen] = useState(false);
// ---- filtered + sorted estimates ----
const STATUS_SORT_ORDER = ['Draft', 'Sent', 'Waiting Approval', 'Approved', 'Follow Up Required', 'Revision Required', 'Rejected', 'Expired'];
const filteredEstimates = useMemo(() => {
let result = estimates.filter(e => {
const q = estimateSearch.toLowerCase();
const matchesSearch =
e.clientName.toLowerCase().includes(q) ||
e.address.toLowerCase().includes(q) ||
(e.templateName || '').toLowerCase().includes(q);
const matchesStatus = estimateStatusFilter === 'All' || e.status === estimateStatusFilter;
const matchesFrom = !dateFrom || e.date >= dateFrom;
const matchesTo = !dateTo || e.date <= dateTo;
return matchesSearch && matchesStatus && matchesFrom && matchesTo;
});
if (sortBy === 'newest') {
result = [...result].sort((a, b) => b.date.localeCompare(a.date));
} else if (sortBy === 'oldest') {
result = [...result].sort((a, b) => a.date.localeCompare(b.date));
} else if (sortBy === 'status') {
result = [...result].sort((a, b) =>
STATUS_SORT_ORDER.indexOf(a.status) - STATUS_SORT_ORDER.indexOf(b.status)
);
}
return result;
}, [estimates, estimateSearch, estimateStatusFilter, sortBy, dateFrom, dateTo]);
// ---- 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);
}
};
// ---- computed totals map (same formula as detail modal) ----
const computedTotals = useMemo(() => {
const map = {};
estimates.forEach(est => {
const tpl = templates.find(t => t.id === est.templateId);
map[est.id] = tpl && est.roofArea ? computeGrandTotal(tpl, est.roofArea) : est.total;
});
return map;
}, [estimates, templates]);
// ---- 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 + (computedTotals[e.id] ?? e.total), 0);
const pending = estimates.filter(e => e.status === 'Sent' || e.status === 'Draft').length;
return { total, approved, totalValue, pending };
}, [estimates, computedTotals]);
const TABS = [
{ key: 'estimates', label: 'Estimates', count: estimates.length },
{ key: 'templates', label: 'Templates', count: templates.length },
];
const ESTIMATE_STATUSES = ['All', 'Draft', 'Sent', 'Waiting Approval', 'Approved', 'Follow Up Required', 'Revision Required', '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 row 1: Search + Sort */}
<div className="flex flex-col sm:flex-row gap-3 mb-3">
<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>
{/* Sort control */}
<div className="flex items-center gap-1 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl p-1 shrink-0">
{[
{ key: 'newest', icon: ArrowDown, label: 'Newest' },
{ key: 'oldest', icon: ArrowUp, label: 'Oldest' },
{ key: 'status', icon: ArrowDownUp, label: 'Status' },
].map(({ key, icon: Icon, label }) => (
<button
key={key}
onClick={() => setSortBy(key)}
title={`Sort by ${label}`}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-colors ${
sortBy === key
? 'bg-blue-600 text-white'
: 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200'
}`}
>
<Icon size={12} />
<span className="hidden sm:inline">{label}</span>
</button>
))}
</div>
</div>
{/* Filters row 2: Status chips + Date range */}
<div className="flex flex-col sm:flex-row gap-3 mb-5">
<div className="flex gap-1.5 flex-wrap flex-1">
{ESTIMATE_STATUSES.map(s => (
<button
key={s}
onClick={() => setEstimateStatusFilter(s)}
className={`px-3 py-1.5 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>
{/* Date range */}
<div className="flex items-center gap-2 shrink-0">
<input
type="date"
value={dateFrom}
onChange={e => setDateFrom(e.target.value)}
title="From date"
className="px-3 py-1.5 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 text-xs outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500 transition-shadow"
/>
<span className="text-xs text-zinc-400"></span>
<input
type="date"
value={dateTo}
onChange={e => setDateTo(e.target.value)}
title="To date"
className="px-3 py-1.5 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 text-xs outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500 transition-shadow"
/>
{(dateFrom || dateTo) && (
<button
onClick={() => { setDateFrom(''); setDateTo(''); }}
title="Clear date range"
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-zinc-800 transition-colors"
>
<XIcon size={13} />
</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} computedTotal={computedTotals[est.id]} 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>
))}
{canEditTemplates && (
<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>
)}
{isOwner && (
<button
onClick={() => setAccessModalOpen(true)}
className="flex items-center gap-1.5 px-4 py-2 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 text-zinc-600 dark:text-zinc-300 text-xs font-semibold hover:border-zinc-300 dark:hover:border-zinc-600 transition-colors"
>
<Shield size={13} /> Manage Access
</button>
)}
</div>
</div>
{!canEditTemplates && (
<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>
{canEditTemplates && (
<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={canEditTemplates}
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>
{/* Template access modal — Owner only */}
<TemplateAccessModal
isOpen={accessModalOpen}
onClose={() => setAccessModalOpen(false)}
/>
</div>
);
}