diff --git a/src/components/estimates/EstimateDetailModal.jsx b/src/components/estimates/EstimateDetailModal.jsx index f1ffad6..91b0d92 100644 --- a/src/components/estimates/EstimateDetailModal.jsx +++ b/src/components/estimates/EstimateDetailModal.jsx @@ -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 ( @@ -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 ( +
- return { materials, labor }; + {/* Pipeline track */} +

Estimate Status

+
+ {PIPELINE_STEPS.map((step, i) => { + const isCompleted = i < effectivePipelineIdx; + const isCurrent = !isEdge && i === pipelineIdx; + const col = PIPELINE_COLORS[i]; + + return ( + + {/* Node */} + + + {/* Connector */} + {i < PIPELINE_STEPS.length - 1 && ( +
+ )} + + ); + })} +
+ + {/* Edge status pills */} +
+ Exceptions + {Object.entries(EDGE_STATUSES).map(([label, cfg]) => { + const Icon = cfg.icon; + const isActive = currentStatus === label; + return ( + + ); + })} +
+
+ ); } -// --------------------------------------------------------------------------- -// 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 }) { Description Qty UOM - Unit - Client Price + Unit Price + Total
- {items.map((item, i) => ( + {items.map((item, i) => { + const unitPrice = item.clientPrice / item.qty; + return (
{item.desc} {item.qty} {item.uom} - ${item.unitCost.toFixed(2)} + ${unitPrice.toFixed(2)} ${item.clientPrice.toLocaleString('en-US', { minimumFractionDigits: 2 })}
- ))} + ); + })}
@@ -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 }) {
+ {/* Share & Export bar */} +
+ Share + + {/* WhatsApp */} + + + {/* iMessage */} + + + {/* Email */} + + + {/* PDF */} + +
+ + {/* Status tracker */} + updateEstimate(estimate.id, { status: newStatus })} + /> + {/* Scrollable body */}
@@ -270,7 +398,7 @@ export default function EstimateDetailModal({ isOpen, onClose, estimate }) { {/* Footer — financial summary */} {(materials.length > 0 || labor.length > 0) && ( -
+
Materials subtotal @@ -282,7 +410,7 @@ export default function EstimateDetailModal({ isOpen, onClose, estimate }) {
Total (Client Price) - ${grandTotal.toLocaleString('en-US', { minimumFractionDigits: 2 })} + ${grandTotal.toLocaleString('en-US', { minimumFractionDigits: 2 })}
{estimate.total !== grandTotal && (

diff --git a/src/pages/EstimatesPage.jsx b/src/pages/EstimatesPage.jsx index 32ac7ba..7ffc81b 100644 --- a/src/pages/EstimatesPage.jsx +++ b/src/pages/EstimatesPage.jsx @@ -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 ( - {cfg.label} + {status} ); } @@ -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 ( - ${estimate.total.toLocaleString('en-US', { minimumFractionDigits: 2 })} + ${displayTotal.toLocaleString('en-US', { minimumFractionDigits: 2 })} View @@ -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() { {activeTab === 'estimates' && ( - {/* Filters */} -

+ {/* Filters row 1: Search + Sort */} +
-
+ {/* Sort control */} +
+ {[ + { key: 'newest', icon: ArrowDown, label: 'Newest' }, + { key: 'oldest', icon: ArrowUp, label: 'Oldest' }, + { key: 'status', icon: ArrowDownUp, label: 'Status' }, + ].map(({ key, icon: Icon, label }) => ( + + ))} +
+
+ + {/* Filters row 2: Status chips + Date range */} +
+
{ESTIMATE_STATUSES.map(s => ( ))}
+ {/* Date range */} +
+ 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" + /> + + 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) && ( + + )} +
{filteredEstimates.length === 0 ? ( @@ -437,7 +518,7 @@ export default function EstimatesPage() { ) : (
{filteredEstimates.map((est, i) => ( - setDetailEstimate(est)} /> + setDetailEstimate(est)} /> ))}
)} diff --git a/src/utils/estimateExport.js b/src/utils/estimateExport.js new file mode 100644 index 0000000..cd2efb2 --- /dev/null +++ b/src/utils/estimateExport.js @@ -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`); +}