diff --git a/src/modules/estimateWizard/components/TenYearValueChart.jsx b/src/modules/estimateWizard/components/TenYearValueChart.jsx index ab6b088..7e948a8 100644 --- a/src/modules/estimateWizard/components/TenYearValueChart.jsx +++ b/src/modules/estimateWizard/components/TenYearValueChart.jsx @@ -1,52 +1,128 @@ /** - * TenYearValueChart — upfront vs illustrative 10-yr savings, per option (spec §10). - * Savings bars only render when a premium was provided and conservative mode is off. + * TenYearValueChart — 10-year value comparison panel (spec §10). + * + * Honors the full §10 brief: + * - shows ASSUMPTIONS per tier (discount %, service life, hail positioning, value view) + * - lets the customer TOGGLE discount scenarios (low / base / high) + * - separates upfront cost from lifecycle value (breakeven + lifecycle delta) + * - never claims a guaranteed discount; all savings labeled illustrative + * + * Savings/breakeven/lifecycle only render when a premium was provided and + * conservative mode is off; otherwise the panel falls back to assumptions only. */ -import React from 'react'; +import React, { useState } from 'react'; import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend, Cell } from 'recharts'; const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' }; const BAR_COLOR = { GOOD: '#a1a1aa', BETTER: '#3b82f6', BEST: '#fda913' }; +const SCENARIOS = [ + { key: 'low', label: 'Low' }, + { key: 'base', label: 'Base' }, + { key: 'high', label: 'High' }, +]; + +const usd = (n) => `$${Number(n).toLocaleString('en-US')}`; const TenYearValueChart = ({ valueModel }) => { + const [scenario, setScenario] = useState('base'); + const showSavings = valueModel.premiumKnown && !valueModel.conservativeMode; + const data = valueModel.rows.map((r) => ({ name: PKG_LABEL[r.type], type: r.type, Upfront: r.price.base, - 'Est. 10-yr savings': r.savings ? r.savings.base : 0, + 'Est. 10-yr savings': r.savings ? r.savings[scenario] : 0, })); - const fmt = (n) => `$${(n / 1000).toFixed(0)}k`; + const fmtK = (n) => `$${(n / 1000).toFixed(0)}k`; return (
-

10-year value comparison

-
+
+

10-year value comparison

