From 075d2dd60f3719a9517eccaabc276982357b1632 Mon Sep 17 00:00:00 2001 From: Mayur Shinde Date: Tue, 9 Jun 2026 18:01:32 +0530 Subject: [PATCH] wizard admin feature in existing crm as separate route --- src/App.jsx | 6 + src/components/Layout.jsx | 2 + .../estimateWizard/CustomerEstimateWizard.jsx | 14 +- .../estimateWizard/EstimateWizardAdmin.jsx | 193 +++++++++++++++ src/modules/estimateWizard/ai/explain.js | 4 +- src/modules/estimateWizard/api/crmHandoff.js | 71 ++++++ .../estimateWizard/components/WizardShell.jsx | 6 +- .../components/steps/ReportStep.jsx | 135 +++++++++++ .../estimateWizard/data/tenantConfig.js | 50 +++- .../utils/wizardReportExport.js | 223 ++++++++++++++++++ 10 files changed, 695 insertions(+), 9 deletions(-) create mode 100644 src/modules/estimateWizard/EstimateWizardAdmin.jsx create mode 100644 src/modules/estimateWizard/api/crmHandoff.js create mode 100644 src/modules/estimateWizard/components/steps/ReportStep.jsx create mode 100644 src/modules/estimateWizard/utils/wizardReportExport.js diff --git a/src/App.jsx b/src/App.jsx index a9d4661..f24f9bf 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -46,6 +46,7 @@ import FieldStormZonePage from './pages/FieldStormZonePage'; import UserDetailsPage from './pages/UserDetailsPage'; import LeadVerificationPage from './pages/LeadVerification/LeadVerificationPage'; import CustomerEstimateWizard from './modules/estimateWizard/CustomerEstimateWizard'; +import EstimateWizardAdmin from './modules/estimateWizard/EstimateWizardAdmin'; // ... (existing imports) const ProtectedRoute = ({ children, allowedRoles }) => { @@ -77,6 +78,11 @@ function App() { } /> {/* Public DIY Roofing Estimate Wizard — homeowner-facing, no auth, chrome-less */} } /> + + + + } /> { { to: "/owner/people", icon: Users, label: "People" }, { to: "/owner/settings", icon: Settings, label: "Org Settings" }, ...commonItems, + { to: "/estimate-wizard/admin", icon: Settings, label: "Wizard Admin" }, ]; case 'ADMIN': return [ @@ -178,6 +179,7 @@ const Layout = () => { { to: "/admin/subcontractor-tasks", icon: HardHat, label: "Subcontractor Tasks" }, { to: "/admin/settings", icon: Settings, label: "Org Settings" }, ...commonItems, + { to: "/estimate-wizard/admin", icon: Settings, label: "Wizard Admin" }, ]; case 'CONTRACTOR': case 'SUBCONTRACTOR': diff --git a/src/modules/estimateWizard/CustomerEstimateWizard.jsx b/src/modules/estimateWizard/CustomerEstimateWizard.jsx index 2475729..24c3132 100644 --- a/src/modules/estimateWizard/CustomerEstimateWizard.jsx +++ b/src/modules/estimateWizard/CustomerEstimateWizard.jsx @@ -33,8 +33,20 @@ const CompletionScreen = () => { ); }; +const FeatureDisabledScreen = () => ( +
+
+

Estimate wizard unavailable

+

+ This tool is temporarily turned off. Please contact us directly and we’ll be glad to help. +

