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 (
{status}
);
}
// ---------------------------------------------------------------------------
// 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 (
{category}
);
}
// ---------------------------------------------------------------------------
// Estimate Card
// ---------------------------------------------------------------------------
function EstimateCard({ estimate, computedTotal, index, onClick }) {
const displayTotal = computedTotal ?? estimate.total;
return (
{estimate.clientName}
{estimate.address}
{estimate.date}
{estimate.estimatedBy}
{estimate.templateName && (
{estimate.templateName}
)}
${displayTotal.toLocaleString('en-US', { minimumFractionDigits: 2 })}
View
{estimate.notes && (
{estimate.notes}
)}
);
}
// ---------------------------------------------------------------------------
// 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 (
{/* Header */}
{template.name}
{template.brand}
{/* Description preview */}
{template.description?.length > 0 && (
{template.description[0]}
)}
{/* Material count */}
{template.materials?.length || 0} material line items
{/* Actions — Owner only */}
{isOwner && (
)}
);
}
// ---------------------------------------------------------------------------
// Delete confirm modal
// ---------------------------------------------------------------------------
function DeleteConfirmModal({ template, onConfirm, onCancel }) {
if (!template) return null;
return ReactDOM.createPortal(
e.stopPropagation()}
>
Delete Template
Are you sure you want to delete {template.name}? This cannot be undone.
,
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 (
{/* Page header */}
Estimates
Manage estimates and master templates
{/* KPI strip */}
{[
{ 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 (
);
})}
{/* Tabs */}
{TABS.map(tab => (
))}
{/* ===== ESTIMATES TAB ===== */}
{activeTab === 'estimates' && (
{/* Filters row 1: Search + Sort */}
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"
/>
{/* Sort control */}
{[
{ key: 'newest', icon: ArrowDown, label: 'Newest' },
{ key: 'oldest', icon: ArrowUp, label: 'Oldest' },
{ key: 'status', icon: ArrowDownUp, label: 'Status' },
].map(({ key, icon: Icon, label }) => (
))}
{/* Filters row 2: Status chips + Date range */}
{filteredEstimates.length === 0 ? (
No estimates found
Try adjusting your search or filters
) : (
{filteredEstimates.map((est, i) => (
setDetailEstimate(est)} />
))}
)}
)}
{/* ===== TEMPLATES TAB ===== */}
{activeTab === 'templates' && (
{/* Filters + create button */}
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"
/>
{TEMPLATE_CATEGORIES_FILTER.map(c => (
))}
{canEditTemplates && (
)}
{isOwner && (
)}
{!canEditTemplates && (
Templates are managed by the Owner. You can apply these when creating a new estimate.
)}
{filteredTemplates.length === 0 ? (
No templates found
{canEditTemplates && (
)}
) : (
{filteredTemplates.map((tpl, i) => (
))}
)}
)}
{/* Estimate detail drawer */}
setDetailEstimate(null)}
estimate={detailEstimate}
/>
{/* Template editor modal */}
setEditorOpen(false)}
onSave={handleEditorSave}
mode={editorMode}
template={editorTemplate}
/>
{/* Delete confirm */}
{deleteTarget && (
setDeleteTarget(null)}
/>
)}
{/* Template access modal — Owner only */}
setAccessModalOpen(false)}
/>
);
}