wizard admin feature in existing crm as separate route

This commit is contained in:
Mayur Shinde
2026-06-09 18:01:32 +05:30
parent 0da194b157
commit 075d2dd60f
10 changed files with 695 additions and 9 deletions
@@ -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 }) => (
<button
type="button"
onClick={onClick}
disabled={busy}
className={`flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold transition-all disabled:opacity-50
${primary
? 'bg-gradient-to-r from-amber-400 to-orange-500 text-white shadow-lg shadow-amber-500/25'
: 'border border-zinc-200 dark:border-white/10 text-zinc-700 dark:text-zinc-200 hover:border-amber-400'}`}
>
{busy ? <Loader2 size={16} className="animate-spin" /> : Icon && <Icon size={16} />} {label}
</button>
);
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 (
<div className="space-y-4">
{/* Report summary card */}
<div className="rounded-2xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-5">
<div className="flex items-center gap-2 mb-3">
<FileText size={18} className="text-amber-500" />
<span className="font-bold text-zinc-900 dark:text-white">Your Roof Options Report</span>
<span className="ml-auto text-[11px] text-zinc-400">{model.reportId}</span>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div><div className="text-zinc-400 text-xs">Prepared for</div><div className="font-medium text-zinc-800 dark:text-zinc-100">{model.contact.name || '—'}</div></div>
<div><div className="text-zinc-400 text-xs">Recommended</div><div className="font-medium text-amber-600 dark:text-amber-400">{PKG_LABEL[model.recommended]}</div></div>
<div><div className="text-zinc-400 text-xs">Hail exposure</div><div className="font-medium text-zinc-800 dark:text-zinc-100 capitalize">{model.hail?.exposureTier || '—'}</div></div>
<div><div className="text-zinc-400 text-xs">Estimated total</div><div className="font-medium text-zinc-800 dark:text-zinc-100">~{formatUSD(model.totals.total)}</div></div>
</div>
<p className="text-[11px] text-zinc-400 mt-3 leading-relaxed border-t border-zinc-100 dark:border-white/5 pt-2.5">
{tenantConfig.disclaimer}
</p>
</div>
{/* Delivery actions */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2.5">
<ActionButton icon={Download} label="Download" onClick={handleDownload} busy={busy === 'download'} />
<ActionButton icon={Printer} label="Print" onClick={handlePrint} busy={busy === 'print'} />
<ActionButton icon={Mail} label="Email" onClick={handleEmail} />
<ActionButton icon={Share2} label="Share" onClick={handleShare} />
</div>
{/* Next step CTA */}
{booked ? (
<div className="rounded-2xl border border-emerald-200 dark:border-emerald-500/20 bg-emerald-50 dark:bg-emerald-500/10 p-4 flex items-center gap-3">
<Check size={20} className="text-emerald-500" />
<div className="text-sm text-emerald-700 dark:text-emerald-300">
Inspection requested your rep will reach out via {session.inputs.contactMethod}. Thank you!
</div>
</div>
) : (
<ActionButton icon={CalendarCheck} label="Book my free inspection" onClick={handleBook} primary />
)}
</div>
);
};
export default ReportStep;