+ {showSavings && ( +
+ Discount scenario + {SCENARIOS.map((s) => ( + + ))} +
+ )} +
+ + {/* Upfront vs illustrative savings */} +
- + `$${Number(v).toLocaleString('en-US')}`} + formatter={(v) => usd(v)} contentStyle={{ borderRadius: 12, border: '1px solid #e4e4e7', fontSize: 12 }} /> {data.map((d) => )} - {valueModel.premiumKnown && !valueModel.conservativeMode && ( + {showSavings && ( )}
-

- {valueModel.premiumKnown - ? 'Savings shown are illustrative and depend on carrier approval, policy terms, and documentation.' - : 'Add your insurance premium earlier in the wizard to see an illustrative 10-year savings scenario.'} + + {/* Assumptions + lifecycle metrics per tier */} +

+ {valueModel.rows.map((r) => ( +
+
{PKG_LABEL[r.type]}
+ + + + + + {showSavings && ( + <> +
+ + {/* Breakeven only when pricing AND discount known (spec §10 display rule). */} + + + + )} +
+ ))} +
+ +

+ {showSavings + ? 'Savings scenarios are illustrative model estimates — not guaranteed. They depend on carrier approval, policy terms, state rules, and documentation. Lifecycle value delta is a model estimate, not a promise.' + : valueModel.conservativeMode + ? 'Savings scenarios are hidden. Figures show upfront cost and durability assumptions only.' + : 'Add your insurance premium earlier in the wizard to see illustrative 10-year savings, breakeven, and lifecycle value scenarios.'}

); }; +const Metric = ({ label, value, accent, muted, hint }) => ( +
+ {label} + + {value} + {hint && {hint}} + +
+); + export default TenYearValueChart; diff --git a/src/modules/estimateWizard/engine/valueModel.js b/src/modules/estimateWizard/engine/valueModel.js index a87f017..11bfd76 100644 --- a/src/modules/estimateWizard/engine/valueModel.js +++ b/src/modules/estimateWizard/engine/valueModel.js @@ -3,63 +3,82 @@ * * Makes premium options understandable WITHOUT pretending savings are guaranteed: * - 10-yr premium savings scenario = annual premium × assumed discount × 10 - * - shows low/base/high range (carrier discount is never certain) + * - computes low/base/high scenarios so the UI can TOGGLE between them (carrier + * discount is never certain) * - upgrade breakeven only when pricing AND discount are known * - lifecycle value delta is labeled a model estimate, not a promise + * - per-tier "10-year value view" positioning (Upfront only / Savings + protection / + * Savings + longer lifecycle + protection) * * Pure function. Honors conservativeMode (spec §18) by suppressing savings claims. */ import { pricePackage } from './pricing'; const YEARS = 10; +const SCEN_KEYS = ['low', 'base', 'high']; + +// 10-year value view positioning per tier (spec §10 comparison table). +const VALUE_VIEW = { + GOOD: 'Upfront only', + BETTER: 'Savings + protection', + BEST: 'Savings + longer lifecycle + protection', +}; export function computeTenYearValue({ inputs, tenantConfig }) { const { packages, pricing, discountScenarios, conservativeMode } = tenantConfig; const premium = (!inputs.premiumUnknown && inputs.premiumAmount > 0) ? inputs.premiumAmount : null; + const showSavings = !!premium && !conservativeMode; - const goodPrice = pricePackage(packages.find((p) => p.type === 'GOOD'), pricing).base; + const goodPkg = packages.find((p) => p.type === 'GOOD'); + const goodPrice = pricePackage(goodPkg, pricing).base; + const goodLife = goodPkg?.serviceLifeYears || 28; const rows = packages.map((pkg) => { const price = pricePackage(pkg, pricing); const scen = discountScenarios[pkg.type] || { low: 0, base: 0, high: 0 }; - // 10-year premium savings scenario (illustrative). - const savings = (premium && !conservativeMode) - ? { - low: Math.round(premium * (scen.low / 100) * YEARS), - base: Math.round(premium * (scen.base / 100) * YEARS), - high: Math.round(premium * (scen.high / 100) * YEARS), - } - : null; - // Upgrade cost vs the Good baseline. const upgradeCost = Math.max(0, price.base - goodPrice); - // Breakeven (years) — only when both pricing and a positive savings scenario exist. - const annualSavingsBase = (premium && !conservativeMode) ? premium * (scen.base / 100) : 0; - const breakevenYears = (upgradeCost > 0 && annualSavingsBase > 0) - ? +(upgradeCost / annualSavingsBase).toFixed(1) - : null; - - // Lifecycle value delta (model estimate): savings + avoided future roof reserve − upgrade cost. - // Avoided-reserve proxy: extra service life beyond Good, valued at Good's price prorated. - const goodLife = packages.find((p) => p.type === 'GOOD').serviceLifeYears || 28; + // Avoided future roof-cycle reserve proxy: extra service life beyond Good, + // valued at Good's price prorated (labeled a model estimate downstream). const extraLife = Math.max(0, (pkg.serviceLifeYears || goodLife) - goodLife); const avoidedReserve = Math.round((extraLife / goodLife) * goodPrice * 0.5); - const lifecycleDelta = (savings ? savings.base : 0) + avoidedReserve - upgradeCost; + + // Per-scenario savings / annual / breakeven / lifecycle delta so the UI can + // toggle low/base/high (spec §10: "allow toggling discount scenarios"). + const savings = {}, annualSavings = {}, breakeven = {}, lifecycleDelta = {}; + SCEN_KEYS.forEach((k) => { + const pct = scen[k] || 0; + const annual = showSavings ? premium * (pct / 100) : 0; + const tenYr = Math.round(annual * YEARS); + annualSavings[k] = Math.round(annual); + savings[k] = tenYr; + // Breakeven (years) — only when both pricing and a positive discount exist. + breakeven[k] = (upgradeCost > 0 && annual > 0) ? +(upgradeCost / annual).toFixed(1) : null; + // Lifecycle value delta = savings + avoided reserve − upgrade cost. + lifecycleDelta[k] = Math.round(tenYr + avoidedReserve - upgradeCost); + }); return { type: pkg.type, name: pkg.name, price, + // Assumptions surfaced to the customer (spec §10 "show assumptions"). + discountScenario: scen, // { low, base, high } discount % assumption discountPct: scen.base, serviceLifeYears: pkg.serviceLifeYears, hailResistance: pkg.hailResistance, - savings, + valueView: VALUE_VIEW[pkg.type] || '', upgradeCost, - breakevenYears, avoidedReserve, - lifecycleDelta: Math.round(lifecycleDelta), + // Per-scenario outputs (null savings when not shown, for back-compat callers). + savings: showSavings ? savings : null, + annualSavings, + breakeven, + lifecycleDelta, + // Back-compat scalar (base scenario). + breakevenYears: breakeven.base, }; }); @@ -67,6 +86,7 @@ export function computeTenYearValue({ inputs, tenantConfig }) { years: YEARS, premiumKnown: !!premium, conservativeMode: !!conservativeMode, + scenarios: SCEN_KEYS, rows, }; } diff --git a/src/modules/estimateWizard/utils/wizardReportExport.js b/src/modules/estimateWizard/utils/wizardReportExport.js index 3b3622d..4b23c77 100644 --- a/src/modules/estimateWizard/utils/wizardReportExport.js +++ b/src/modules/estimateWizard/utils/wizardReportExport.js @@ -148,21 +148,45 @@ export async function generateReportPdf(session, tenantConfig) { // ── 10-year comparison ── heading('10-year value comparison'); + + // Assumptions table (spec §10 "show assumptions"). autoTable(doc, { startY: y, margin: { left: MARGIN, right: MARGIN }, - head: [['Option', 'Upfront (base)', 'Est. 10-yr savings', 'Service life']], + head: [['Option', 'Upfront (base)', 'Discount assumption', 'Service life', 'Hail resistance', '10-yr value view']], 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.discountScenario?.base ?? 0}% (if eligible)`, `${r.serviceLifeYears}+ yrs`, + r.hailResistance, + r.valueView, ]), - styles: { fontSize: 8.5, cellPadding: 2 }, + styles: { fontSize: 8, cellPadding: 1.8 }, + headStyles: { fillColor: [24, 24, 27], textColor: 255 }, + }); + y = doc.lastAutoTable.finalY + 4; + + // Value outputs table — savings range, breakeven, lifecycle delta (model estimate). + autoTable(doc, { + startY: y, margin: { left: MARGIN, right: MARGIN }, + head: [['Option', 'Est. 10-yr savings (low–high)', 'Upgrade breakeven', 'Lifecycle value delta (model est.)']], + body: m.value.rows.map((r) => [ + PKG_LABEL[r.type], + r.savings ? `${formatUSD(r.savings.low)}–${formatUSD(r.savings.high)}` : '—', + r.breakeven?.base != null ? `${r.breakeven.base} yrs` : '—', + r.savings ? formatUSD(r.lifecycleDelta?.base ?? 0) : '—', + ]), + styles: { fontSize: 8, cellPadding: 1.8 }, 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; + doc.splitTextToSize( + 'Savings scenarios are illustrative model estimates, not guaranteed. They depend on carrier approval, policy ' + + 'terms, state rules, and documentation. Breakeven and lifecycle value delta are model estimates, not promises.', + W - MARGIN * 2, + ).forEach((ln) => { ensure(5); text(ln, MARGIN, y); y += 4.2; }); + y += 4; // ── Selected add-ons ── heading('Selected add-ons');