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:
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -7,22 +7,41 @@ import { useMockStore } from '../../data/mockStore';
|
||||
import {
|
||||
X, Calendar, User, Layers, Maximize2,
|
||||
CheckCircle, Clock, XCircle, AlertCircle, Send,
|
||||
MapPin, FileText, Package, ChevronRight
|
||||
MapPin, FileText, Package, ChevronRight,
|
||||
MessageCircle, Download, Loader2, Mail,
|
||||
RefreshCw, PhoneCall, GitBranch, Timer
|
||||
} from 'lucide-react';
|
||||
import { computeLineItems, formatWhatsAppMessage, openWhatsApp, openIMessage, openEmail, generateEstimatePDF } from '../../utils/estimateExport';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status config
|
||||
// Status system
|
||||
// ---------------------------------------------------------------------------
|
||||
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' },
|
||||
|
||||
// Pipeline: linear progression shown as a track
|
||||
export const PIPELINE_STEPS = ['Draft', 'Sent', 'Waiting Approval', 'Approved'];
|
||||
|
||||
// Edge statuses: branch off the pipeline (not linear)
|
||||
export const EDGE_STATUSES = {
|
||||
'Follow Up Required': { icon: PhoneCall, color: '#F59E0B', bg: 'bg-amber-100 dark:bg-amber-500/20', text: 'text-amber-700 dark:text-amber-400' },
|
||||
'Revision Required': { icon: RefreshCw, color: '#8B5CF6', bg: 'bg-purple-100 dark:bg-purple-500/20', text: 'text-purple-700 dark:text-purple-400' },
|
||||
'Rejected': { icon: XCircle, color: '#EF4444', bg: 'bg-red-100 dark:bg-red-500/20', text: 'text-red-700 dark:text-red-400' },
|
||||
'Expired': { icon: Timer, color: '#F97316', bg: 'bg-orange-100 dark:bg-orange-500/20', text: 'text-orange-700 dark:text-orange-400' },
|
||||
};
|
||||
|
||||
// All statuses combined (for badges, cards, filters)
|
||||
export const ALL_STATUS_CONFIG = {
|
||||
'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' },
|
||||
'Waiting Approval': { icon: GitBranch, bg: 'bg-sky-100 dark:bg-sky-500/20', text: 'text-sky-700 dark:text-sky-400' },
|
||||
'Approved': { icon: CheckCircle, bg: 'bg-emerald-100 dark:bg-emerald-500/20', text: 'text-emerald-700 dark:text-emerald-400' },
|
||||
'Follow Up Required': { icon: PhoneCall, bg: 'bg-amber-100 dark:bg-amber-500/20', text: 'text-amber-700 dark:text-amber-400' },
|
||||
'Revision Required': { icon: RefreshCw, bg: 'bg-purple-100 dark:bg-purple-500/20', text: 'text-purple-700 dark:text-purple-400' },
|
||||
'Rejected': { icon: XCircle, bg: 'bg-red-100 dark:bg-red-500/20', text: 'text-red-700 dark:text-red-400' },
|
||||
'Expired': { icon: Timer, bg: 'bg-orange-100 dark:bg-orange-500/20', text: 'text-orange-700 dark:text-orange-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.5 py-1 rounded-full text-[11px] font-bold uppercase tracking-wider ${cfg.bg} ${cfg.text}`}>
|
||||
@@ -32,58 +51,108 @@ function StatusBadge({ status }) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Line item computation — mirrors EstimateBuilder's handleMeasurementsNext logic
|
||||
// Section table component
|
||||
// ---------------------------------------------------------------------------
|
||||
function computeLineItems(template, roofArea) {
|
||||
if (!template || !roofArea) return { materials: [], labor: [] };
|
||||
// Status Tracker — pipeline track + edge status pills + change status
|
||||
// ---------------------------------------------------------------------------
|
||||
function EstimateStatusTracker({ currentStatus, onStatusChange }) {
|
||||
const isEdge = !!EDGE_STATUSES[currentStatus];
|
||||
const pipelineIdx = PIPELINE_STEPS.indexOf(currentStatus);
|
||||
|
||||
const waste = 1.05;
|
||||
const margin = 1.428; // ~30% margin
|
||||
const ridgesLF = Math.round(roofArea * 1.6);
|
||||
const eavesLF = Math.round(roofArea * 4.2);
|
||||
// For edge statuses, show track filled up to the last pipeline step before going edge
|
||||
// We treat "Sent" as the branching point for most edge states
|
||||
const effectivePipelineIdx = isEdge ? 1 : pipelineIdx; // show filled to "Sent" when edge
|
||||
|
||||
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 PIPELINE_COLORS = {
|
||||
0: { active: '#71717A', fill: '#3F3F46' }, // Draft — zinc
|
||||
1: { active: '#3B82F6', fill: '#2563EB' }, // Sent — blue
|
||||
2: { active: '#0EA5E9', fill: '#0284C7' }, // Waiting Approval — sky
|
||||
3: { active: '#10B981', fill: '#059669' }, // Approved — emerald
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="px-5 py-4 border-b border-zinc-100 dark:border-white/[0.07] bg-white dark:bg-zinc-900/80">
|
||||
|
||||
return { materials, labor };
|
||||
{/* Pipeline track */}
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-zinc-400 mb-3">Estimate Status</p>
|
||||
<div className="flex items-center gap-0 mb-3">
|
||||
{PIPELINE_STEPS.map((step, i) => {
|
||||
const isCompleted = i < effectivePipelineIdx;
|
||||
const isCurrent = !isEdge && i === pipelineIdx;
|
||||
const col = PIPELINE_COLORS[i];
|
||||
|
||||
return (
|
||||
<React.Fragment key={step}>
|
||||
{/* Node */}
|
||||
<button
|
||||
onClick={() => onStatusChange(step)}
|
||||
title={`Set to ${step}`}
|
||||
className="flex flex-col items-center gap-1 group shrink-0"
|
||||
>
|
||||
<div
|
||||
className={`w-7 h-7 rounded-full border-2 flex items-center justify-center transition-all ${
|
||||
isCurrent
|
||||
? 'scale-110 shadow-md'
|
||||
: 'hover:scale-105'
|
||||
}`}
|
||||
style={{
|
||||
borderColor: (isCompleted || isCurrent) ? col.fill : '#D4D4D8',
|
||||
backgroundColor: isCompleted ? col.fill : isCurrent ? col.active : 'transparent',
|
||||
}}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<CheckCircle size={13} color="#fff" />
|
||||
) : isCurrent ? (
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-white" />
|
||||
) : (
|
||||
<div className="w-2 h-2 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-[9px] font-semibold uppercase tracking-wide text-center leading-tight max-w-[52px] ${
|
||||
isCurrent ? 'text-zinc-800 dark:text-white' : isCompleted ? 'text-zinc-500 dark:text-zinc-400' : 'text-zinc-400 dark:text-zinc-600'
|
||||
}`}>
|
||||
{step}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Connector */}
|
||||
{i < PIPELINE_STEPS.length - 1 && (
|
||||
<div className="flex-1 h-0.5 mx-1 mb-4 rounded-full transition-all" style={{
|
||||
backgroundColor: i < effectivePipelineIdx ? PIPELINE_COLORS[i].fill : '#E4E4E7',
|
||||
}} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Edge status pills */}
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<span className="text-[9px] font-bold uppercase tracking-widest text-zinc-400">Exceptions</span>
|
||||
{Object.entries(EDGE_STATUSES).map(([label, cfg]) => {
|
||||
const Icon = cfg.icon;
|
||||
const isActive = currentStatus === label;
|
||||
return (
|
||||
<button
|
||||
key={label}
|
||||
onClick={() => onStatusChange(label)}
|
||||
className={`flex items-center gap-1 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-wide transition-all border ${
|
||||
isActive
|
||||
? 'text-white border-transparent scale-105'
|
||||
: 'bg-transparent border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:border-zinc-400 dark:hover:border-zinc-500'
|
||||
}`}
|
||||
style={isActive ? { backgroundColor: cfg.color, borderColor: cfg.color } : {}}
|
||||
title={`Mark as ${label}`}
|
||||
>
|
||||
<Icon size={10} /> {label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section table component
|
||||
// ---------------------------------------------------------------------------
|
||||
function LineItemTable({ title, items, accentClass }) {
|
||||
const subtotal = items.reduce((s, i) => s + i.clientPrice, 0);
|
||||
@@ -97,19 +166,22 @@ function LineItemTable({ title, items, accentClass }) {
|
||||
<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>
|
||||
<span className="text-right">Unit Price</span>
|
||||
<span className="text-right">Total</span>
|
||||
</div>
|
||||
<div className="divide-y divide-zinc-100 dark:divide-white/[0.04]">
|
||||
{items.map((item, i) => (
|
||||
{items.map((item, i) => {
|
||||
const unitPrice = item.clientPrice / item.qty;
|
||||
return (
|
||||
<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 text-zinc-500 dark:text-zinc-400">${unitPrice.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">
|
||||
@@ -128,9 +200,10 @@ function LineItemTable({ title, items, accentClass }) {
|
||||
export default function EstimateDetailModal({ isOpen, onClose, estimate }) {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { templates } = useMockStore();
|
||||
const { templates, updateEstimate } = useMockStore();
|
||||
|
||||
const basePath = user?.role === 'OWNER' ? '/owner' : user?.role === 'ADMIN' ? '/admin' : '/emp/fa';
|
||||
const [pdfLoading, setPdfLoading] = useState(false);
|
||||
|
||||
const template = useMemo(
|
||||
() => templates.find(t => t.id === estimate?.templateId),
|
||||
@@ -184,8 +257,9 @@ export default function EstimateDetailModal({ isOpen, onClose, estimate }) {
|
||||
<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"
|
||||
title="Open in Estimate Builder"
|
||||
>
|
||||
<Maximize2 size={12} /> Open in Builder
|
||||
<Maximize2 size={12} /> Builder
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -196,6 +270,60 @@ export default function EstimateDetailModal({ isOpen, onClose, estimate }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Share & Export bar */}
|
||||
<div className="shrink-0 flex items-center gap-2 px-5 py-2.5 border-b border-zinc-100 dark:border-white/[0.07] bg-zinc-50 dark:bg-zinc-800/40 flex-wrap">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-zinc-400 mr-1">Share</span>
|
||||
|
||||
{/* WhatsApp */}
|
||||
<button
|
||||
onClick={() => openWhatsApp(formatWhatsAppMessage(estimate, template, materials, labor))}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-colors text-white"
|
||||
style={{ background: '#25D366' }}
|
||||
title="Send via WhatsApp"
|
||||
>
|
||||
<MessageCircle size={12} /> WhatsApp
|
||||
</button>
|
||||
|
||||
{/* iMessage */}
|
||||
<button
|
||||
onClick={() => openIMessage(formatWhatsAppMessage(estimate, template, materials, labor))}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-colors text-white bg-[#34C759] hover:bg-[#2db34a]"
|
||||
title="Send via iMessage / SMS"
|
||||
>
|
||||
<MessageCircle size={12} /> iMessage
|
||||
</button>
|
||||
|
||||
{/* Email */}
|
||||
<button
|
||||
onClick={() => openEmail(estimate, formatWhatsAppMessage(estimate, template, materials, labor))}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-colors text-white bg-indigo-600 hover:bg-indigo-700"
|
||||
title="Send via Email"
|
||||
>
|
||||
<Mail size={12} /> Email
|
||||
</button>
|
||||
|
||||
{/* PDF */}
|
||||
<button
|
||||
onClick={async () => {
|
||||
setPdfLoading(true);
|
||||
try { await generateEstimatePDF(estimate, template, materials, labor); }
|
||||
finally { setPdfLoading(false); }
|
||||
}}
|
||||
disabled={pdfLoading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-colors text-white bg-rose-600 hover:bg-rose-700 disabled:opacity-60"
|
||||
title="Download PDF"
|
||||
>
|
||||
{pdfLoading ? <Loader2 size={12} className="animate-spin" /> : <Download size={12} />}
|
||||
{pdfLoading ? 'Generating…' : 'Download PDF'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status tracker */}
|
||||
<EstimateStatusTracker
|
||||
currentStatus={estimate.status}
|
||||
onStatusChange={(newStatus) => updateEstimate(estimate.id, { status: newStatus })}
|
||||
/>
|
||||
|
||||
{/* Scrollable body */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
||||
<div className="p-5 space-y-5">
|
||||
@@ -270,7 +398,7 @@ export default function EstimateDetailModal({ isOpen, onClose, estimate }) {
|
||||
|
||||
{/* 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="shrink-0 border-t border-zinc-100 dark:border-white/[0.07] pl-5 pr-20 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>
|
||||
@@ -282,7 +410,7 @@ export default function EstimateDetailModal({ isOpen, onClose, estimate }) {
|
||||
</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>
|
||||
<span className="text-emerald-600 dark:text-emerald-400">${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">
|
||||
|
||||
Reference in New Issue
Block a user