/* Report Builder — main tab ============================================= home (saved reports + template picker) → setup (scope + preflight + brand kit) → builder (3-zone canvas: block library | 16:9 preview + inspector | slide rail). Autosave: localStorage instantly, server on idle. reportDef is the single source of truth (see spec). ======================================================================== */ const R_LS_DRAFT = 'relay_report_draft_'; function rISO(ms){ const d=new Date(ms); return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0'); } function rCompareRange(mode, start, end){ const s = new Date(start+'T00:00:00'), e = new Date(end+'T00:00:00'); const days = Math.round((e-s)/86400000)+1; if (mode==='same_period_last_year'){ const cs = new Date(s); cs.setFullYear(cs.getFullYear()-1); const ce = new Date(e); ce.setFullYear(ce.getFullYear()-1); return { start: rISO(+cs), end: rISO(+ce) }; } const ce = new Date(s); ce.setDate(ce.getDate()-1); const cs = new Date(ce); cs.setDate(cs.getDate()-days+1); return { start: rISO(+cs), end: rISO(+ce) }; } /* ---------- Data hooks -------------------------------------------------- */ function useReportDataset(def){ const [ds, setDs] = useState(null); const [loading, setLoading] = useState(false); const [err, setErr] = useState(''); const scope = def && def.scope; const key = scope ? [def.accountId, (scope.platforms||[]).join(','), scope.start, scope.end, scope.source || 'system', scope.compare && scope.compare.mode, scope.compare && scope.compare.start].join('|') : ''; useEffect(()=>{ if (!def || !def.accountId || !scope.start) return; let dead = false; setLoading(true); setErr(''); const qs = new URLSearchParams({ account:def.accountId, start:scope.start, end:scope.end, src: scope.source === 'manual' ? 'manual' : 'system' }); if (scope.platforms && scope.platforms.length) qs.set('platforms', scope.platforms.join(',')); if (scope.compare && scope.compare.mode !== 'prev_period' && scope.compare.start){ qs.set('cstart', scope.compare.start); qs.set('cend', scope.compare.end); } RelayAPI.get('/report/dataset?' + qs.toString()) .then(d=>{ if(!dead){ setDs(d); setLoading(false); } }) .catch(e=>{ if(!dead){ setErr(e.message||'Failed to load data'); setLoading(false); } }); return ()=>{ dead = true; }; }, [key]); return { ds, loading, err }; } function useBrandKits(accountId){ const [kits, setKits] = useState([]); const reload = ()=> RelayAPI.get('/report/brandkits?account='+encodeURIComponent(accountId||'')) .then(r=>setKits(r.kits||[])).catch(()=>setKits([])); useEffect(()=>{ if (accountId) reload(); }, [accountId]); return { kits, reload }; } /* ---------- Brand kit editor (Phase 7) ---------------------------------- */ function BrandKitEditor({ open, onClose, kit, accountId, onSaved }){ const blank = { name:'', account_id:accountId, colors:['#233433','#45464B','#488D82'], logo_path:null, logo_dark_path:null, bg_path:null, bg_path_flipped:null, font:'', rtl:false }; const [k, setK] = useState(blank); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); useEffect(()=>{ if (open) setK(kit ? JSON.parse(JSON.stringify(kit)) : { ...blank, account_id:accountId }); }, [open, kit]); if (!open) return null; const setColor = (i, v)=> setK(x=>({ ...x, colors: x.colors.map((c,j)=>j===i?v:c) })); const upload = async (field, file)=>{ if (!file) return; setBusy(true); setError(''); try { const fd = new FormData(); fd.append('file', file); const res = await fetch(API_BASE + '/report/asset', { method:'POST', credentials:'include', body:fd }); const j = await res.json(); if (!res.ok) throw new Error(j.error || 'Upload failed'); setK(x=>({ ...x, [field]: j.path })); } catch(e){ setError(e.message); } setBusy(false); }; const save = async ()=>{ if (!k.name.trim()){ setError('Give the kit a name'); return; } setBusy(true); setError(''); try { const res = await RelayAPI.post('/report/brandkit', { id:k.id, account_id:k.account_id, name:k.name.trim(), logo_path:k.logo_path, logo_dark_path:k.logo_dark_path, bg_path:k.bg_path, bg_path_flipped:k.bg_path_flipped, color_main:k.colors[0], color_secondary:k.colors[1], color_tertiary:k.colors[2], font:k.font || null, rtl:!!k.rtl, }); onSaved && onSaved(res.id); onClose(); } catch(e){ setError(e.message); } setBusy(false); }; const FileBtn = ({ field, label })=>( ); const contrast = k.colors.map(c=>rContrastText(c)); return ( {}:onClose} width={620}>
{kit ? 'Edit brand kit' : 'New brand kit'}
{['Main','Secondary','Tertiary'].map((lbl,i)=>( ))}
{/* palette ramp preview */}
{rRamp(k.colors, 8).map((c,i)=>)}
{error &&
{error}
}
Cancel {busy?'Saving…':'Save kit'}
); } /* ---------- Followers quick entry --------------------------------------- */ function FollowersEntry({ accountId, platforms, endDate, onSaved }){ const [openIt, setOpenIt] = useState(false); const [rows, setRows] = useState({}); const [busy, setBusy] = useState(false); const [err, setErr] = useState(''); const plats = rSortPlatforms(platforms||[]); const save = async ()=>{ const payload = plats.filter(p=>rows[p] && (rows[p].g || rows[p].t)).map(p=>({ platform:p, date:endDate, followers: rows[p].t ? +rows[p].t : undefined, followers_gained: rows[p].g ? +rows[p].g : undefined, })); if (!payload.length) return; setBusy(true); setErr(''); try { await RelayAPI.post('/report/brandmetrics', { account_id:accountId, rows:payload }); onSaved && onSaved(); setOpenIt(false); } catch(e){ setErr(e.message || 'Save failed'); } /* audit: was silently swallowed */ setBusy(false); }; return (
{openIt && (
{plats.map(p=>(
{rPlatform(p).label} setRows(r=>({...r,[p]:{...(r[p]||{}),g:e.target.value}}))} className="flex-1 h-8 px-2 rounded-lg border border-ink-200 dark:border-white/10 bg-white dark:bg-ink-900/60 text-[12px]"/> setRows(r=>({...r,[p]:{...(r[p]||{}),t:e.target.value}}))} className="flex-1 h-8 px-2 rounded-lg border border-ink-200 dark:border-white/10 bg-white dark:bg-ink-900/60 text-[12px]"/>
))} {err &&
{err}
}
{busy?'Saving…':'Save followers'}
)}
); } /* ---------- Block inspector ---------------------------------------------- */ function BlockInspector({ def, slide, block, onPatchBinding, onPatchOptions, onRemove }){ if (!block) return (
Select a block on the slide to edit it — or add one from the library below.
); const b = block.binding || {}, o = block.options || {}; const meta = R_BLOCKS[block.type] || {}; const metricChoices = rAllMetrics().filter(m=>m.tier!==3 && m.agg!=='last'); const scopePlats = def.scope.platforms || []; const Sel = ({ label, value, onChange, children })=>( ); const multiMetrics = ['scorecard','metric_grid','trend','table'].includes(block.type); return (
{meta.label||block.type}
{['scorecard','metric_grid','trend','bar','donut','table','heatmap','ranked_list','perf_flags'].includes(block.type) && ( onPatchBinding({ platform:v||undefined })}> {scopePlats.map(p=>)} )} {['table','ranked_list','bar','perf_flags'].includes(block.type) && ( onPatchBinding({ level:v })}> )} {['bar','donut','heatmap','ranked_list'].includes(block.type) && ( onPatchBinding({ metric:v })}> {metricChoices.map(m=>)} )} {block.type==='ranked_list' && ( onPatchBinding({ secondary:v })}> {metricChoices.map(m=>)} )} {multiMetrics && (
Metrics
{metricChoices.map(m=>{ const on = (b.metrics||[]).includes(m.key); return ( ); })}
)} {['table','ranked_list'].includes(block.type) && (
onPatchBinding({ sortKey:v })}> {metricChoices.map(m=>)} onPatchBinding({ topN:+v })}> {[5,8,10,12,15,20].map(n=>)}
)} {block.type==='table' && (
Heatmap columns
{(b.metrics||[]).map(k=>{ const on = (o.heatmap||[]).includes(k); return ; })}
)} {block.type==='trend' && ( )} {block.type==='text' && (
Bullets (one per line — overrides auto)