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:
@@ -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