wizard admin feature in existing crm as separate route
This commit is contained in:
@@ -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 }) => (
|
||||
<label className="flex items-start justify-between gap-3 rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-4 cursor-pointer">
|
||||
<div className="flex items-start gap-2.5">
|
||||
{Icon && <Icon size={18} className="text-amber-500 mt-0.5" />}
|
||||
<div>
|
||||
<div className="font-semibold text-zinc-900 dark:text-white">{label}</div>
|
||||
<div className="text-xs text-zinc-500 dark:text-zinc-400">{desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
<input type="checkbox" className="mt-1 h-5 w-5 accent-amber-500 shrink-0" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
||||
</label>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div className="h-full overflow-auto bg-zinc-50 dark:bg-[#09090b]">
|
||||
<div className="max-w-4xl mx-auto p-4 md:p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white flex items-center gap-2">
|
||||
<span className="w-2 h-6 bg-amber-500 rounded-full" /> Estimate Wizard — Admin
|
||||
</h1>
|
||||
<p className="text-zinc-500 dark:text-zinc-400 text-sm ml-4">
|
||||
Tenant controls for pricing, discounts, AI, branding, and analytics.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleReset} className="rounded-xl border border-zinc-200 dark:border-white/10 px-3 py-2 text-sm font-medium text-zinc-600 dark:text-zinc-300 hover:border-amber-400 flex items-center gap-1.5">
|
||||
<RotateCcw size={15} /> Reset
|
||||
</button>
|
||||
<button onClick={handleSave} className="rounded-xl bg-gradient-to-r from-amber-400 to-orange-500 text-white px-4 py-2 text-sm font-semibold flex items-center gap-1.5 shadow-lg shadow-amber-500/25">
|
||||
<Save size={15} /> Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feature flags */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<Toggle icon={Power} label="Feature enabled" desc="Master kill-switch for the public wizard." checked={cfg.featureEnabled} onChange={(v) => setFlag('featureEnabled', v)} />
|
||||
<Toggle icon={Sparkles} label="AI explanations" desc="Use AI copy (off = deterministic text)." checked={cfg.aiExplanationsEnabled} onChange={(v) => setFlag('aiExplanationsEnabled', v)} />
|
||||
<Toggle icon={ShieldCheck} label="Conservative mode" desc="Hide customer-facing savings claims." checked={cfg.conservativeMode} onChange={(v) => setFlag('conservativeMode', v)} />
|
||||
</div>
|
||||
|
||||
{/* Package pricing */}
|
||||
<section className="rounded-2xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-4">
|
||||
<h2 className="font-semibold text-zinc-900 dark:text-white mb-3">Packages, pricing & discounts</h2>
|
||||
<div className="space-y-3">
|
||||
{cfg.packages.map((p) => (
|
||||
<div key={p.type} className="grid grid-cols-2 sm:grid-cols-4 gap-3 items-end">
|
||||
<div>
|
||||
<div className="text-xs font-bold uppercase tracking-wide text-zinc-400">{PKG_LABEL[p.type]}</div>
|
||||
<div className="text-sm text-zinc-700 dark:text-zinc-200 truncate">{p.name}</div>
|
||||
</div>
|
||||
<label className="text-xs text-zinc-500 dark:text-zinc-400">Price / square ($)
|
||||
<input type="number" className={numInput} value={p.pricePerSquare} onChange={(e) => setPkg(p.type, 'pricePerSquare', Number(e.target.value))} />
|
||||
</label>
|
||||
<label className="text-xs text-zinc-500 dark:text-zinc-400">Service life (yrs)
|
||||
<input type="number" className={numInput} value={p.serviceLifeYears} onChange={(e) => setPkg(p.type, 'serviceLifeYears', Number(e.target.value))} />
|
||||
</label>
|
||||
<label className="text-xs text-zinc-500 dark:text-zinc-400">Base discount (%)
|
||||
<input type="number" className={numInput} value={cfg.discountScenarios[p.type]?.base ?? 0} onChange={(e) => setDiscount(p.type, Number(e.target.value))} />
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-zinc-400 mt-3">Customer copy always states discounts are illustrative — "ask your carrier to confirm."</p>
|
||||
</section>
|
||||
|
||||
{/* Branding */}
|
||||
<section className="rounded-2xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-4">
|
||||
<h2 className="font-semibold text-zinc-900 dark:text-white mb-3">Report branding</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<label className="text-xs text-zinc-500 dark:text-zinc-400">Company
|
||||
<input className={numInput} value={cfg.branding.company} onChange={(e) => setBranding('company', e.target.value)} />
|
||||
</label>
|
||||
<label className="text-xs text-zinc-500 dark:text-zinc-400">Phone
|
||||
<input className={numInput} value={cfg.branding.phone} onChange={(e) => setBranding('phone', e.target.value)} />
|
||||
</label>
|
||||
<label className="text-xs text-zinc-500 dark:text-zinc-400">Primary color
|
||||
<input className={numInput} value={cfg.branding.primaryColor} onChange={(e) => setBranding('primaryColor', e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Analytics */}
|
||||
<section className="rounded-2xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-4">
|
||||
<h2 className="font-semibold text-zinc-900 dark:text-white mb-3 flex items-center gap-2">
|
||||
<BarChart3 size={17} className="text-amber-500" /> Funnel analytics
|
||||
</h2>
|
||||
{analytics.total === 0 ? (
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">No completed estimates yet. Finish a wizard run to populate metrics.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<Metric label="Completed reports" value={analytics.total} />
|
||||
<Metric label="Appointments booked" value={analytics.booked} />
|
||||
<Metric label="Avg add-ons / report" value={analytics.addonAttach.toFixed(1)} />
|
||||
<Metric label="Best recommended" value={analytics.recommended.BEST || 0} />
|
||||
<DistRow label="Recommended split" dist={analytics.recommended} />
|
||||
<DistRow label="Customer-selected split" dist={analytics.selected} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Metric = ({ label, value }) => (
|
||||
<div className="rounded-xl bg-zinc-50 dark:bg-white/5 p-3">
|
||||
<div className="text-xs text-zinc-400">{label}</div>
|
||||
<div className="text-xl font-bold text-zinc-900 dark:text-white mt-0.5">{value}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DistRow = ({ label, dist }) => (
|
||||
<div className="rounded-xl bg-zinc-50 dark:bg-white/5 p-3 col-span-2">
|
||||
<div className="text-xs text-zinc-400 mb-1">{label}</div>
|
||||
<div className="flex gap-3 text-sm font-medium text-zinc-700 dark:text-zinc-200">
|
||||
{['GOOD', 'BETTER', 'BEST'].map((t) => (
|
||||
<span key={t}>{PKG_LABEL[t]}: <span className="text-amber-600 dark:text-amber-400">{dist[t] || 0}</span></span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default EstimateWizardAdmin;
|
||||
Reference in New Issue
Block a user