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 ReactDOM from 'react-dom';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
@@ -7,22 +7,41 @@ import { useMockStore } from '../../data/mockStore';
|
|||||||
import {
|
import {
|
||||||
X, Calendar, User, Layers, Maximize2,
|
X, Calendar, User, Layers, Maximize2,
|
||||||
CheckCircle, Clock, XCircle, AlertCircle, Send,
|
CheckCircle, Clock, XCircle, AlertCircle, Send,
|
||||||
MapPin, FileText, Package, ChevronRight
|
MapPin, FileText, Package, ChevronRight,
|
||||||
|
MessageCircle, Download, Loader2, Mail,
|
||||||
|
RefreshCw, PhoneCall, GitBranch, Timer
|
||||||
} from 'lucide-react';
|
} 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' },
|
// Pipeline: linear progression shown as a track
|
||||||
Sent: { icon: Send, bg: 'bg-blue-100 dark:bg-blue-500/20', text: 'text-blue-700 dark:text-blue-400' },
|
export const PIPELINE_STEPS = ['Draft', 'Sent', 'Waiting Approval', 'Approved'];
|
||||||
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' },
|
// Edge statuses: branch off the pipeline (not linear)
|
||||||
Expired: { icon: AlertCircle, bg: 'bg-amber-100 dark:bg-amber-500/20', text: 'text-amber-700 dark:text-amber-400' },
|
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 }) {
|
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;
|
const Icon = cfg.icon;
|
||||||
return (
|
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}`}>
|
<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}`}>
|
||||||
@@ -31,59 +50,109 @@ function StatusBadge({ status }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Line item computation — mirrors EstimateBuilder's handleMeasurementsNext logic
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
function computeLineItems(template, roofArea) {
|
|
||||||
if (!template || !roofArea) return { materials: [], labor: [] };
|
|
||||||
|
|
||||||
const waste = 1.05;
|
|
||||||
const margin = 1.428; // ~30% margin
|
|
||||||
const ridgesLF = Math.round(roofArea * 1.6);
|
|
||||||
const eavesLF = Math.round(roofArea * 4.2);
|
|
||||||
|
|
||||||
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 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 { materials, labor };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Section table component
|
// Section table component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Status Tracker — pipeline track + edge status pills + change status
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function EstimateStatusTracker({ currentStatus, onStatusChange }) {
|
||||||
|
const isEdge = !!EDGE_STATUSES[currentStatus];
|
||||||
|
const pipelineIdx = PIPELINE_STEPS.indexOf(currentStatus);
|
||||||
|
|
||||||
|
// 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 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
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-5 py-4 border-b border-zinc-100 dark:border-white/[0.07] bg-white dark:bg-zinc-900/80">
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function LineItemTable({ title, items, accentClass }) {
|
function LineItemTable({ title, items, accentClass }) {
|
||||||
const subtotal = items.reduce((s, i) => s + i.clientPrice, 0);
|
const subtotal = items.reduce((s, i) => s + i.clientPrice, 0);
|
||||||
@@ -97,19 +166,22 @@ function LineItemTable({ title, items, accentClass }) {
|
|||||||
<span>Description</span>
|
<span>Description</span>
|
||||||
<span className="text-right">Qty</span>
|
<span className="text-right">Qty</span>
|
||||||
<span className="text-right">UOM</span>
|
<span className="text-right">UOM</span>
|
||||||
<span className="text-right">Unit</span>
|
<span className="text-right">Unit Price</span>
|
||||||
<span className="text-right">Client Price</span>
|
<span className="text-right">Total</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y divide-zinc-100 dark:divide-white/[0.04]">
|
<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">
|
<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-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-500 dark:text-zinc-400">{item.qty}</span>
|
||||||
<span className="text-right text-zinc-400">{item.uom}</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>
|
<span className="text-right font-semibold text-zinc-800 dark:text-zinc-100">${item.clientPrice.toLocaleString('en-US', { minimumFractionDigits: 2 })}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</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]">
|
<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">
|
<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 }) {
|
export default function EstimateDetailModal({ isOpen, onClose, estimate }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { templates } = useMockStore();
|
const { templates, updateEstimate } = useMockStore();
|
||||||
|
|
||||||
const basePath = user?.role === 'OWNER' ? '/owner' : user?.role === 'ADMIN' ? '/admin' : '/emp/fa';
|
const basePath = user?.role === 'OWNER' ? '/owner' : user?.role === 'ADMIN' ? '/admin' : '/emp/fa';
|
||||||
|
const [pdfLoading, setPdfLoading] = useState(false);
|
||||||
|
|
||||||
const template = useMemo(
|
const template = useMemo(
|
||||||
() => templates.find(t => t.id === estimate?.templateId),
|
() => templates.find(t => t.id === estimate?.templateId),
|
||||||
@@ -184,8 +257,9 @@ export default function EstimateDetailModal({ isOpen, onClose, estimate }) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => { onClose(); navigate(`${basePath}/estimate`); }}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -196,6 +270,60 @@ export default function EstimateDetailModal({ isOpen, onClose, estimate }) {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Scrollable body */}
|
||||||
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
||||||
<div className="p-5 space-y-5">
|
<div className="p-5 space-y-5">
|
||||||
@@ -270,7 +398,7 @@ export default function EstimateDetailModal({ isOpen, onClose, estimate }) {
|
|||||||
|
|
||||||
{/* Footer — financial summary */}
|
{/* Footer — financial summary */}
|
||||||
{(materials.length > 0 || labor.length > 0) && (
|
{(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 flex-col gap-1.5">
|
||||||
<div className="flex justify-between text-xs text-zinc-500 dark:text-zinc-400">
|
<div className="flex justify-between text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
<span>Materials subtotal</span>
|
<span>Materials subtotal</span>
|
||||||
@@ -282,7 +410,7 @@ export default function EstimateDetailModal({ isOpen, onClose, estimate }) {
|
|||||||
</div>
|
</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]">
|
<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>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>
|
</div>
|
||||||
{estimate.total !== grandTotal && (
|
{estimate.total !== grandTotal && (
|
||||||
<p className="text-[10px] text-zinc-400 dark:text-zinc-600 mt-0.5">
|
<p className="text-[10px] text-zinc-400 dark:text-zinc-600 mt-0.5">
|
||||||
|
|||||||
+111
-30
@@ -7,31 +7,26 @@ import { useAuth, ROLES } from '../context/AuthContext';
|
|||||||
import { useMockStore } from '../data/mockStore';
|
import { useMockStore } from '../data/mockStore';
|
||||||
import {
|
import {
|
||||||
Plus, Search, FileText, Layers, Calendar, DollarSign,
|
Plus, Search, FileText, Layers, Calendar, DollarSign,
|
||||||
User, CheckCircle, Clock, XCircle, AlertCircle, Send,
|
User, CheckCircle, Clock,
|
||||||
Pencil, Trash2, Copy, ChevronRight, Tag, Package
|
Pencil, Trash2, Copy, ChevronRight, Tag, Package,
|
||||||
|
ArrowDownUp, ArrowUp, ArrowDown, X as XIcon
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import TemplateEditorModal from '../components/estimates/TemplateEditorModal';
|
import TemplateEditorModal from '../components/estimates/TemplateEditorModal';
|
||||||
import EstimateDetailModal from '../components/estimates/EstimateDetailModal';
|
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 }) {
|
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;
|
const Icon = cfg.icon;
|
||||||
return (
|
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}`}>
|
<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} />
|
<Icon size={10} />
|
||||||
{cfg.label}
|
{status}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -60,7 +55,8 @@ function CategoryBadge({ category }) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Estimate Card
|
// Estimate Card
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function EstimateCard({ estimate, index, onClick }) {
|
function EstimateCard({ estimate, computedTotal, index, onClick }) {
|
||||||
|
const displayTotal = computedTotal ?? estimate.total;
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
@@ -96,7 +92,7 @@ function EstimateCard({ estimate, index, onClick }) {
|
|||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-lg font-bold text-zinc-900 dark:text-white">
|
<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>
|
||||||
<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">
|
<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} />
|
View <ChevronRight size={13} />
|
||||||
@@ -237,6 +233,9 @@ export default function EstimatesPage() {
|
|||||||
const [activeTab, setActiveTab] = useState('estimates'); // 'estimates' | 'templates'
|
const [activeTab, setActiveTab] = useState('estimates'); // 'estimates' | 'templates'
|
||||||
const [estimateSearch, setEstimateSearch] = useState('');
|
const [estimateSearch, setEstimateSearch] = useState('');
|
||||||
const [estimateStatusFilter, setEstimateStatusFilter] = useState('All');
|
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 [templateSearch, setTemplateSearch] = useState('');
|
||||||
const [templateCategoryFilter, setTemplateCategoryFilter] = useState('All');
|
const [templateCategoryFilter, setTemplateCategoryFilter] = useState('All');
|
||||||
|
|
||||||
@@ -251,17 +250,34 @@ export default function EstimatesPage() {
|
|||||||
// Delete confirm
|
// Delete confirm
|
||||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
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(() => {
|
const filteredEstimates = useMemo(() => {
|
||||||
return estimates.filter(e => {
|
let result = estimates.filter(e => {
|
||||||
|
const q = estimateSearch.toLowerCase();
|
||||||
const matchesSearch =
|
const matchesSearch =
|
||||||
e.clientName.toLowerCase().includes(estimateSearch.toLowerCase()) ||
|
e.clientName.toLowerCase().includes(q) ||
|
||||||
e.address.toLowerCase().includes(estimateSearch.toLowerCase()) ||
|
e.address.toLowerCase().includes(q) ||
|
||||||
(e.templateName || '').toLowerCase().includes(estimateSearch.toLowerCase());
|
(e.templateName || '').toLowerCase().includes(q);
|
||||||
const matchesStatus = estimateStatusFilter === 'All' || e.status === estimateStatusFilter;
|
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 ----
|
// ---- filtered templates ----
|
||||||
const filteredTemplates = useMemo(() => {
|
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 ----
|
// ---- stats ----
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
const total = estimates.length;
|
const total = estimates.length;
|
||||||
const approved = estimates.filter(e => e.status === 'Approved').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;
|
const pending = estimates.filter(e => e.status === 'Sent' || e.status === 'Draft').length;
|
||||||
return { total, approved, totalValue, pending };
|
return { total, approved, totalValue, pending };
|
||||||
}, [estimates]);
|
}, [estimates, computedTotals]);
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ key: 'estimates', label: 'Estimates', count: estimates.length },
|
{ key: 'estimates', label: 'Estimates', count: estimates.length },
|
||||||
{ key: 'templates', label: 'Templates', count: templates.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'];
|
const TEMPLATE_CATEGORIES_FILTER = ['All', 'Roofing', 'Siding', 'Gutters', 'Windows', 'Insulation', 'Other'];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -400,8 +428,8 @@ export default function EstimatesPage() {
|
|||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{activeTab === 'estimates' && (
|
{activeTab === 'estimates' && (
|
||||||
<motion.div key="estimates" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
|
<motion.div key="estimates" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
|
||||||
{/* Filters */}
|
{/* Filters row 1: Search + Sort */}
|
||||||
<div className="flex flex-col sm:flex-row gap-3 mb-5">
|
<div className="flex flex-col sm:flex-row gap-3 mb-3">
|
||||||
<div className="relative flex-1">
|
<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" />
|
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400 pointer-events-none" />
|
||||||
<input
|
<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"
|
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>
|
||||||
<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 => (
|
{ESTIMATE_STATUSES.map(s => (
|
||||||
<button
|
<button
|
||||||
key={s}
|
key={s}
|
||||||
onClick={() => setEstimateStatusFilter(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
|
estimateStatusFilter === s
|
||||||
? 'bg-blue-600 text-white'
|
? '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'
|
: '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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{filteredEstimates.length === 0 ? (
|
{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">
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
{filteredEstimates.map((est, i) => (
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,549 @@
|
|||||||
|
/**
|
||||||
|
* estimateExport.js
|
||||||
|
* Utilities for exporting estimates via WhatsApp and PDF.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Line item computation — single source of truth for all estimate totals
|
||||||
|
// Mirrors EstimateBuilder's handleMeasurementsNext logic.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export function computeLineItems(template, roofArea) {
|
||||||
|
if (!template || !roofArea) return { materials: [], labor: [] };
|
||||||
|
|
||||||
|
const waste = 1.05;
|
||||||
|
const margin = 1.428; // ~30% margin
|
||||||
|
const ridgesLF = Math.round(roofArea * 1.6);
|
||||||
|
const eavesLF = Math.round(roofArea * 4.2);
|
||||||
|
|
||||||
|
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 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 { materials, labor };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeGrandTotal(template, roofArea) {
|
||||||
|
const { materials, labor } = computeLineItems(template, roofArea);
|
||||||
|
return (
|
||||||
|
materials.reduce((s, m) => s + m.clientPrice, 0) +
|
||||||
|
labor.reduce((s, l) => s + l.clientPrice, 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// WhatsApp — detailed text message
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function formatWhatsAppMessage(estimate, template, materials, labor) {
|
||||||
|
const fmt = (n) =>
|
||||||
|
`$${Number(n).toLocaleString('en-US', { minimumFractionDigits: 2 })}`;
|
||||||
|
|
||||||
|
const divider = '━━━━━━━━━━━━━━━━━━━━━━';
|
||||||
|
|
||||||
|
const lines = [];
|
||||||
|
|
||||||
|
// Header
|
||||||
|
lines.push(`*ESTIMATE — LynkedUpPro Roofing*`);
|
||||||
|
lines.push(divider);
|
||||||
|
lines.push('');
|
||||||
|
lines.push(`*Client:* ${estimate.clientName}`);
|
||||||
|
lines.push(`*Address:* ${estimate.address}`);
|
||||||
|
lines.push(`*Date:* ${estimate.date}`);
|
||||||
|
lines.push(`*Status:* ${estimate.status}`);
|
||||||
|
if (estimate.templateName) lines.push(`*Template:* ${estimate.templateName}`);
|
||||||
|
if (estimate.roofArea) lines.push(`*Roof Area:* ${estimate.roofArea} SQ`);
|
||||||
|
if (estimate.estimatedBy) lines.push(`*Estimated By:* ${estimate.estimatedBy}`);
|
||||||
|
|
||||||
|
// Notes
|
||||||
|
if (estimate.notes) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push(`_Note: ${estimate.notes}_`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scope of work
|
||||||
|
if (template?.description?.length) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push(divider);
|
||||||
|
lines.push('*SCOPE OF WORK*');
|
||||||
|
lines.push(divider);
|
||||||
|
template.description.forEach((line) => {
|
||||||
|
lines.push(`▸ ${line}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Materials
|
||||||
|
if (materials.length) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push(divider);
|
||||||
|
lines.push('*MATERIALS*');
|
||||||
|
lines.push(divider);
|
||||||
|
materials.forEach((m) => {
|
||||||
|
const unitPrice = m.clientPrice / m.qty;
|
||||||
|
lines.push(`• ${m.desc} — ${m.qty} ${m.uom} @ ${fmt(unitPrice)} = *${fmt(m.clientPrice)}*`);
|
||||||
|
});
|
||||||
|
const matTotal = materials.reduce((s, m) => s + m.clientPrice, 0);
|
||||||
|
lines.push(` _Subtotal: ${fmt(matTotal)}_`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Labor
|
||||||
|
if (labor.length) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push(divider);
|
||||||
|
lines.push('*LABOR*');
|
||||||
|
lines.push(divider);
|
||||||
|
labor.forEach((l) => {
|
||||||
|
const unitPrice = l.clientPrice / l.qty;
|
||||||
|
lines.push(`• ${l.desc} — ${l.qty} ${l.uom} @ ${fmt(unitPrice)} = *${fmt(l.clientPrice)}*`);
|
||||||
|
});
|
||||||
|
const laborTotal = labor.reduce((s, l) => s + l.clientPrice, 0);
|
||||||
|
lines.push(` _Subtotal: ${fmt(laborTotal)}_`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Financial summary
|
||||||
|
const matTotal = materials.reduce((s, m) => s + m.clientPrice, 0);
|
||||||
|
const laborTotal = labor.reduce((s, l) => s + l.clientPrice, 0);
|
||||||
|
const grandTotal = matTotal + laborTotal;
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
lines.push(divider);
|
||||||
|
lines.push('*FINANCIAL SUMMARY*');
|
||||||
|
lines.push(divider);
|
||||||
|
if (materials.length) lines.push(`Materials: ${fmt(matTotal)}`);
|
||||||
|
if (labor.length) lines.push(`Labor: ${fmt(laborTotal)}`);
|
||||||
|
lines.push(`*TOTAL: ${fmt(grandTotal)}*`);
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
lines.push('');
|
||||||
|
lines.push(divider);
|
||||||
|
lines.push('_This estimate is valid for 30 days from the date above._');
|
||||||
|
lines.push('_LynkedUpPro Roofing | Plano, TX | lynkeduppro.com_');
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openWhatsApp(message) {
|
||||||
|
const url = `https://wa.me/?text=${encodeURIComponent(message)}`;
|
||||||
|
window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openIMessage(message) {
|
||||||
|
// sms: scheme opens Messages on iOS/macOS; body pre-fills the message
|
||||||
|
window.open(`sms:?&body=${encodeURIComponent(message)}`, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openEmail(estimate, message) {
|
||||||
|
const subject = `Estimate for ${estimate.clientName} — ${estimate.date}`;
|
||||||
|
const url = `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(message)}`;
|
||||||
|
window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PDF — light-mode, color-coded
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Color palette
|
||||||
|
const C = {
|
||||||
|
// Blues — header + materials
|
||||||
|
blueDark: [30, 58, 138], // #1E3A8A
|
||||||
|
blue: [59, 130, 246], // #3B82F6
|
||||||
|
blueLight: [239, 246, 255], // #EFF6FF
|
||||||
|
blueMid: [219, 234, 254], // #DBEAFE
|
||||||
|
|
||||||
|
// Purples — labor
|
||||||
|
purpleDark: [88, 28, 135], // #581C87
|
||||||
|
purple: [139, 92, 246], // #8B5CF6
|
||||||
|
purpleLight:[250, 245, 255], // #FAF5FF
|
||||||
|
purpleMid: [237, 233, 254], // #EDE9FE
|
||||||
|
|
||||||
|
// Greens — totals
|
||||||
|
greenDark: [5, 150, 105], // #059669
|
||||||
|
greenLight: [236, 253, 245], // #ECFDF5
|
||||||
|
|
||||||
|
// Neutrals
|
||||||
|
zinc900: [24, 24, 27], // text
|
||||||
|
zinc700: [63, 63, 70],
|
||||||
|
zinc500: [113, 113, 122],
|
||||||
|
zinc400: [161, 161, 170],
|
||||||
|
zinc200: [228, 228, 231],
|
||||||
|
zinc100: [244, 244, 245],
|
||||||
|
white: [255, 255, 255],
|
||||||
|
|
||||||
|
// Amber — notes
|
||||||
|
amberLight: [255, 251, 235], // #FFFBEB
|
||||||
|
amberBorder:[251, 191, 36], // #FBBf24
|
||||||
|
amber800: [146, 64, 14], // #92400E
|
||||||
|
};
|
||||||
|
|
||||||
|
function rgb(arr) { return { r: arr[0], g: arr[1], b: arr[2] }; }
|
||||||
|
function setFill(doc, arr) { doc.setFillColor(...arr); }
|
||||||
|
function setDraw(doc, arr) { doc.setDrawColor(...arr); }
|
||||||
|
function setTextColor(doc, arr) { doc.setTextColor(...arr); }
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export async function generateEstimatePDF(estimate, template, materials, labor) {
|
||||||
|
// Dynamic import so jsPDF doesn't bloat the initial bundle
|
||||||
|
const { default: jsPDF } = await import('jspdf');
|
||||||
|
const { default: autoTable } = await import('jspdf-autotable');
|
||||||
|
|
||||||
|
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
|
||||||
|
const PW = 210; // page width
|
||||||
|
const PH = 297; // page height
|
||||||
|
const ML = 15; // margin left
|
||||||
|
const MR = 15; // margin right
|
||||||
|
const CW = PW - ML - MR; // content width
|
||||||
|
|
||||||
|
let y = 0;
|
||||||
|
|
||||||
|
// ---- HEADER BAR -------------------------------------------------------
|
||||||
|
setFill(doc, C.blueDark);
|
||||||
|
doc.rect(0, 0, PW, 42, 'F');
|
||||||
|
|
||||||
|
// Company name
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(20);
|
||||||
|
setTextColor(doc, C.white);
|
||||||
|
doc.text('LynkedUpPro Roofing', ML, 14);
|
||||||
|
|
||||||
|
// Tagline
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setTextColor(180, 200, 255);
|
||||||
|
doc.text('Professional Roofing Estimates | Plano, TX', ML, 20);
|
||||||
|
|
||||||
|
// ESTIMATE label (right side)
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(26);
|
||||||
|
setTextColor(doc, C.white);
|
||||||
|
doc.text('ESTIMATE', PW - MR, 16, { align: 'right' });
|
||||||
|
|
||||||
|
// Estimate meta — right aligned
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setTextColor(200, 220, 255);
|
||||||
|
doc.text(`Date: ${estimate.date}`, PW - MR, 23, { align: 'right' });
|
||||||
|
doc.text(`ID: ${estimate.id}`, PW - MR, 28, { align: 'right' });
|
||||||
|
doc.text(`Status: ${estimate.status}`, PW - MR, 33, { align: 'right' });
|
||||||
|
|
||||||
|
// Status dot
|
||||||
|
const statusColors = {
|
||||||
|
Approved: C.greenDark,
|
||||||
|
Sent: C.blue,
|
||||||
|
Draft: C.zinc500,
|
||||||
|
Rejected: [220, 38, 38],
|
||||||
|
Expired: [217, 119, 6],
|
||||||
|
};
|
||||||
|
setFill(doc, statusColors[estimate.status] ?? C.zinc500);
|
||||||
|
doc.circle(PW - MR - doc.getTextWidth(`Status: ${estimate.status}`) - 3, 32.2, 1.5, 'F');
|
||||||
|
|
||||||
|
y = 50;
|
||||||
|
|
||||||
|
// ---- CLIENT INFO CARD -------------------------------------------------
|
||||||
|
setFill(doc, C.blueLight);
|
||||||
|
setDraw(doc, C.blueMid);
|
||||||
|
doc.setLineWidth(0.3);
|
||||||
|
doc.roundedRect(ML, y, CW, 28, 3, 3, 'FD');
|
||||||
|
|
||||||
|
// Blue left accent bar
|
||||||
|
setFill(doc, C.blue);
|
||||||
|
doc.roundedRect(ML, y, 2.5, 28, 1, 1, 'F');
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(7.5);
|
||||||
|
setTextColor(doc, C.blue);
|
||||||
|
doc.text('CLIENT INFORMATION', ML + 6, y + 6);
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(13);
|
||||||
|
setTextColor(doc, C.zinc900);
|
||||||
|
doc.text(estimate.clientName, ML + 6, y + 13);
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setFontSize(9);
|
||||||
|
setTextColor(doc, C.zinc500);
|
||||||
|
doc.text(estimate.address, ML + 6, y + 19.5);
|
||||||
|
|
||||||
|
const metaItems = [
|
||||||
|
estimate.estimatedBy && `Estimated by: ${estimate.estimatedBy}`,
|
||||||
|
estimate.roofArea && `Roof area: ${estimate.roofArea} SQ`,
|
||||||
|
estimate.templateName && `Template: ${estimate.templateName}`,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
doc.setFontSize(8);
|
||||||
|
setTextColor(doc, C.zinc400);
|
||||||
|
doc.text(metaItems.join(' · '), ML + 6, y + 25);
|
||||||
|
|
||||||
|
y += 35;
|
||||||
|
|
||||||
|
// ---- NOTES ------------------------------------------------------------
|
||||||
|
if (estimate.notes) {
|
||||||
|
setFill(doc, C.amberLight);
|
||||||
|
setDraw(doc, C.amberBorder);
|
||||||
|
doc.setLineWidth(0.3);
|
||||||
|
const noteLines = doc.splitTextToSize(`Note: ${estimate.notes}`, CW - 12);
|
||||||
|
const noteH = noteLines.length * 5 + 8;
|
||||||
|
doc.roundedRect(ML, y, CW, noteH, 2, 2, 'FD');
|
||||||
|
doc.setFont('helvetica', 'italic');
|
||||||
|
doc.setFontSize(8);
|
||||||
|
setTextColor(doc, C.amber800);
|
||||||
|
doc.text(noteLines, ML + 5, y + 6);
|
||||||
|
y += noteH + 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- SCOPE OF WORK ----------------------------------------------------
|
||||||
|
if (template?.description?.length) {
|
||||||
|
// Section heading
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(9);
|
||||||
|
setTextColor(doc, C.blueDark);
|
||||||
|
doc.text('SCOPE OF WORK', ML, y + 5);
|
||||||
|
setFill(doc, C.blue);
|
||||||
|
doc.rect(ML, y + 7, CW, 0.6, 'F');
|
||||||
|
y += 12;
|
||||||
|
|
||||||
|
template.description.forEach((line) => {
|
||||||
|
const wrapped = doc.splitTextToSize(line, CW - 8);
|
||||||
|
// Bullet dot
|
||||||
|
setFill(doc, C.blue);
|
||||||
|
doc.circle(ML + 2, y + 1.5, 1, 'F');
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setFontSize(8.5);
|
||||||
|
setTextColor(doc, C.zinc700);
|
||||||
|
doc.text(wrapped, ML + 6, y + 3);
|
||||||
|
y += wrapped.length * 5 + 2;
|
||||||
|
});
|
||||||
|
y += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- MATERIALS TABLE --------------------------------------------------
|
||||||
|
if (materials.length) {
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(9);
|
||||||
|
setTextColor(doc, C.blueDark);
|
||||||
|
doc.text('MATERIALS', ML, y + 5);
|
||||||
|
setFill(doc, C.blue);
|
||||||
|
doc.rect(ML, y + 7, CW, 0.6, 'F');
|
||||||
|
y += 10;
|
||||||
|
|
||||||
|
autoTable(doc, {
|
||||||
|
startY: y,
|
||||||
|
margin: { left: ML, right: MR },
|
||||||
|
head: [['Description', 'Qty', 'UOM', 'Unit Price', 'Total']],
|
||||||
|
body: materials.map(m => [
|
||||||
|
m.desc,
|
||||||
|
m.qty,
|
||||||
|
m.uom,
|
||||||
|
`$${(m.clientPrice / m.qty).toFixed(2)}`,
|
||||||
|
`$${m.clientPrice.toLocaleString('en-US', { minimumFractionDigits: 2 })}`
|
||||||
|
]),
|
||||||
|
foot: [[
|
||||||
|
{ content: 'Materials Subtotal', colSpan: 4, styles: { halign: 'right', fontStyle: 'bold' } },
|
||||||
|
{
|
||||||
|
content: `$${materials.reduce((s, m) => s + m.clientPrice, 0).toLocaleString('en-US', { minimumFractionDigits: 2 })}`,
|
||||||
|
styles: { fontStyle: 'bold' }
|
||||||
|
}
|
||||||
|
]],
|
||||||
|
headStyles: {
|
||||||
|
fillColor: C.blue,
|
||||||
|
textColor: C.white,
|
||||||
|
fontStyle: 'bold',
|
||||||
|
fontSize: 8,
|
||||||
|
},
|
||||||
|
footStyles: {
|
||||||
|
fillColor: C.blueMid,
|
||||||
|
textColor: C.blueDark,
|
||||||
|
fontSize: 8.5,
|
||||||
|
},
|
||||||
|
bodyStyles: { fontSize: 8, textColor: C.zinc700 },
|
||||||
|
alternateRowStyles: { fillColor: C.blueLight },
|
||||||
|
columnStyles: {
|
||||||
|
0: { cellWidth: 'auto' },
|
||||||
|
1: { halign: 'right', cellWidth: 18 },
|
||||||
|
2: { halign: 'center', cellWidth: 18 },
|
||||||
|
3: { halign: 'right', cellWidth: 25 },
|
||||||
|
4: { halign: 'right', cellWidth: 32 },
|
||||||
|
},
|
||||||
|
showFoot: 'lastPage',
|
||||||
|
tableLineColor: C.blueMid,
|
||||||
|
tableLineWidth: 0.2,
|
||||||
|
});
|
||||||
|
|
||||||
|
y = doc.lastAutoTable.finalY + 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- LABOR TABLE ------------------------------------------------------
|
||||||
|
if (labor.length) {
|
||||||
|
// Page break check
|
||||||
|
if (y > PH - 60) { doc.addPage(); y = 20; }
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(9);
|
||||||
|
setTextColor(doc, C.purpleDark);
|
||||||
|
doc.text('LABOR', ML, y + 5);
|
||||||
|
setFill(doc, C.purple);
|
||||||
|
doc.rect(ML, y + 7, CW, 0.6, 'F');
|
||||||
|
y += 10;
|
||||||
|
|
||||||
|
autoTable(doc, {
|
||||||
|
startY: y,
|
||||||
|
margin: { left: ML, right: MR },
|
||||||
|
head: [['Description', 'Qty', 'UOM', 'Unit Price', 'Total']],
|
||||||
|
body: labor.map(l => [
|
||||||
|
l.desc,
|
||||||
|
l.qty,
|
||||||
|
l.uom,
|
||||||
|
`$${(l.clientPrice / l.qty).toFixed(2)}`,
|
||||||
|
`$${l.clientPrice.toLocaleString('en-US', { minimumFractionDigits: 2 })}`
|
||||||
|
]),
|
||||||
|
foot: [[
|
||||||
|
{ content: 'Labor Subtotal', colSpan: 4, styles: { halign: 'right', fontStyle: 'bold' } },
|
||||||
|
{
|
||||||
|
content: `$${labor.reduce((s, l) => s + l.clientPrice, 0).toLocaleString('en-US', { minimumFractionDigits: 2 })}`,
|
||||||
|
styles: { fontStyle: 'bold' }
|
||||||
|
}
|
||||||
|
]],
|
||||||
|
headStyles: {
|
||||||
|
fillColor: C.purple,
|
||||||
|
textColor: C.white,
|
||||||
|
fontStyle: 'bold',
|
||||||
|
fontSize: 8,
|
||||||
|
},
|
||||||
|
footStyles: {
|
||||||
|
fillColor: C.purpleMid,
|
||||||
|
textColor: C.purpleDark,
|
||||||
|
fontSize: 8.5,
|
||||||
|
},
|
||||||
|
bodyStyles: { fontSize: 8, textColor: C.zinc700 },
|
||||||
|
alternateRowStyles: { fillColor: C.purpleLight },
|
||||||
|
columnStyles: {
|
||||||
|
0: { cellWidth: 'auto' },
|
||||||
|
1: { halign: 'right', cellWidth: 18 },
|
||||||
|
2: { halign: 'center', cellWidth: 18 },
|
||||||
|
3: { halign: 'right', cellWidth: 25 },
|
||||||
|
4: { halign: 'right', cellWidth: 32 },
|
||||||
|
},
|
||||||
|
showFoot: 'lastPage',
|
||||||
|
tableLineColor: C.purpleMid,
|
||||||
|
tableLineWidth: 0.2,
|
||||||
|
});
|
||||||
|
|
||||||
|
y = doc.lastAutoTable.finalY + 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- FINANCIAL SUMMARY ------------------------------------------------
|
||||||
|
if (y > PH - 55) { doc.addPage(); y = 20; }
|
||||||
|
|
||||||
|
const matTotal = materials.reduce((s, m) => s + m.clientPrice, 0);
|
||||||
|
const laborTotal = labor.reduce((s, l) => s + l.clientPrice, 0);
|
||||||
|
const grandTotal = matTotal + laborTotal;
|
||||||
|
|
||||||
|
const summaryX = PW - MR - 85;
|
||||||
|
const summaryW = 85;
|
||||||
|
let sy = y;
|
||||||
|
|
||||||
|
// Section label
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(9);
|
||||||
|
setTextColor(doc, C.zinc900);
|
||||||
|
doc.text('FINANCIAL SUMMARY', ML, sy + 5);
|
||||||
|
setFill(doc, C.zinc200);
|
||||||
|
doc.rect(ML, sy + 7, CW, 0.4, 'F');
|
||||||
|
sy += 12;
|
||||||
|
|
||||||
|
// Summary box
|
||||||
|
setFill(doc, C.zinc100);
|
||||||
|
setDraw(doc, C.zinc200);
|
||||||
|
doc.setLineWidth(0.3);
|
||||||
|
doc.roundedRect(summaryX, sy, summaryW, 36, 2, 2, 'FD');
|
||||||
|
|
||||||
|
const rowH = 8;
|
||||||
|
const rows = [
|
||||||
|
materials.length ? ['Materials', matTotal] : null,
|
||||||
|
labor.length ? ['Labor', laborTotal] : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
rows.forEach(([label, val], i) => {
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setFontSize(8.5);
|
||||||
|
setTextColor(doc, C.zinc500);
|
||||||
|
doc.text(label, summaryX + 5, sy + 8 + i * rowH);
|
||||||
|
setTextColor(doc, C.zinc700);
|
||||||
|
doc.text(`$${val.toLocaleString('en-US', { minimumFractionDigits: 2 })}`, summaryX + summaryW - 5, sy + 8 + i * rowH, { align: 'right' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Divider
|
||||||
|
const divY = sy + rows.length * rowH + 4;
|
||||||
|
setDraw(doc, C.zinc200);
|
||||||
|
doc.setLineWidth(0.4);
|
||||||
|
doc.line(summaryX + 4, divY, summaryX + summaryW - 4, divY);
|
||||||
|
|
||||||
|
// Grand total row
|
||||||
|
setFill(doc, C.greenLight);
|
||||||
|
doc.roundedRect(summaryX, divY + 1, summaryW, 12, 0, 0, 'F');
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setFontSize(10);
|
||||||
|
setTextColor(doc, C.greenDark);
|
||||||
|
doc.text('TOTAL', summaryX + 5, divY + 9);
|
||||||
|
doc.text(`$${grandTotal.toLocaleString('en-US', { minimumFractionDigits: 2 })}`, summaryX + summaryW - 5, divY + 9, { align: 'right' });
|
||||||
|
|
||||||
|
y = Math.max(y + 55, divY + 25);
|
||||||
|
|
||||||
|
// ---- FOOTER -----------------------------------------------------------
|
||||||
|
const footerY = PH - 18;
|
||||||
|
setFill(doc, C.zinc100);
|
||||||
|
doc.rect(0, footerY - 2, PW, 20, 'F');
|
||||||
|
setDraw(doc, C.zinc200);
|
||||||
|
doc.setLineWidth(0.3);
|
||||||
|
doc.line(0, footerY - 2, PW, footerY - 2);
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setFontSize(7.5);
|
||||||
|
setTextColor(doc, C.zinc500);
|
||||||
|
doc.text('LynkedUpPro Roofing | Plano, TX | lynkeduppro.com', ML, footerY + 4);
|
||||||
|
doc.text('This estimate is valid for 30 days from the date of issue.', ML, footerY + 9);
|
||||||
|
|
||||||
|
// Page numbers
|
||||||
|
const pageCount = doc.internal.getNumberOfPages();
|
||||||
|
for (let i = 1; i <= pageCount; i++) {
|
||||||
|
doc.setPage(i);
|
||||||
|
doc.setFontSize(7.5);
|
||||||
|
setTextColor(doc, C.zinc400);
|
||||||
|
doc.text(`Page ${i} of ${pageCount}`, PW - MR, footerY + 4, { align: 'right' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- SAVE -------------------------------------------------------------
|
||||||
|
const safeName = estimate.clientName.replace(/\s+/g, '_');
|
||||||
|
doc.save(`Estimate_${safeName}_${estimate.date}.pdf`);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user