package content fully tenant-editable and versioned in the admin
This commit is contained in:
@@ -8,9 +8,9 @@
|
|||||||
*/
|
*/
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Save, RotateCcw, Power, Sparkles, ShieldCheck, BarChart3 } from 'lucide-react';
|
import { Save, RotateCcw, Power, Sparkles, ShieldCheck, BarChart3, Package, History } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
getTenantConfig, loadConfigOverrides, saveConfigOverrides, resetConfigOverrides,
|
getTenantConfig, loadConfigOverrides, publishConfigOverrides, resetConfigOverrides,
|
||||||
} from './data/tenantConfig';
|
} from './data/tenantConfig';
|
||||||
import { loadSubmissions } from './api/crmHandoff';
|
import { loadSubmissions } from './api/crmHandoff';
|
||||||
|
|
||||||
@@ -31,9 +31,16 @@ 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 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 textInput = numInput;
|
||||||
|
const areaInput = numInput + ' min-h-[64px] resize-y leading-relaxed';
|
||||||
|
|
||||||
const EstimateWizardAdmin = () => {
|
const EstimateWizardAdmin = () => {
|
||||||
const initial = useMemo(() => getTenantConfig(), []);
|
const initial = useMemo(() => getTenantConfig(), []);
|
||||||
const [cfg, setCfg] = useState(initial);
|
const [cfg, setCfg] = useState(initial);
|
||||||
|
const [versionInfo, setVersionInfo] = useState(() => {
|
||||||
|
const o = loadConfigOverrides();
|
||||||
|
return { version: initial.version, versionedAt: o.versionedAt || null, history: o.versionHistory || [] };
|
||||||
|
});
|
||||||
|
|
||||||
const submissions = useMemo(() => loadSubmissions(), []);
|
const submissions = useMemo(() => loadSubmissions(), []);
|
||||||
const analytics = useMemo(() => {
|
const analytics = useMemo(() => {
|
||||||
@@ -49,6 +56,8 @@ const EstimateWizardAdmin = () => {
|
|||||||
...c,
|
...c,
|
||||||
packages: c.packages.map((p) => (p.type === type ? { ...p, [field]: value } : p)),
|
packages: c.packages.map((p) => (p.type === type ? { ...p, [field]: value } : p)),
|
||||||
}));
|
}));
|
||||||
|
// Included scope is edited as one item per line.
|
||||||
|
const setScope = (type, text) => setPkg(type, 'scope', text.split('\n').map((s) => s.trim()).filter(Boolean));
|
||||||
const setDiscount = (type, value) => setCfg((c) => ({
|
const setDiscount = (type, value) => setCfg((c) => ({
|
||||||
...c,
|
...c,
|
||||||
discountScenarios: { ...c.discountScenarios, [type]: { ...c.discountScenarios[type], base: value } },
|
discountScenarios: { ...c.discountScenarios, [type]: { ...c.discountScenarios[type], base: value } },
|
||||||
@@ -64,17 +73,31 @@ const EstimateWizardAdmin = () => {
|
|||||||
conservativeMode: cfg.conservativeMode,
|
conservativeMode: cfg.conservativeMode,
|
||||||
branding: cfg.branding,
|
branding: cfg.branding,
|
||||||
discountScenarios: cfg.discountScenarios,
|
discountScenarios: cfg.discountScenarios,
|
||||||
|
// Full Good/Better/Best content is tenant-controlled (spec §4/§14).
|
||||||
packages: cfg.packages.map((p) => ({
|
packages: cfg.packages.map((p) => ({
|
||||||
type: p.type, pricePerSquare: p.pricePerSquare, serviceLifeYears: p.serviceLifeYears,
|
type: p.type,
|
||||||
|
name: p.name,
|
||||||
|
category: p.category,
|
||||||
|
warrantyLabel: p.warrantyLabel,
|
||||||
|
hailResistance: p.hailResistance,
|
||||||
|
position: p.position,
|
||||||
|
fit: p.fit,
|
||||||
|
scope: p.scope,
|
||||||
|
pricePerSquare: p.pricePerSquare,
|
||||||
|
serviceLifeYears: p.serviceLifeYears,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
saveConfigOverrides(overrides);
|
const saved = publishConfigOverrides(overrides); // bumps version + history
|
||||||
toast.success('Estimate Wizard settings saved');
|
setVersionInfo({ version: saved.version, versionedAt: saved.versionedAt, history: saved.versionHistory });
|
||||||
|
setCfg((c) => ({ ...c, version: saved.version }));
|
||||||
|
toast.success(`Published ${saved.version}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
resetConfigOverrides();
|
resetConfigOverrides();
|
||||||
setCfg(getTenantConfig());
|
const fresh = getTenantConfig();
|
||||||
|
setCfg(fresh);
|
||||||
|
setVersionInfo({ version: fresh.version, versionedAt: null, history: [] });
|
||||||
toast.message('Reset to defaults');
|
toast.message('Reset to defaults');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -88,15 +111,25 @@ const EstimateWizardAdmin = () => {
|
|||||||
<span className="w-2 h-6 bg-amber-500 rounded-full" /> Estimate Wizard — Admin
|
<span className="w-2 h-6 bg-amber-500 rounded-full" /> Estimate Wizard — Admin
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-zinc-500 dark:text-zinc-400 text-sm ml-4">
|
<p className="text-zinc-500 dark:text-zinc-400 text-sm ml-4">
|
||||||
Tenant controls for pricing, discounts, AI, branding, and analytics.
|
Tenant controls for package content, pricing, discounts, AI, branding, and analytics.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="ml-4 mt-1.5 flex items-center gap-2 text-[11px]">
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-zinc-100 dark:bg-white/5 px-2 py-0.5 font-mono font-semibold text-zinc-600 dark:text-zinc-300">
|
||||||
|
<History size={11} /> {versionInfo.version}
|
||||||
|
</span>
|
||||||
|
{versionInfo.versionedAt && (
|
||||||
|
<span className="text-zinc-400">
|
||||||
|
published {new Date(versionInfo.versionedAt).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<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">
|
<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
|
<RotateCcw size={15} /> Reset
|
||||||
</button>
|
</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">
|
<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
|
<Save size={15} /> Save & publish
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,25 +141,52 @@ const EstimateWizardAdmin = () => {
|
|||||||
<Toggle icon={ShieldCheck} label="Conservative mode" desc="Hide customer-facing savings claims." checked={cfg.conservativeMode} onChange={(v) => setFlag('conservativeMode', v)} />
|
<Toggle icon={ShieldCheck} label="Conservative mode" desc="Hide customer-facing savings claims." checked={cfg.conservativeMode} onChange={(v) => setFlag('conservativeMode', v)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Package pricing */}
|
{/* Good / Better / Best content — fully tenant-controlled (spec §4/§14) */}
|
||||||
<section className="rounded-2xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-4">
|
<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>
|
<h2 className="font-semibold text-zinc-900 dark:text-white mb-1 flex items-center gap-2">
|
||||||
<div className="space-y-3">
|
<Package size={17} className="text-amber-500" /> Good / Better / Best content
|
||||||
|
</h2>
|
||||||
|
<p className="text-[11px] text-zinc-400 mb-4">
|
||||||
|
Every customer-facing field is editable here — nothing is hard-coded. Saving publishes a new version.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-5">
|
||||||
{cfg.packages.map((p) => (
|
{cfg.packages.map((p) => (
|
||||||
<div key={p.type} className="grid grid-cols-2 sm:grid-cols-4 gap-3 items-end">
|
<div key={p.type} className="rounded-xl border border-zinc-200 dark:border-white/10 p-3.5">
|
||||||
<div>
|
<div className="text-xs font-bold uppercase tracking-wide text-amber-600 dark:text-amber-400 mb-3">{PKG_LABEL[p.type]}</div>
|
||||||
<div className="text-xs font-bold uppercase tracking-wide text-zinc-400">{PKG_LABEL[p.type]}</div>
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<div className="text-sm text-zinc-700 dark:text-zinc-200 truncate">{p.name}</div>
|
<label className="text-xs text-zinc-500 dark:text-zinc-400">Package name
|
||||||
|
<input className={textInput} value={p.name} onChange={(e) => setPkg(p.type, 'name', e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-zinc-500 dark:text-zinc-400">Category / description
|
||||||
|
<input className={textInput} value={p.category} onChange={(e) => setPkg(p.type, 'category', e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-zinc-500 dark:text-zinc-400">Warranty label
|
||||||
|
<input className={textInput} value={p.warrantyLabel} onChange={(e) => setPkg(p.type, 'warrantyLabel', e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-zinc-500 dark:text-zinc-400">Hail resistance label
|
||||||
|
<input className={textInput} value={p.hailResistance} onChange={(e) => setPkg(p.type, 'hailResistance', e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-zinc-500 dark:text-zinc-400 sm:col-span-2">Positioning / upgrade copy
|
||||||
|
<input className={textInput} value={p.position} onChange={(e) => setPkg(p.type, 'position', e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-zinc-500 dark:text-zinc-400 sm:col-span-2">Best-fit description
|
||||||
|
<textarea className={areaInput} value={p.fit} onChange={(e) => setPkg(p.type, 'fit', e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-zinc-500 dark:text-zinc-400 sm:col-span-2">Included scope (one item per line)
|
||||||
|
<textarea className={areaInput} value={(p.scope || []).join('\n')} onChange={(e) => setScope(p.type, e.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3 mt-3">
|
||||||
|
<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>
|
||||||
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -167,6 +227,28 @@ const EstimateWizardAdmin = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Version history */}
|
||||||
|
<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">
|
||||||
|
<History size={17} className="text-amber-500" /> Version history
|
||||||
|
</h2>
|
||||||
|
{(!versionInfo.history || versionInfo.history.length === 0) ? (
|
||||||
|
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
No published changes yet — currently on defaults ({versionInfo.version}).
|
||||||
|
Each save publishes a new version that reports are stamped with.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{versionInfo.history.map((h) => (
|
||||||
|
<li key={h.version} className="flex items-center justify-between text-sm border-b border-zinc-100 dark:border-white/5 pb-1.5 last:border-0">
|
||||||
|
<span className="font-mono font-semibold text-zinc-700 dark:text-zinc-200">{h.version}</span>
|
||||||
|
<span className="text-xs text-zinc-400">{new Date(h.versionedAt).toLocaleString()}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -173,6 +173,9 @@ export const DISCLAIMER_TEMPLATE =
|
|||||||
'premium savings are illustrative only and must be confirmed with your insurance carrier or licensed ' +
|
'premium savings are illustrative only and must be confirmed with your insurance carrier or licensed ' +
|
||||||
'insurance professional. Historical storm information is not a forecast or guarantee.';
|
'insurance professional. Historical storm information is not a forecast or guarantee.';
|
||||||
|
|
||||||
|
// Schema version of the config shape. The *published* tenant version is this plus
|
||||||
|
// a revision suffix (e.g. tenant-config-v1.r4) that bumps on every admin save, so
|
||||||
|
// each report records exactly which package content/pricing produced it (§14, §18).
|
||||||
export const TENANT_CONFIG_VERSION = 'tenant-config-v1';
|
export const TENANT_CONFIG_VERSION = 'tenant-config-v1';
|
||||||
export const DISCLAIMER_VERSION = 'disclaimer-v1';
|
export const DISCLAIMER_VERSION = 'disclaimer-v1';
|
||||||
|
|
||||||
@@ -214,11 +217,36 @@ export function resetConfigOverrides() {
|
|||||||
try { localStorage.removeItem(CONFIG_STORAGE_KEY); } catch { /* ignore */ }
|
try { localStorage.removeItem(CONFIG_STORAGE_KEY); } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish a new tenant config version (spec §14: "versioned"). Bumps the revision,
|
||||||
|
* stamps versionedAt, and appends to a capped version history so admins can see and
|
||||||
|
* audit every change. Returns the saved overrides (with the new version metadata).
|
||||||
|
*/
|
||||||
|
export function publishConfigOverrides(overrides) {
|
||||||
|
const prev = loadConfigOverrides();
|
||||||
|
const revision = (Number(prev.revision) || 0) + 1;
|
||||||
|
const version = `${TENANT_CONFIG_VERSION}.r${revision}`;
|
||||||
|
const versionedAt = new Date().toISOString();
|
||||||
|
const history = Array.isArray(prev.versionHistory) ? prev.versionHistory : [];
|
||||||
|
|
||||||
|
const saved = {
|
||||||
|
...overrides,
|
||||||
|
revision,
|
||||||
|
version,
|
||||||
|
versionedAt,
|
||||||
|
versionHistory: [{ revision, version, versionedAt }, ...history].slice(0, 25),
|
||||||
|
};
|
||||||
|
saveConfigOverrides(saved);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
export function getTenantConfig() {
|
export function getTenantConfig() {
|
||||||
const base = defaults();
|
const base = defaults();
|
||||||
const o = loadConfigOverrides();
|
const o = loadConfigOverrides();
|
||||||
|
|
||||||
// Shallow-merge top-level objects so partial admin edits don't drop defaults.
|
// Shallow-merge top-level objects so partial admin edits don't drop defaults.
|
||||||
|
// `...o` lets a published version/versionedAt/revision override the base schema
|
||||||
|
// version, so getTenantConfig().version reflects the tenant's edits.
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
...o,
|
...o,
|
||||||
|
|||||||
Reference in New Issue
Block a user