+
+
+); + const WizardRouter = () => { - const { session } = useEstimateWizard(); + const { session, tenantConfig } = useEstimateWizard(); + if (tenantConfig.featureEnabled === false) return ; // §18 kill-switch return session.status === 'completed' ? : ; }; diff --git a/src/modules/estimateWizard/EstimateWizardAdmin.jsx b/src/modules/estimateWizard/EstimateWizardAdmin.jsx new file mode 100644 index 0000000..d68dba5 --- /dev/null +++ b/src/modules/estimateWizard/EstimateWizardAdmin.jsx @@ -0,0 +1,193 @@ +/** + * EstimateWizardAdmin — internal admin controls + analytics (spec §14, §16, §18). + * + * Tenant-controls (no hard-coded pricing/copy): feature kill-switch, AI toggle, + * conservative mode, per-package price + base discount + service life, and report + * branding. Persists as overrides via saveConfigOverrides(). Also shows a funnel + * summary built from completed-session submissions (spec §16). + */ +import React, { useMemo, useState } from 'react'; +import { toast } from 'sonner'; +import { Save, RotateCcw, Power, Sparkles, ShieldCheck, BarChart3 } from 'lucide-react'; +import { + getTenantConfig, loadConfigOverrides, saveConfigOverrides, resetConfigOverrides, +} from './data/tenantConfig'; +import { loadSubmissions } from './api/crmHandoff'; + +const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' }; + +const Toggle = ({ label, desc, checked, onChange, icon: Icon }) => ( + +); + +const numInput = 'w-full rounded-lg border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-2.5 py-1.5 text-sm text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-amber-500'; + +const EstimateWizardAdmin = () => { + const initial = useMemo(() => getTenantConfig(), []); + const [cfg, setCfg] = useState(initial); + + const submissions = useMemo(() => loadSubmissions(), []); + const analytics = useMemo(() => { + const total = submissions.length; + const dist = (key) => submissions.reduce((m, s) => { const k = s[key]; if (k) m[k] = (m[k] || 0) + 1; return m; }, {}); + const booked = submissions.filter((s) => s.report?.appointmentBooked).length; + const addonAttach = total ? (submissions.reduce((n, s) => n + (s.selectedAddons?.length || 0), 0) / total) : 0; + return { total, recommended: dist('recommendedPackage'), selected: dist('selectedPackage'), booked, addonAttach }; + }, [submissions]); + + const setFlag = (k, v) => setCfg((c) => ({ ...c, [k]: v })); + const setPkg = (type, field, value) => setCfg((c) => ({ + ...c, + packages: c.packages.map((p) => (p.type === type ? { ...p, [field]: value } : p)), + })); + const setDiscount = (type, value) => setCfg((c) => ({ + ...c, + discountScenarios: { ...c.discountScenarios, [type]: { ...c.discountScenarios[type], base: value } }, + })); + const setBranding = (field, value) => setCfg((c) => ({ ...c, branding: { ...c.branding, [field]: value } })); + + const handleSave = () => { + const prev = loadConfigOverrides(); + const overrides = { + ...prev, + featureEnabled: cfg.featureEnabled, + aiExplanationsEnabled: cfg.aiExplanationsEnabled, + conservativeMode: cfg.conservativeMode, + branding: cfg.branding, + discountScenarios: cfg.discountScenarios, + packages: cfg.packages.map((p) => ({ + type: p.type, pricePerSquare: p.pricePerSquare, serviceLifeYears: p.serviceLifeYears, + })), + }; + saveConfigOverrides(overrides); + toast.success('Estimate Wizard settings saved'); + }; + + const handleReset = () => { + resetConfigOverrides(); + setCfg(getTenantConfig()); + toast.message('Reset to defaults'); + }; + + return ( +
+
+ {/* Header */} +
+
+

+ Estimate Wizard — Admin +

+

+ Tenant controls for pricing, discounts, AI, branding, and analytics. +

+
+
+ + +
+
+ + {/* Feature flags */} +
+ setFlag('featureEnabled', v)} /> + setFlag('aiExplanationsEnabled', v)} /> + setFlag('conservativeMode', v)} /> +
+ + {/* Package pricing */} +
+

Packages, pricing & discounts

+
+ {cfg.packages.map((p) => ( +
+
+
{PKG_LABEL[p.type]}
+
{p.name}
+
+ + + +
+ ))} +
+

Customer copy always states discounts are illustrative — "ask your carrier to confirm."

+
+ + {/* Branding */} +
+

Report branding

+
+ + + +
+
+ + {/* Analytics */} +
+

+ Funnel analytics +

