/* QuoteFlow Prototype · extra admin screens, all reading window.QFStore / window.QF_CATALOG. Rendered by AdminApp's screen map. */ /* -------- Proposals -------- */ function AdminProposals({ store, onOpen }) { const { Card, StatusBadge, Avatar, Badge } = window.QuoteFlowDesignSystem_41788d; const rows = store.getProposals(); const m = store.getMetrics(); const Stat = ({ label, value }) => (
{label}
{value}
); return (

Proposals

Every proposal you've sent, with live acceptance & deposit status from the customer portal.

{rows.length === 0 ? (
No proposals yet — open a lead in the pipeline and hit “Send proposal”.
) : ( {['Customer', 'System', 'Total', 'Deposit', 'Status', 'Sent'].map(h => )} {rows.map((l, i) => ( onOpen(l.id)} style={{ borderBottom: i < rows.length - 1 ? '1px solid var(--slate-100)' : 'none', cursor: 'pointer' }}> ))}
{h}
{l.name}
{l.system} {gbp(l.proposal.total)} {gbp(l.proposal.deposit)} {l.proposal.payment ? Deposit paid : l.proposal.status === 'accepted' ? Accepted : Awaiting customer} {store.fmtDate(l.proposal.sentAt)}
)}
); } window.AdminProposals = AdminProposals; /* -------- After Care (won customers become installed base) -------- */ function AdminAfterCare({ store, onOpen }) { const { Card, MetricCard, StatusBadge, Avatar, Badge, Button } = window.QuoteFlowDesignSystem_41788d; const won = store.getWon(); const m = store.getMetrics(); const plans = store.SERVICE_PLANS; const planTone = (id) => id === 'Premium care' ? 'primary' : id === 'Standard care' ? 'info' : 'neutral'; return (

After care

Customers who've accepted become your installed base — manage service plans, warranties & renewals.

} /> } /> } />
{won.length === 0 ? (
No installed customers yet — once a proposal is accepted in the portal, the customer appears here.
) : ( {['Customer', 'System', 'Service plan', 'Annual', 'Status'].map(h => )} {won.map((l, i) => ( ))}
{h}
onOpen(l.id)}>
{l.name}
{l.loc}
{l.system} {store.servicePlanAnnual(l.servicePlan) ? gbp(store.servicePlanAnnual(l.servicePlan)) + '/yr' : '—'} onOpen(l.id)}>
)}
); } window.AdminAfterCare = AdminAfterCare; /* -------- Quote Tools (from the shared catalogue packages) -------- */ function AdminQuoteTools({ store, onPreview }) { const { Card, Badge, Button, Input, Switch, Select } = window.QuoteFlowDesignSystem_41788d; const C = window.QF_CATALOG; const [modal, setModal] = React.useState(null); // {mode:'configure'|'create', tool} const tools = [ { name: 'Solar & Battery Quote Tool', cat: 'solar', status: 'Active', desc: 'Design a solar & battery system and get an instant estimate.' }, { name: 'Battery Storage Quote Tool', cat: 'battery', status: 'Active', desc: 'Add storage to an existing system or load-shift off-peak.' }, { name: 'EV Charger Quote Tool', cat: 'ev', status: 'Active', desc: 'Choose a smart charger for home or workplace.' }, { name: 'Heat Pump Quote Tool', cat: 'heat', status: 'Draft', desc: 'Estimate the cost of switching to an air-source heat pump.' }, ]; return (

Quote tools

Branded quote journeys you publish to your website. Pricing comes from your shared catalogue.

{tools.map((t, i) => (

{t.name}

{t.desc}

))}

Published packages

{C.packages.map((p, i) => (
{p.name} {p.tier}
{C.kWp(p)} kWp · {C.margin(p)}% margin
{gbp(C.price(p))}
))}
{modal && setModal(null)} onPreview={onPreview} />}
); } window.AdminQuoteTools = AdminQuoteTools; /* Configure / Create modal for quote tools (dummy but interactive). */ function QuoteToolModal({ modal, onClose, onPreview }) { const { Card, Button, Input, Switch, Select, Badge } = window.QuoteFlowDesignSystem_41788d; const isCreate = modal.mode === 'create'; const t = modal.tool; const [name, setName] = React.useState(isCreate ? '' : t.name); const [cat, setCat] = React.useState(isCreate ? 'Solar & battery' : 'Solar & battery'); const [portal, setPortal] = React.useState(true); const [deposits, setDeposits] = React.useState(true); const [saved, setSaved] = React.useState(false); const save = () => { setSaved(true); setTimeout(onClose, 850); }; return (
e.stopPropagation()} style={{ width: 480, maxWidth: '100%', maxHeight: '90vh', overflowY: 'auto', background: 'var(--surface-card)', borderRadius: 'var(--radius-lg)', boxShadow: 'var(--shadow-xl)' }}>

{isCreate ? 'Create quote tool' : 'Configure · ' + t.name}

{saved ? (
{isCreate ? 'Quote tool created' : 'Changes saved'}
) : (
setName(e.target.value)} placeholder="e.g. Solar & Battery Quote Tool" />

Customer journey

store.setSetting('customerPortal', v)} /> store.setSetting('eSignature', v)} /> store.setSetting('takeDeposits', v)} />
“Take deposits” is wired live — toggle it, then accept a proposal in the customer portal to see the difference.
); } window.AdminSettings = AdminSettings; /* -------- Profile (editable user details, address, subscription) -------- */ function AdminProfile({ store, onBack }) { const { Card, Input, Select, Button, Badge, Avatar } = window.QuoteFlowDesignSystem_41788d; const p = store.getProfile(); const [form, setForm] = React.useState(p); const [saved, setSaved] = React.useState(false); const set = (k) => (e) => { setForm(Object.assign({}, form, { [k]: e.target.value })); setSaved(false); }; const dirty = JSON.stringify(form) !== JSON.stringify(p); const save = () => { store.setProfile(form); setSaved(true); }; const plans = [ { id: 'Launch', price: '£375/mo', desc: 'Smaller installers streamlining their business. Gold support.' }, { id: 'Scale', price: '£690/mo', desc: 'Growing teams with multiple quote tools & journeys. Priority support.' }, { id: 'Enterprise', price: 'Custom', desc: 'Multi-branch operations, SSO, custom integrations & SLAs.' }, ]; return (

{form.name}

{form.role} · {form.company}

{form.plan} plan
{/* Personal details */}

Personal details

{/* Business address */}

Business address

{/* Subscription plan */}

Subscription plan

Renews {form.renews} · {form.billing}
{plans.map(pl => { const on = form.plan === pl.id; return ( ); })}
{/* sticky-ish save bar */}
{saved ? Profile saved : {dirty ? 'You have unsaved changes' : 'Your profile is up to date'}}
); } window.AdminProfile = AdminProfile; /* -------- ProjectBlock: survey + install milestones (admin lead drawer) -------- */ function ProjectBlock({ store, lead }) { const { Button, Input } = window.QuoteFlowDesignSystem_41788d; const proj = lead.project || {}; const won = !!(lead.proposal && lead.proposal.status === 'accepted'); const [surveyDate, setSurveyDate] = React.useState(proj.surveyDate || store.plusDaysISO(5)); const [surveyNote, setSurveyNote] = React.useState(proj.surveyNote || ''); const [installDate, setInstallDate] = React.useState(proj.installDate || store.plusDaysISO(21)); if (!won) { return (
Survey & install scheduling unlocks once the proposal is accepted.
); } const Step = ({ icon, title, done, doneText, children }) => (
{title}
{done ?
{doneText}
:
{children}
}
); return (
Project schedule
{/* Survey */}
setSurveyDate(e.target.value)} style={dateStyle} /> setSurveyNote(e.target.value)} size="sm" />
{/* Install — only once survey booked */} {proj.surveyDate && (
setInstallDate(e.target.value)} style={dateStyle} />
)} {/* Reschedule / complete actions when install is booked */} {proj.installDate && !proj.completedAt && ( )} {proj.completedAt && (
Installed on {store.prettyISO((new Date(proj.completedAt)).toISOString().slice(0, 10))} — now in after care.
)}
); } var dateStyle = { width: '100%', boxSizing: 'border-box', height: 40, padding: '0 14px', fontFamily: 'var(--font-sans)', fontSize: 14, color: 'var(--text-strong)', background: 'var(--surface-card)', border: '1px solid var(--border-default)', borderRadius: 'var(--radius-full)', outline: 'none' }; window.ProjectBlock = ProjectBlock; /* -------- MessagesBlock: email/SMS notification log + manual send (drawer) -------- */ function MessagesBlock({ store, lead }) { const { Button } = window.QuoteFlowDesignSystem_41788d; const [compose, setCompose] = React.useState(false); const [channel, setChannel] = React.useState('email'); const [subject, setSubject] = React.useState(''); const msgs = (lead.messages || []).slice().reverse(); const send = () => { if (!subject.trim()) return; store.sendMessage(lead.id, channel, subject.trim(), ''); setSubject(''); setCompose(false); }; return (
Messages sent · {msgs.length}
{compose && (
{['email', 'sms'].map(ch => ( ))}
setSubject(e.target.value)} placeholder={channel === 'sms' ? 'Message to ' + (lead.phone || 'customer') : 'Subject / message to ' + (lead.email || 'customer')} style={{ width: '100%', boxSizing: 'border-box', height: 38, padding: '0 14px', fontFamily: 'var(--font-sans)', fontSize: 13.5, color: 'var(--text-strong)', background: 'var(--surface-card)', border: '1px solid var(--border-default)', borderRadius: 'var(--radius-full)', outline: 'none' }} />

Demo stub — no real message is sent; it's logged below.

)}
{msgs.length === 0 &&
No messages yet.
} {msgs.map((m, i) => (
{m.subject} {store.fmtDate(m.ts)}
{m.channel === 'sms' ? 'SMS' : 'Email'} · {m.to}
{m.body &&
{m.body}
}
))}
); } window.MessagesBlock = MessagesBlock; /* -------- ActivityFeed: roll-up of recent activity + messages (dashboard) -------- */ function ActivityFeed({ store, onOpen }) { const { Card, Avatar } = window.QuoteFlowDesignSystem_41788d; const feed = store.getFeed(14); const meta = { sms: { icon: 'message-square', bg: '#E0E7FF', fg: '#4338CA' }, email: { icon: 'mail', bg: 'var(--green-50)', fg: 'var(--accent-deep)' }, activity: { icon: 'activity', bg: 'var(--surface-sunken)', fg: 'var(--text-muted)' }, }; const ago = (ts) => { const s = Math.floor((Date.now() - ts) / 1000); if (s < 60) return 'just now'; const m = Math.floor(s / 60); if (m < 60) return m + 'm ago'; const h = Math.floor(m / 60); if (h < 24) return h + 'h ago'; const d = Math.floor(h / 24); if (d < 7) return d + 'd ago'; return store.fmtDate(ts); }; return (

Activity

Live
{feed.length === 0 &&
No activity yet.
} {feed.map((f, i) => { const mt = meta[f.kind] || meta.activity; return ( ); })}
); } window.ActivityFeed = ActivityFeed; /* -------- LeadsChart: SVG line chart of new leads over time -------- */ function LeadsChart({ store, days }) { const { Card } = window.QuoteFlowDesignSystem_41788d; const data = store.leadsOverTime(days || 30); const [hover, setHover] = React.useState(null); // index of hovered day const W = 560, H = 150, padL = 8, padR = 8, padT = 12, padB = 22; const max = Math.max(1, ...data.map(d => d.count)); const n = data.length; const x = (i) => padL + (n <= 1 ? 0 : (i / (n - 1)) * (W - padL - padR)); const y = (v) => padT + (1 - v / max) * (H - padT - padB); const pts = data.map((d, i) => [x(i), y(d.count)]); const line = pts.map((p, i) => (i ? 'L' : 'M') + p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' '); const area = line + ' L ' + x(n - 1).toFixed(1) + ' ' + (H - padB) + ' L ' + x(0).toFixed(1) + ' ' + (H - padB) + ' Z'; const total = data.reduce((s, d) => s + d.count, 0); const ticks = data.filter((d, i) => i % Math.ceil(n / 6) === 0 || i === n - 1); const hd = hover != null ? data[hover] : null; const bandW = (W - padL - padR) / Math.max(1, n); return (

Leads over time

{total} new {total === 1 ? 'lead' : 'leads'} in the last {days || 30} days

New leads / day
{[0, 0.5, 1].map((g, i) => ( ))} {/* hovered guideline */} {hd && } {pts.map((p, i) => (data[i].count > 0 || i === hover) ? : null)} {ticks.map((d, i) => { const idx = data.indexOf(d); return {d.label}; })} {/* invisible hover hit-areas, one per day */} {data.map((d, i) => ( setHover(i)} onMouseLeave={() => setHover(h => h === i ? null : h)} style={{ cursor: 'pointer' }} /> ))} {/* tooltip */} {hd && (
{hd.label}
{hd.count} {hd.count === 1 ? 'lead' : 'leads'}
)}
); } window.LeadsChart = LeadsChart; /* -------- ToolStats: per-quote-tool conversion -------- */ function ToolStats({ store }) { const { Card } = window.QuoteFlowDesignSystem_41788d; const rows = store.toolStats(); const maxLeads = Math.max(1, ...rows.map(r => r.leads)); return (

By quote tool

{rows.map((t, i) => (
{t.system} {t.leads} {t.leads === 1 ? 'lead' : 'leads'} = 50 ? 'var(--accent-deep)' : 'var(--text-strong)', width: 40, textAlign: 'right' }}>{t.conversion}%
))} {rows.length === 0 &&
No leads yet.
}
); } window.ToolStats = ToolStats; /* -------- LeadEditBlock: inline edit of a lead's contact fields -------- */ function LeadEditBlock({ store, lead, onDone }) { const { Input, Button } = window.QuoteFlowDesignSystem_41788d; const [f, setF] = React.useState({ name: lead.name, email: lead.email || '', phone: lead.phone || '', postcode: lead.postcode || '', loc: lead.loc || '' }); const set = (k) => (e) => setF(Object.assign({}, f, { [k]: e.target.value })); const save = () => { store.updateLead(lead.id, f); onDone && onDone(); }; return (
Edit contact
); } window.LeadEditBlock = LeadEditBlock; /* -------- PipelineBoard: Trello-style kanban with drag-and-drop -------- Module-scope (stable identity) so store re-renders & its own drag state never remount it mid-drag — the bug when it was inline in AdminApp. */ var QF_STAGE_ACCENT = { 'New lead': '#5B6BE8', 'Contacted': '#0EA5E9', 'Quote completed': '#8B5CF6', 'Proposal sent': '#F59E0B', 'Accepted': '#12B981', 'Survey booked': '#0E9488', 'Install booked': '#6366F1', 'Completed': '#12B981', 'Lost': '#94A3B8', }; function PipelineBoard({ store, onOpen }) { const { Card, Avatar } = window.QuoteFlowDesignSystem_41788d; const [dragId, setDragId] = React.useState(null); const [overStage, setOverStage] = React.useState(null); const cols = store.byStage(); const stages = store.STAGES.filter(s => s !== 'Lost'); const dragLead = dragId ? store.getLead(dragId) : null; const drop = (stage, e) => { var id = dragId; if (!id && e && e.dataTransfer) { try { id = e.dataTransfer.getData('text/plain'); } catch (err) {} } if (id) store.setStage(id, stage); setDragId(null); setOverStage(null); }; return (

Sales pipeline

Drag a card between columns to move a lead through your stages — or click it to open the full record. Changes save instantly.

{stages.map(stage => { const items = cols[stage]; const total = items.reduce((s, l) => s + (l.value || 0), 0); const accent = QF_STAGE_ACCENT[stage] || 'var(--slate-400)'; const isOver = overStage === stage && dragLead && dragLead.stage !== stage; return (
{ e.preventDefault(); if (overStage !== stage) setOverStage(stage); }} onDragLeave={e => { if (overStage === stage && !e.currentTarget.contains(e.relatedTarget)) setOverStage(null); }} onDrop={e => { e.preventDefault(); drop(stage, e); }} style={{ width: 280, flex: 'none', display: 'flex', flexDirection: 'column', borderRadius: 'var(--radius-lg)', maxHeight: '100%', background: isOver ? 'var(--primary-soft)' : 'var(--surface-sunken)', outline: isOver ? '2px dashed var(--primary)' : '2px solid transparent', outlineOffset: -2, transition: 'background var(--dur-fast), outline-color var(--dur-fast)' }}>
{stage} {items.length} {total ? gbp(total) : ''}
{items.map(l => (
{ setDragId(l.id); e.dataTransfer.effectAllowed = 'move'; try { e.dataTransfer.setData('text/plain', l.id); } catch (err) {} }} onDragEnd={() => { setDragId(null); setOverStage(null); }} onClick={() => onOpen(l.id)} style={{ background: 'var(--surface-card)', borderRadius: 'var(--radius-md)', border: '1px solid var(--border-default)', borderTop: '3px solid ' + accent, boxShadow: 'var(--shadow-xs)', padding: 12, cursor: 'grab', display: 'flex', flexDirection: 'column', gap: 9, opacity: dragId === l.id ? 0.4 : 1 }}>
{l.name}
{l.system}
{gbp(l.value)} {l.loc}
{l.owner === 'Unassigned' ? 'Unassigned' : l.owner.split(' ')[0]} {l.proposal && }
))} {items.length === 0 &&
{isOver ? 'Release to move here' : 'Drop leads here'}
}
); })}
); } window.PipelineBoard = PipelineBoard;