feat(estimates): Estimates hub with templates, detail drawer, status tracker, sort, and share

- Add EstimatesPage with Estimates + Templates tabs, KPI strip, search, and filter chips
- Estimate detail drawer with pipeline status tracker (Draft→Sent→Waiting Approval→Approved) and edge statuses (Follow Up Required, Revision Required, Rejected, Expired); clickable nodes update estimate status live
- Share bar: WhatsApp, iMessage, Email, PDF download (jsPDF + autotable, light-mode A4)
- ALL_STATUS_CONFIG as shared status registry across cards, badge, filters, and tracker
- Sort controls: Newest First / Oldest First / By Status
- Date range filter with From/To date inputs and clear button
- Fix unit price discrepancy: Unit Price = clientPrice/qty so Qty × Unit Price = Total
- Fix chatbot overlap: pr-20 on drawer footer keeps grand total visible
- Fix card vs drawer total mismatch: computeGrandTotal extracted to estimateExport.js, used as single source of truth in cards, drawer, PDF, and WhatsApp message
- TemplateEditorModal: Owner CRUD for master templates, duplicate with Save as Copy / Replace Original toggle
This commit is contained in:
Satyam-Rastogi
2026-04-01 16:45:16 +05:30
parent 8f9cb9dd48
commit fe9ddda941
3 changed files with 852 additions and 94 deletions
+111 -30
View File
@@ -7,31 +7,26 @@ 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
User, CheckCircle, Clock,
Pencil, Trash2, Copy, ChevronRight, Tag, Package,
ArrowDownUp, ArrowUp, ArrowDown, X as XIcon
} from 'lucide-react';
import { toast } from 'sonner';
import TemplateEditorModal from '../components/estimates/TemplateEditorModal';
import EstimateDetailModal from '../components/estimates/EstimateDetailModal';
import { ALL_STATUS_CONFIG } from '../components/estimates/EstimateDetailModal';
import { computeGrandTotal } from '../utils/estimateExport';
// ---------------------------------------------------------------------------
// Status config — estimates
// Status badge — uses shared ALL_STATUS_CONFIG from EstimateDetailModal
// ---------------------------------------------------------------------------
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 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} />
{cfg.label}
{status}
</span>
);
}
@@ -60,7 +55,8 @@ function CategoryBadge({ category }) {
// ---------------------------------------------------------------------------
// Estimate Card
// ---------------------------------------------------------------------------
function EstimateCard({ estimate, index, onClick }) {
function EstimateCard({ estimate, computedTotal, index, onClick }) {
const displayTotal = computedTotal ?? estimate.total;
return (
<motion.div
initial={{ opacity: 0, y: 12 }}
@@ -96,7 +92,7 @@ function EstimateCard({ estimate, index, onClick }) {
<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 })}
${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} />
@@ -237,6 +233,9 @@ export default function EstimatesPage() {
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');
@@ -251,17 +250,34 @@ export default function EstimatesPage() {
// Delete confirm
const [deleteTarget, setDeleteTarget] = useState(null);
// ---- filtered estimates ----
// ---- filtered + sorted estimates ----
const STATUS_SORT_ORDER = ['Draft', 'Sent', 'Waiting Approval', 'Approved', 'Follow Up Required', 'Revision Required', 'Rejected', 'Expired'];
const filteredEstimates = useMemo(() => {
return estimates.filter(e => {
let result = estimates.filter(e => {
const q = estimateSearch.toLowerCase();
const matchesSearch =
e.clientName.toLowerCase().includes(estimateSearch.toLowerCase()) ||
e.address.toLowerCase().includes(estimateSearch.toLowerCase()) ||
(e.templateName || '').toLowerCase().includes(estimateSearch.toLowerCase());
e.clientName.toLowerCase().includes(q) ||
e.address.toLowerCase().includes(q) ||
(e.templateName || '').toLowerCase().includes(q);
const matchesStatus = estimateStatusFilter === 'All' || e.status === estimateStatusFilter;
return matchesSearch && matchesStatus;
const matchesFrom = !dateFrom || e.date >= dateFrom;
const matchesTo = !dateTo || e.date <= dateTo;
return matchesSearch && matchesStatus && matchesFrom && matchesTo;
});
}, [estimates, estimateSearch, estimateStatusFilter]);
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(() => {
@@ -306,21 +322,33 @@ export default function EstimatesPage() {
}
};
// ---- 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 + e.total, 0);
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]);
}, [estimates, computedTotals]);
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 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 (
@@ -400,8 +428,8 @@ export default function EstimatesPage() {
<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">
{/* 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
@@ -411,12 +439,38 @@ export default function EstimatesPage() {
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">
{/* 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-2 rounded-xl text-xs font-semibold transition-colors ${
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'
@@ -426,6 +480,33 @@ export default function EstimatesPage() {
</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 ? (
@@ -437,7 +518,7 @@ export default function EstimatesPage() {
) : (
<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)} />
<EstimateCard key={est.id} estimate={est} computedTotal={computedTotals[est.id]} index={i} onClick={() => setDetailEstimate(est)} />
))}
</div>
)}