+ {analytics.total === 0 ? ( +

No completed estimates yet. Finish a wizard run to populate metrics.

+ ) : ( +
+ + + + + + +
+ )} +
+
+
+ ); +}; + +const Metric = ({ label, value }) => ( +
+
{label}
+
{value}
+
+); + +const DistRow = ({ label, dist }) => ( +
+
{label}
+
+ {['GOOD', 'BETTER', 'BEST'].map((t) => ( + {PKG_LABEL[t]}: {dist[t] || 0} + ))} +
+
+); + +export default EstimateWizardAdmin; diff --git a/src/modules/estimateWizard/ai/explain.js b/src/modules/estimateWizard/ai/explain.js index 4703337..33ed854 100644 --- a/src/modules/estimateWizard/ai/explain.js +++ b/src/modules/estimateWizard/ai/explain.js @@ -79,8 +79,8 @@ Respond ONLY as JSON: {"customerExplanation": "...", "repBrief": "..."}`; } export async function generateExplanation(ctx) { - // No valid key → deterministic mode (still fully functional for the demo). - if (config.isDemoMode(config.groqApiKey)) { + // Admin disabled AI (spec §18) or no valid key → deterministic mode. + if (ctx.tenantConfig?.aiExplanationsEnabled === false || config.isDemoMode(config.groqApiKey)) { return fallbackExplanation(ctx); } diff --git a/src/modules/estimateWizard/api/crmHandoff.js b/src/modules/estimateWizard/api/crmHandoff.js new file mode 100644 index 0000000..17e5967 --- /dev/null +++ b/src/modules/estimateWizard/api/crmHandoff.js @@ -0,0 +1,71 @@ +/** + * crmHandoff.js — completion handoff to CRM + nurture (spec §11, §13, §17 E2). + * + * The wizard is a self-contained module, so instead of mutating the global CRM + * store it writes a CRM-ready submission to localStorage. An internal page (or a + * future adapter) can read this list to create the contact/lead and fire the + * nurture playbook. Also returns the immutable compliance record (spec §18 G1). + */ + +const SUBMISSIONS_KEY = 'lup_estimate_wizard_submissions_v1'; + +export function loadSubmissions() { + try { + const raw = localStorage.getItem(SUBMISSIONS_KEY); + return raw ? JSON.parse(raw) : []; + } catch { return []; } +} + +function persist(list) { + try { localStorage.setItem(SUBMISSIONS_KEY, JSON.stringify(list)); } catch { /* quota */ } +} + +/** + * Build the immutable compliance record stored with the report (spec §18 G1): + * assumptions + source data + disclaimer version + package-config version + consent. + */ +export function buildComplianceRecord(session, tenantConfig) { + return { + consent: !!session.inputs.consent, + tenantConfigVersion: tenantConfig.version, + disclaimerVersion: tenantConfig.disclaimerVersion, + scoringModelVersion: session.recommendation?.modelVersion || null, + hailSource: session.hailSnapshot?.source || null, + hailWindowYears: session.hailSnapshot?.windowYears || null, + hailConfidence: session.hailSnapshot?.confidence || null, + assumedSquares: tenantConfig.pricing.assumedSquares, + conservativeMode: tenantConfig.conservativeMode, + aiUsed: session.recommendation?.explanation?.aiUsed ?? false, + generatedAt: new Date().toISOString(), + }; +} + +/** + * Create a CRM contact/lead payload + nurture trigger from a completed session. + * @returns the submission record (also persisted). + */ +export function submitToCrm(session, tenantConfig, reportMeta) { + const i = session.inputs; + const submission = { + sessionId: session.id, + submittedAt: new Date().toISOString(), + contact: { name: i.name, address: i.address, phone: i.phone, email: i.email, contactMethod: i.contactMethod }, + source: session.source, + selectedPackage: session.selectedPackage, + selectedAddons: session.selectedAddons, + recommendedPackage: session.recommendation?.selectedPackage || null, + hail: session.hailSnapshot + ? { exposureTier: session.hailSnapshot.exposureTier, p10Base: session.hailSnapshot.probability10yr?.base } + : null, + repBrief: session.recommendation?.explanation?.repBrief || null, + report: reportMeta || null, + compliance: buildComplianceRecord(session, tenantConfig), + // Nurture handoff (spec §14): which playbook trigger fired. + nurtureTrigger: 'wizard_completed', + }; + + const list = loadSubmissions(); + list.unshift(submission); + persist(list); + return submission; +} diff --git a/src/modules/estimateWizard/components/WizardShell.jsx b/src/modules/estimateWizard/components/WizardShell.jsx index fddf82c..3d0d76f 100644 --- a/src/modules/estimateWizard/components/WizardShell.jsx +++ b/src/modules/estimateWizard/components/WizardShell.jsx @@ -19,9 +19,9 @@ import HailRiskStep from './steps/HailRiskStep'; import OptionsStep from './steps/OptionsStep'; import AddonsStep from './steps/AddonsStep'; import RecommendationStep from './steps/RecommendationStep'; -import PlaceholderStep from './steps/PlaceholderStep'; +import ReportStep from './steps/ReportStep'; -// Step registry — later phases swap PlaceholderStep entries for real components. +// Step registry — every step now has a real component. const STEP_COMPONENTS = { start: StartStep, address: AddressStep, @@ -30,7 +30,7 @@ const STEP_COMPONENTS = { options: OptionsStep, addons: AddonsStep, recommendation: RecommendationStep, - report: (props) => , + report: ReportStep, }; // Validation per step: returns true when the user may advance. diff --git a/src/modules/estimateWizard/components/steps/ReportStep.jsx b/src/modules/estimateWizard/components/steps/ReportStep.jsx new file mode 100644 index 0000000..f70ee56 --- /dev/null +++ b/src/modules/estimateWizard/components/steps/ReportStep.jsx @@ -0,0 +1,135 @@ +/** + * ReportStep — Screen 9 "Report + next step" (spec §11, §13). + * Shows the report summary, lets the customer email/print/download/share, books + * the inspection, and fires the CRM + nurture handoff (once) with the immutable + * compliance record (spec §18 G1). + */ +import React, { useEffect, useState } from 'react'; +import { Download, Printer, Mail, Share2, CalendarCheck, FileText, Loader2, Check } from 'lucide-react'; +import { useEstimateWizard } from '../../EstimateWizardContext'; +import { buildReportModel, downloadReportPdf, getReportBlobUrl } from '../../utils/wizardReportExport'; +import { submitToCrm } from '../../api/crmHandoff'; +import { formatUSD } from '../../engine/pricing'; + +const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' }; + +const ActionButton = ({ icon: Icon, label, onClick, busy, primary }) => ( + +); + +const ReportStep = () => { + const { session, tenantConfig, patchSession, logEvent } = useEstimateWizard(); + const model = buildReportModel(session, tenantConfig); + const [busy, setBusy] = useState(null); + const [booked, setBooked] = useState(!!session.report?.appointmentBooked); + + // Fire CRM + nurture handoff once (spec §13 / §17 E2). + useEffect(() => { + if (session.report) return; + const reportMeta = { reportId: model.reportId, version: 1, generatedAt: model.generatedAt, deliveryStatus: 'created' }; + patchSession({ report: reportMeta }); + submitToCrm(session, tenantConfig, reportMeta); + logEvent('report_generated', { reportId: model.reportId }); + logEvent('crm_handoff', { trigger: 'wizard_completed' }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleDownload = async () => { + setBusy('download'); + try { await downloadReportPdf(session, tenantConfig); logEvent('report_downloaded', { reportId: model.reportId }); } + finally { setBusy(null); } + }; + + const handlePrint = async () => { + setBusy('print'); + try { + const { url } = await getReportBlobUrl(session, tenantConfig); + const w = window.open(url, '_blank'); + if (w) w.addEventListener('load', () => w.print()); + patchSession({ report: { ...session.report, printedAt: new Date().toISOString() } }); + logEvent('report_printed', { reportId: model.reportId }); + } finally { setBusy(null); } + }; + + const handleEmail = () => { + const subject = encodeURIComponent(`Your Roof Options Report (${model.reportId})`); + const body = encodeURIComponent( + `Hi ${model.contact.name || ''},\n\nHere is a summary of your roof options report.\n` + + `Recommended: ${PKG_LABEL[model.recommended]}\nEstimated total: ~${formatUSD(model.totals.total)}\n\n` + + `${tenantConfig.branding.company} · ${tenantConfig.branding.phone}\n\n` + + `Note: ${tenantConfig.disclaimer}` + ); + window.location.href = `mailto:${model.contact.email || ''}?subject=${subject}&body=${body}`; + patchSession({ report: { ...session.report, emailedAt: new Date().toISOString(), deliveryStatus: 'emailed' } }); + logEvent('report_emailed', { reportId: model.reportId }); + }; + + const handleShare = async () => { + const summary = `My roof options report (${PKG_LABEL[model.recommended]} recommended) from ${tenantConfig.branding.company}.`; + try { + if (navigator.share) await navigator.share({ title: 'Roof Options Report', text: summary }); + else { await navigator.clipboard?.writeText(summary); } + logEvent('report_shared', { reportId: model.reportId }); + } catch { /* user cancelled */ } + }; + + const handleBook = () => { + setBooked(true); + patchSession({ report: { ...session.report, appointmentBooked: true, bookedAt: new Date().toISOString() } }); + logEvent('appointment_booked', { reportId: model.reportId }); + }; + + return ( +
+ {/* Report summary card */} +
+
+ + Your Roof Options Report + {model.reportId} +
+
+
Prepared for
{model.contact.name || '—'}
+
Recommended
{PKG_LABEL[model.recommended]}
+
Hail exposure
{model.hail?.exposureTier || '—'}
+
Estimated total
~{formatUSD(model.totals.total)}
+
+

+ {tenantConfig.disclaimer} +

+
+ + {/* Delivery actions */} +
+ + + + +
+ + {/* Next step CTA */} + {booked ? ( +
+ +
+ Inspection requested — your rep will reach out via {session.inputs.contactMethod}. Thank you! +
+
+ ) : ( + + )} +
+ ); +}; + +export default ReportStep; diff --git a/src/modules/estimateWizard/data/tenantConfig.js b/src/modules/estimateWizard/data/tenantConfig.js index 7ed022f..e1a6dac 100644 --- a/src/modules/estimateWizard/data/tenantConfig.js +++ b/src/modules/estimateWizard/data/tenantConfig.js @@ -174,11 +174,20 @@ export const DISCLAIMER_TEMPLATE = 'insurance professional. Historical storm information is not a forecast or guarantee.'; export const TENANT_CONFIG_VERSION = 'tenant-config-v1'; +export const DISCLAIMER_VERSION = 'disclaimer-v1'; -export function getTenantConfig() { - // Phase 5 admin page will override these from localStorage / mock store. +// Admin-editable overrides persist here (spec §14). The wizard merges them over +// the defaults so pricing/branding/feature flags are tenant-controlled, not code. +const CONFIG_STORAGE_KEY = 'lup_estimate_wizard_config_v1'; + +function defaults() { return { version: TENANT_CONFIG_VERSION, + disclaimerVersion: DISCLAIMER_VERSION, + // Feature flags (spec §18): kill-switch, AI toggle, conservative copy mode. + featureEnabled: true, + aiExplanationsEnabled: true, + conservativeMode: false, packages: DEFAULT_PACKAGES, addons: DEFAULT_ADDONS, scoringWeights: DEFAULT_SCORING_WEIGHTS, @@ -187,6 +196,41 @@ export function getTenantConfig() { discountScenarios: DEFAULT_DISCOUNT_SCENARIOS, branding: DEFAULT_BRANDING, disclaimer: DISCLAIMER_TEMPLATE, - conservativeMode: false, // §18: hides customer-facing savings claims when true + }; +} + +export function loadConfigOverrides() { + try { + const raw = localStorage.getItem(CONFIG_STORAGE_KEY); + return raw ? JSON.parse(raw) : {}; + } catch { return {}; } +} + +export function saveConfigOverrides(overrides) { + try { localStorage.setItem(CONFIG_STORAGE_KEY, JSON.stringify(overrides || {})); } catch { /* quota */ } +} + +export function resetConfigOverrides() { + try { localStorage.removeItem(CONFIG_STORAGE_KEY); } catch { /* ignore */ } +} + +export function getTenantConfig() { + const base = defaults(); + const o = loadConfigOverrides(); + + // Shallow-merge top-level objects so partial admin edits don't drop defaults. + return { + ...base, + ...o, + pricing: { ...base.pricing, ...(o.pricing || {}) }, + branding: { ...base.branding, ...(o.branding || {}) }, + scoringWeights: { ...base.scoringWeights, ...(o.scoringWeights || {}) }, + stormSettings: { ...base.stormSettings, ...(o.stormSettings || {}) }, + discountScenarios: { ...base.discountScenarios, ...(o.discountScenarios || {}) }, + // Packages: merge admin price/label edits by type, keep default scope/copy. + packages: base.packages.map((p) => { + const ov = (o.packages || []).find((x) => x.type === p.type); + return ov ? { ...p, ...ov } : p; + }), }; } diff --git a/src/modules/estimateWizard/utils/wizardReportExport.js b/src/modules/estimateWizard/utils/wizardReportExport.js new file mode 100644 index 0000000..3b3622d --- /dev/null +++ b/src/modules/estimateWizard/utils/wizardReportExport.js @@ -0,0 +1,223 @@ +/** + * wizardReportExport.js — branded customer report PDF (spec §11, §12 customer_reports). + * + * Sections (spec §11): cover, property summary, storm/hail snapshot, Good/Better/Best + * options, AI recommendation, 10-year comparison, selected add-ons, disclaimers + * (verbatim §15 template), next steps. Mirrors the app's jsPDF pattern (estimateExport.js). + * + * The same computed inputs drive the on-screen preview and the PDF so they match + * exactly (spec §18: "report generated by email and print must match the saved version"). + */ +import { pricePackage, addonsTotal, formatUSD } from '../engine/pricing'; +import { computeTenYearValue } from '../engine/valueModel'; + +const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' }; + +function reportId(sessionId) { + return `RPT-${(sessionId || '').replace(/[^a-zA-Z0-9]/g, '').slice(-8).toUpperCase() || 'PREVIEW'}`; +} + +/** Builds the structured report model — single source of truth for PDF + preview. */ +export function buildReportModel(session, tenantConfig) { + const i = session.inputs; + const value = session.recommendation?.value || computeTenYearValue({ inputs: i, tenantConfig }); + const recommended = session.recommendation?.selectedPackage || session.selectedPackage; + const selected = session.selectedPackage || recommended; + + const selectedPkg = tenantConfig.packages.find((p) => p.type === selected); + const base = selectedPkg ? pricePackage(selectedPkg, tenantConfig.pricing).base : 0; + const addonsSum = addonsTotal(tenantConfig.addons, session.selectedAddons || []); + + return { + reportId: reportId(session.id), + generatedAt: new Date().toISOString(), + branding: tenantConfig.branding, + disclaimer: tenantConfig.disclaimer, + contact: { name: i.name, address: i.address, phone: i.phone, email: i.email }, + property: session.property, + roof: { ageYears: i.roofAgeYears, ownership: i.ownershipHorizon, issue: i.issueType, premium: i.premiumUnknown ? null : i.premiumAmount }, + hail: session.hailSnapshot, + packages: tenantConfig.packages.map((p) => ({ + type: p.type, name: p.name, category: p.category, warranty: p.warrantyLabel, + hailResistance: p.hailResistance, price: pricePackage(p, tenantConfig.pricing), + })), + recommended, + selected, + explanation: session.recommendation?.explanation || null, + reasons: session.recommendation?.reasons || [], + value, + addons: (tenantConfig.addons || []).filter((a) => (session.selectedAddons || []).includes(a.id)), + totals: { base, addonsSum, total: base + addonsSum }, + }; +} + +export async function generateReportPdf(session, tenantConfig) { + const m = buildReportModel(session, tenantConfig); + const { default: jsPDF } = await import('jspdf'); + const { default: autoTable } = await import('jspdf-autotable'); + + const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }); + const W = doc.internal.pageSize.getWidth(); + const MARGIN = 14; + const accent = tenantConfig.branding.primaryColor || '#fda913'; + let y = 0; + + const text = (s, x, yy, opts) => doc.text(String(s ?? ''), x, yy, opts); + const ensure = (needed) => { if (y + needed > 285) { doc.addPage(); y = 18; } }; + const heading = (label) => { + ensure(14); + doc.setFont('helvetica', 'bold'); doc.setFontSize(11); + doc.setTextColor(20); text(label, MARGIN, y); y += 2; + doc.setDrawColor(accent); doc.setLineWidth(0.6); doc.line(MARGIN, y, W - MARGIN, y); y += 6; + }; + + // ── Cover band ── + doc.setFillColor(17, 17, 19); doc.rect(0, 0, W, 34, 'F'); + doc.setTextColor(255); doc.setFont('helvetica', 'bold'); doc.setFontSize(18); + text(m.branding.company, MARGIN, 16); + doc.setFont('helvetica', 'normal'); doc.setFontSize(10); + text('Your Roof Options Report', MARGIN, 24); + doc.setFontSize(8); + text(`${m.reportId} · ${new Date(m.generatedAt).toLocaleDateString()}`, MARGIN, 30); + y = 44; + + doc.setTextColor(40); doc.setFont('helvetica', 'bold'); doc.setFontSize(12); + text(`Prepared for ${m.contact.name || 'Homeowner'}`, MARGIN, y); y += 6; + doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(90); + text(m.contact.address || (m.property?.displayName ?? ''), MARGIN, y); y += 10; + + // ── Property summary ── + heading('Property summary'); + autoTable(doc, { + startY: y, theme: 'plain', margin: { left: MARGIN, right: MARGIN }, + styles: { fontSize: 9, cellPadding: 1.5 }, + body: [ + ['Roof age (estimated)', `${m.roof.ageYears ?? '—'} years`], + ['Planned ownership', m.roof.ownership ? `${m.roof.ownership} years` : '—'], + ['Current concern', m.roof.issue || '—'], + ['Annual insurance premium', m.roof.premium ? formatUSD(m.roof.premium) : 'Not provided'], + ], + columnStyles: { 0: { fontStyle: 'bold', cellWidth: 60, textColor: 60 }, 1: { textColor: 30 } }, + }); + y = doc.lastAutoTable.finalY + 8; + + // ── Storm / hail snapshot ── + heading('Storm / hail risk snapshot (historical)'); + doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(60); + if (m.hail) { + const p = m.hail.probability10yr; + const lines = [ + m.hail.wording?.probability || '', + `Annualized rate: ${m.hail.annualRate}/yr · 10-yr range: ${m.hail.eventCount === 0 ? '—' : `${p.low}–${p.high}%`} · Confidence: ${m.hail.confidence}`, + `Source: ${m.hail.source} · ~${m.hail.radiusMiles} mi · ${m.hail.wording?.lastUpdated || ''}`, + ]; + lines.forEach((l) => { ensure(6); doc.splitTextToSize(l, W - MARGIN * 2).forEach((ln) => { ensure(5); text(ln, MARGIN, y); y += 4.6; }); }); + } else { + text('Storm history will be reviewed by your rep during inspection.', MARGIN, y); y += 5; + } + y += 4; + + // ── Good / Better / Best ── + heading('Your Good / Better / Best options'); + autoTable(doc, { + startY: y, margin: { left: MARGIN, right: MARGIN }, + head: [['Option', 'System', 'Warranty', 'Hail', 'Estimated range']], + body: m.packages.map((p) => [ + PKG_LABEL[p.type] + (p.type === m.recommended ? ' ★' : ''), + p.name, p.warranty, p.hailResistance, `${formatUSD(p.price.low)}–${formatUSD(p.price.high)}`, + ]), + styles: { fontSize: 8.5, cellPadding: 2 }, + headStyles: { fillColor: [24, 24, 27], textColor: 255 }, + alternateRowStyles: { fillColor: [248, 248, 248] }, + }); + y = doc.lastAutoTable.finalY + 8; + + // ── AI recommendation ── + heading(`Recommendation: ${PKG_LABEL[m.recommended] || '—'}`); + doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(50); + if (m.explanation?.customerExplanation) { + doc.splitTextToSize(m.explanation.customerExplanation, W - MARGIN * 2).forEach((ln) => { ensure(5); text(ln, MARGIN, y); y += 4.8; }); + y += 2; + } + m.reasons.forEach((r) => { + ensure(6); + doc.setTextColor(accent); text('•', MARGIN, y); doc.setTextColor(60); + doc.splitTextToSize(r, W - MARGIN * 2 - 4).forEach((ln, idx) => { ensure(5); text(ln, MARGIN + 4, y); if (idx >= 0) y += 4.6; }); + }); + y += 4; + + // ── 10-year comparison ── + heading('10-year value comparison'); + autoTable(doc, { + startY: y, margin: { left: MARGIN, right: MARGIN }, + head: [['Option', 'Upfront (base)', 'Est. 10-yr savings', 'Service life']], + body: m.value.rows.map((r) => [ + PKG_LABEL[r.type], + formatUSD(r.price.base), + r.savings ? `${formatUSD(r.savings.low)}–${formatUSD(r.savings.high)}` : '—', + `${r.serviceLifeYears}+ yrs`, + ]), + styles: { fontSize: 8.5, cellPadding: 2 }, + headStyles: { fillColor: [24, 24, 27], textColor: 255 }, + }); + y = doc.lastAutoTable.finalY + 6; + doc.setFont('helvetica', 'italic'); doc.setFontSize(7.5); doc.setTextColor(120); + text('Savings shown are illustrative and must be confirmed with your insurance carrier.', MARGIN, y); y += 8; + + // ── Selected add-ons ── + heading('Selected add-ons'); + if (m.addons.length) { + autoTable(doc, { + startY: y, margin: { left: MARGIN, right: MARGIN }, + body: m.addons.map((a) => [a.name, a.value, `+${formatUSD(a.price)}`]), + styles: { fontSize: 8.5, cellPadding: 2 }, + columnStyles: { 1: { textColor: 90 }, 2: { halign: 'right', fontStyle: 'bold' } }, + }); + y = doc.lastAutoTable.finalY + 4; + } else { + doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(90); + text('No add-ons selected.', MARGIN, y); y += 6; + } + ensure(8); + doc.setFont('helvetica', 'bold'); doc.setFontSize(10); doc.setTextColor(20); + text(`Estimated total (${PKG_LABEL[m.selected]} + add-ons): ~${formatUSD(m.totals.total)}`, MARGIN, y); y += 10; + + // ── Disclaimers (verbatim) ── + heading('Important disclaimers'); + doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setTextColor(90); + doc.splitTextToSize(m.disclaimer, W - MARGIN * 2).forEach((ln) => { ensure(5); text(ln, MARGIN, y); y += 4.2; }); + y += 6; + + // ── Next steps ── + heading('Next steps'); + doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(50); + ['Book your free inspection to verify measurements and finalize pricing.', + `Call or text us at ${m.branding.phone}.`, + 'Have your insurance documents handy if a hail claim may be relevant.'].forEach((s) => { + ensure(6); doc.setTextColor(accent); text('→', MARGIN, y); doc.setTextColor(50); text(s, MARGIN + 5, y); y += 5.5; + }); + + // Footer on every page + const pages = doc.internal.getNumberOfPages(); + for (let pg = 1; pg <= pages; pg++) { + doc.setPage(pg); + doc.setFont('helvetica', 'normal'); doc.setFontSize(7); doc.setTextColor(150); + text(`${m.branding.company} · ${m.branding.phone} · ${m.reportId}`, MARGIN, 292); + text(`Page ${pg} of ${pages}`, W - MARGIN, 292, { align: 'right' }); + } + + const filename = `Roof-Options-${(m.contact.name || 'Report').replace(/\s+/g, '-')}-${m.reportId}.pdf`; + return { doc, filename, model: m }; +} + +export async function downloadReportPdf(session, tenantConfig) { + const { doc, filename, model } = await generateReportPdf(session, tenantConfig); + doc.save(filename); + return { filename, reportId: model.reportId }; +} + +export async function getReportBlobUrl(session, tenantConfig) { + const { doc, filename, model } = await generateReportPdf(session, tenantConfig); + const url = doc.output('bloburl'); + return { url, filename, reportId: model.reportId }; +}