/* 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 })=>(
{label}
{!k[field] && 'Upload PNG/JPG'}
upload(field, e.target.files[0])}/>
);
const contrast = k.colors.map(c=>rContrastText(c));
return (
{}:onClose} width={620}>
{kit ? 'Edit brand kit' : 'New brand kit'}
{/* 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 (
setOpenIt(o=>!o)} className="w-full flex items-center gap-2 text-[12.5px] font-semibold">
Followers (for the Executive Summary)
captured automatically from uploaded files & connected social sources — enter manually only if missing
{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 })=>(
{label}
onChange(e.target.value)}
className="w-full h-8 px-2 rounded-lg border border-ink-200 dark:border-white/10 bg-white dark:bg-ink-900/60 text-[12px]">
{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 })}>
All (report scope)
{scopePlats.map(p=>{rPlatform(p).label} )}
)}
{['table','ranked_list','bar','perf_flags'].includes(block.type) && (
onPatchBinding({ level:v })}>
Campaigns
Ad sets
Ads
)}
{['bar','donut','heatmap','ranked_list'].includes(block.type) && (
onPatchBinding({ metric:v })}>
{metricChoices.map(m=>{m.label} )}
)}
{block.type==='ranked_list' && (
onPatchBinding({ secondary:v })}>
{metricChoices.map(m=>{m.label} )}
)}
{multiMetrics && (
Metrics
{metricChoices.map(m=>{
const on = (b.metrics||[]).includes(m.key);
return (
{
const cur = new Set(b.metrics||[]);
on ? (cur.size>1 && cur.delete(m.key)) : cur.add(m.key);
onPatchBinding({ metrics:[...cur] });
}} className={`text-[10.5px] px-1.5 py-0.5 rounded-full border transition ${on
? 'border-accent-400 bg-accent-50 dark:bg-accent-500/10 text-accent-700 dark:text-accent-300 font-semibold'
: 'border-ink-200 dark:border-white/10 text-ink-500'}`}>{m.label}
);
})}
)}
{['table','ranked_list'].includes(block.type) && (
onPatchBinding({ sortKey:v })}>
{metricChoices.map(m=>{m.label} )}
onPatchBinding({ topN:+v })}>
{[5,8,10,12,15,20].map(n=>{n} )}
)}
{block.type==='table' && (
Heatmap columns
{(b.metrics||[]).map(k=>{
const on = (o.heatmap||[]).includes(k);
return {
const cur = new Set(o.heatmap||[]); on ? cur.delete(k) : cur.add(k);
onPatchOptions({ heatmap:[...cur] });
}} className={`text-[10.5px] px-1.5 py-0.5 rounded-full border ${on
? 'border-amber-400 bg-amber-50 dark:bg-amber-500/10 text-amber-700 font-semibold'
: 'border-ink-200 dark:border-white/10 text-ink-500'}`}>{rMetricLabel(k,'en')} ;
})}
)}
{block.type==='trend' && (
onPatchOptions({ showCompare:v })} size="sm"/>
Show comparison period (ghost line)
)}
{block.type==='text' && (
onPatchOptions({ autoInsights:v })} size="sm"/>
Auto-insights from the data
Bullets (one per line — overrides auto)
)}
{block.type==='image' && (
Image
{!o.src && 'Upload'}
{
const file = e.target.files[0]; if (!file) return;
try { /* audit: was an unhandled rejection on network failure */
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().catch(()=>({}));
if (res.ok) onPatchOptions({ src:j.path });
else console.warn('[report] image upload failed:', j.error || res.status);
} catch(err){ console.warn('[report] image upload failed:', err.message); }
}}/>
)}
onPatchOptions({ span:+v })}>
Full width
Half width
);
}
/* ---------- Slide rail (drag reorder) ------------------------------------ */
function SlideRail({ def, current, onSelect, onMove, onRemove, onAdd }){
const dragFrom = useRef(null);
const bounds = rSlideBounds(def);
return (
{def.slides.map((s,i)=>{
const movable = i >= bounds.firstMovable && i <= bounds.lastMovable;
return (
{ dragFrom.current = i; }}
onDragOver={e=>{ if (movable) e.preventDefault(); }}
onDrop={e=>{ e.preventDefault(); if (dragFrom.current!=null && movable) onMove(dragFrom.current, i); dragFrom.current=null; }}
onClick={()=>onSelect(i)}
className={`relative rounded-lg border-2 transition cursor-pointer group ${i===current
? 'border-accent-500' : 'border-transparent hover:border-ink-300 dark:hover:border-white/20'}`}>
{s.title || s.type}
{s.locked ? '🔒 ' : ''}{s.type.replace('_',' ')} · {(s.blocks||[]).length||'—'}
{i+1}
{rCanRemove(s) && (
{ e.stopPropagation(); onRemove(i); }}
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-rose-500 text-white grid place-items-center opacity-0 group-hover:opacity-100 transition">
)}
);
})}
+ Add slide
);
}
/* ---------- The builder ---------------------------------------------------- */
function ReportBuilderView({ initialDef, savedId, onExit, currentUser, accounts }){
const [def, setDefRaw] = useState(initialDef);
const [reportId, setReportId] = useState(savedId || null);
const [cur, setCur] = useState(0);
const [selBlock, setSelBlock] = useState(null);
const [showExport, setShowExport] = useState(false);
const [showKitEditor, setShowKitEditor] = useState(false);
const [saveState, setSaveState] = useState('saved'); // saved | dirty | saving
const history = useRef({ past:[], future:[] });
const { ds, loading, err } = useReportDataset(def);
const { kits, reload:reloadKits } = useBrandKits(def.accountId);
const kit = kits.find(k=>k.id===def.brandKitId) || kits.find(k=>k.account_id===def.accountId) || kits[0] || null;
const theme = rTheme(kit, def.lang);
const currency = (ds && ds.account.currency) || 'USD';
useEffect(()=>{ rLoadCustomMetrics(def.accountId); }, [def.accountId]);
const setDef = (next, recordHistory)=>{
setDefRaw(prev=>{
const v = typeof next === 'function' ? next(prev) : next;
if (recordHistory !== false){
history.current.past.push(prev);
if (history.current.past.length > 40) history.current.past.shift();
history.current.future = [];
}
try { localStorage.setItem(R_LS_DRAFT + (v.accountId||''), JSON.stringify({ id:reportId, def:v, at:Date.now() })); } catch(e){}
setSaveState('dirty');
return v;
});
};
const undo = ()=>{ const p = history.current.past.pop(); if (p){ history.current.future.push(def); setDefRaw(p); setSaveState('dirty'); } };
const redo = ()=>{ const f = history.current.future.pop(); if (f){ history.current.past.push(def); setDefRaw(f); setSaveState('dirty'); } };
useEffect(()=>{
const onKey = (e)=>{
if ((e.metaKey||e.ctrlKey) && e.key.toLowerCase()==='z'){ e.preventDefault(); e.shiftKey ? redo() : undo(); }
};
window.addEventListener('keydown', onKey);
return ()=>window.removeEventListener('keydown', onKey);
});
/* server autosave on idle — mounted guard stops setState-after-unmount
when the user exits the builder while a save is in flight (audit fix) */
const mountedRef = useRef(true);
useEffect(()=>{ mountedRef.current = true; return ()=>{ mountedRef.current = false; }; }, []);
useEffect(()=>{
if (saveState !== 'dirty') return;
const t = setTimeout(async ()=>{
if (!mountedRef.current) return;
setSaveState('saving');
try {
const res = await RelayAPI.post('/report/save', {
id: reportId || undefined, account_id: def.accountId, name: def.name,
def, brand_kit_id: def.brandKitId || (kit && kit.id) || null,
/* DB column is ENUM('en','ar'); 'both' lives in the def JSON */
lang: def.lang === 'en' ? 'en' : 'ar' });
if (!mountedRef.current) return;
if (res.id) setReportId(res.id);
setSaveState('saved');
} catch(e){ if (mountedRef.current) setSaveState('dirty'); }
}, 2500);
return ()=>clearTimeout(t);
}, [def, saveState]);
const slide = def.slides[Math.min(cur, def.slides.length-1)];
const patchSlide = (i, patch)=> setDef(d=>({ ...d, slides: d.slides.map((s,j)=>j===i?{...s,...patch}:s) }));
const patchBlockBinding = (blockId, patch)=> setDef(d=>({ ...d, slides: d.slides.map((s,j)=> j!==cur ? s :
{ ...s, blocks: s.blocks.map(b=> b.id===blockId ? { ...b, binding:{ ...b.binding, ...patch } } : b) }) }));
const patchBlockOptions = (blockId, patch)=> setDef(d=>({ ...d, slides: d.slides.map((s,j)=> j!==cur ? s :
{ ...s, blocks: s.blocks.map(b=> b.id===blockId ? { ...b, options:{ ...b.options, ...patch } } : b) }) }));
const addBlock = (type)=>{
const blk = rBlock(type, type==='trend' ? { metrics:['spend'] } :
type==='table' ? { level:'campaign', metrics:['spend','impressions','clicks','ctr','conversions','cpa'], sortKey:'spend', sortDir:'desc', topN:10 } :
['scorecard','metric_grid'].includes(type) ? { metrics:['spend','conversions','cpa','roas'] } : {});
setDef(d=>({ ...d, slides: d.slides.map((s,j)=> j===cur ? { ...s, blocks:[...(s.blocks||[]), blk] } : s) }));
setSelBlock(blk.id);
};
const removeBlock = (blockId)=>{
setDef(d=>({ ...d, slides: d.slides.map((s,j)=> j===cur ? { ...s, blocks:s.blocks.filter(b=>b.id!==blockId) } : s) }));
setSelBlock(null);
};
const addSlide = ()=>{
const s = rSlide('content', def.lang==='ar'?'شريحة جديدة':'New slide', []);
setDef(d=>{
const idx = d.slides.findIndex(x=>x.type==='thank_you');
const slides = [...d.slides]; slides.splice(idx, 0, s);
return { ...d, slides };
});
setCur(def.slides.findIndex(x=>x.type==='thank_you'));
};
const canvasW = 780;
const selectedBlock = slide && (slide.blocks||[]).find(b=>b.id===selBlock);
return (
{/* toolbar */}
{err &&
{err}
}
{/* left: block library + inspector */}
Add block
{Object.entries(R_BLOCKS).filter(([t])=>t!=='metric_grid').map(([t,meta])=>(
slide && !slide.locked!==false && addBlock(t)}
disabled={!slide || ['cover','agenda','thank_you','divider'].includes(slide.type)}
className="px-2 py-2 rounded-lg border border-ink-200 dark:border-white/10 text-left hover:border-accent-400 transition disabled:opacity-30 disabled:pointer-events-none">
{meta.label}
))}
Block settings
patchBlockBinding(selBlock, p)}
onPatchOptions={(p)=>patchBlockOptions(selBlock, p)}
onRemove={()=>removeBlock(selBlock)}/>
{/* center: canvas */}
setSelBlock(null)}>
{slide && !slide.locked && (
patchSlide(cur, { title:e.target.value })}
dir="auto" placeholder="Slide title"
className="mb-2 w-full h-8 px-2 rounded-lg border border-transparent hover:border-ink-200 dark:hover:border-white/10 bg-transparent text-[13px] font-bold"/>
)}
{loading &&
loading data…
}
{slide && ds && (
)}
{slide && !ds && !loading && (
{err || 'No data loaded'}
)}
{slide && slide.locked && (
Fixed slide — content adapts automatically; title editable in the rail.
)}
{/* right: slide rail */}
{ setCur(i); setSelBlock(null); }}
onMove={(f,t)=>setDef(d=>rMoveSlide(d, f, t))}
onRemove={(i)=>{ setDef(d=>({ ...d, slides:d.slides.filter((_,j)=>j!==i) })); if (cur>=i) setCur(c=>Math.max(0,c-1)); }}
onAdd={addSlide}/>
setShowExport(false)} def={def} ds={ds} kit={kit}/>
setShowKitEditor(false)} kit={kit}
accountId={def.accountId} onSaved={(id)=>{ reloadKits(); setDef(d=>({...d, brandKitId:id})); }}/>
);
}
/* ---------- Setup (scope + preflight) ------------------------------------- */
function ReportSetup({ template, accounts, currentUser, onBack, onLaunch, globalAccountId, globalRange }){
/* Client templates carry their ad account(s): filter + default + lock. */
const clientIds = template.clientAccounts || null;
const selectable = clientIds ? accounts.filter(a=>clientIds.includes(a.id)) : accounts;
const lockedToClient = !!clientIds && selectable.length <= 1;
const [accountId, setAccountId] = useState(
clientIds ? ((selectable[0]||{}).id || '') :
(accounts.some(a=>a.id===globalAccountId) ? globalAccountId : (accounts[0]||{}).id));
const [source, setSource] = useState('system'); // 'system' | 'manual' — hard wall
const [langSel, setLangSel] = useState(template.lang || 'en'); // 'en' | 'ar' | 'both'
const [plats, setPlats] = useState([]);
const [start, setStart] = useState(rISO(globalRange ? globalRange.start : Date.now()-29*86400000));
const [end, setEnd] = useState(rISO(globalRange ? globalRange.end : Date.now()));
const [cmpMode, setCmpMode] = useState('prev_period');
const [brandName, setBrandName] = useState('');
const [showUpload, setShowUpload] = useState(false);
/* agency credit — the workspace (FLNT) as report author; separate from the
client brand. Not offered on the Al Munawarah template (client decision). */
const agencyBrand = typeof loadBranding==='function' ? loadBranding() : { name:'FLNT', logo:null };
const showAgency = template.id !== 'tpl_almunawarah';
const [agencyOn, setAgencyOn] = useState(showAgency);
const acc = accounts.find(a=>a.id===accountId);
/* preflight dataset probe */
const probeDef = useMemo(()=>({ accountId, lang: template.lang||'en',
scope:{ platforms:plats, start, end, source,
compare:{ mode:cmpMode, ...rCompareRange(cmpMode, start, end) } } }),
[accountId, plats.join(','), start, end, cmpMode, source]);
const { ds, loading } = useReportDataset(probeDef);
const avail = ds ? rDatasetPlatforms(ds) : [];
const perPlat = ds ? rByPlatform(ds) : [];
const fresh = (ds && ds.freshness) || {};
useEffect(()=>{ if (ds && !plats.length && avail.length) setPlats(avail); }, [ds ? avail.join(',') : '']);
const launch = ()=>{
const ctx = { accountId, lang: langSel,
brandName: brandName.trim() || (acc?acc.name:''),
brandKitId: template.brandKitId || null,
agency: showAgency && agencyOn
? { enabled:true, name:agencyBrand.name || 'FLNT', logo:agencyBrand.logo || null }
: null,
scope:{ platforms: plats.length?plats:avail, start, end, source,
compare:{ mode:cmpMode, ...rCompareRange(cmpMode, start, end) } } };
const def = template.build(ctx);
def.accountId = accountId;
onLaunch(def);
};
const fmtLag = (s)=> s==null ? 'no live feed' : s<3600 ? Math.round(s/60)+'m ago' : s<86400 ? Math.round(s/3600)+'h ago' : Math.round(s/86400)+'d ago';
return (
Templates
{template.name}
{template.desc}
Ad account{clientIds ? ' — filtered to this client' : ''}
{ setAccountId(e.target.value); setPlats([]); }}
className={`w-full h-10 px-2 rounded-lg border border-ink-200 dark:border-white/10 bg-white dark:bg-ink-900/60 text-[13px] ${lockedToClient?'opacity-70 cursor-not-allowed':''}`}>
{selectable.length===0 && — no access to this client's accounts — }
{selectable.map(a=>{a.name} )}
{lockedToClient && selectable.length===1 &&
🔒 This template belongs to {selectable[0].name} — account locked.
}
Brand name (on the cover)
setBrandName(e.target.value)} placeholder={acc?acc.name:''}
className="w-full h-10 px-3 rounded-lg border border-ink-200 dark:border-white/10 bg-white dark:bg-ink-900/60 text-[13px]"/>
Report language
setLangSel(e.target.value)}
className="w-full h-10 px-2 rounded-lg border border-ink-200 dark:border-white/10 bg-white dark:bg-ink-900/60 text-[13px]">
English
العربية (RTL)
Bilingual — عربي · English (RTL)
{/* Data source — hard wall: a report reads EXACTLY one source */}
Data source
{[['system','Internal system','Live data synced from Meta / Google / TikTok connections'],
['manual','Manual uploads','Data imported from CSV / Excel files only']].map(([id,label,desc])=>(
{ setSource(id); setPlats([]); }}
className={`text-left px-3 py-2.5 rounded-xl border transition ${source===id
? 'border-accent-400 bg-accent-50/60 dark:bg-accent-500/10'
: 'border-ink-200 dark:border-white/10 opacity-70 hover:opacity-100'}`}>
{label}
{source===id && }
{desc}
))}
The two sources never mix — every number in this report comes only from the selected source.
{/* preflight: platform picker with data-health */}
Platforms
{loading && checking data… }
{!loading && avail.length===0 && (
No ad data in this range for this account. Pick another range, or import files below.
)}
{rSortPlatforms([...new Set([...avail, ...plats])]).map(p=>{
const row = perPlat.find(x=>x.platform===p);
const spend = row ? rMetricValue(row.cur,'spend') : 0;
const on = plats.includes(p);
const lag = fresh[p];
const cur = (ds && ds.account.currency) || 'USD';
return (
setPlats(cur2=> on ? cur2.filter(x=>x!==p) : [...cur2, p])}
className={`flex items-center gap-2.5 px-3 py-2.5 rounded-xl border text-left transition ${on
? 'border-accent-400 bg-accent-50/60 dark:bg-accent-500/10'
: 'border-ink-200 dark:border-white/10 opacity-60 hover:opacity-100'}`}>
{rPlatform(p).label}
{row ? rFmt(spend,'currency',cur,'en') + ' spend' : 'no rows'} · {fmtLag(lag)}
{on && }
);
})}
setShowUpload(true)}
className="mt-3 text-[12px] text-accent-600 dark:text-accent-300 font-semibold">
⤒ Import CSV / Excel files (Snapchat, X, LinkedIn or any export)
{showAgency && (
{agencyBrand.logo && }
Add “Prepared by {agencyBrand.name || 'FLNT'}” agency credit
the agency logo/name — separate from the client's brand kit
)}
Back
Build report
setShowUpload(false)}
accountId={accountId} accounts={accounts} currentUser={currentUser}
onDone={(res)=>{
setShowUpload(false);
setSource('manual'); // imported files live behind the manual wall
if (res.accId !== accountId) setAccountId(res.accId);
if (res.brandName) setBrandName(res.brandName);
if (res.start){ setStart(res.start); setEnd(res.end); }
setPlats(rSortPlatforms(res.platforms || []));
}}/>
);
}
/* ---------- Home: saved reports + template picker -------------------------- */
function ReportsHome({ accounts, currentUser, onOpenTemplate, onOpenSaved, globalAccountId }){
const [saved, setSaved] = useState(null);
useEffect(()=>{
RelayAPI.get('/report/list?account='+encodeURIComponent(globalAccountId||''))
.then(r=>setSaved(r.reports||[])).catch(()=>setSaved([]));
}, [globalAccountId]);
const iconTone = { tpl_monthly:'#2f7bff', tpl_wrapup:'#f59e0b', tpl_weekly:'#10b981',
tpl_almunawarah:'#A67E32', tpl_blank:'#8b8b80' };
return (
Report Builder
Branded client reports — PPTX, PDF and Excel from live or imported data.
Start from a template
{REPORT_TEMPLATES.map(t=>(
onOpenTemplate(t)}
className="text-left p-4 rounded-2xl border border-ink-200 dark:border-white/10 bg-white dark:bg-ink-900/50 hover:border-accent-400 hover:shadow-card transition group">
{t.name}
{t.lang==='ar' &&
عربي · RTL }
{t.desc}
))}
Saved reports
{saved===null &&
loading…
}
{saved && saved.length===0 && (
Nothing saved yet — reports autosave while you build.
)}
{saved && saved.length>0 && (
{saved.map(r=>(
onOpenSaved(r.id)}
className="w-full flex items-center gap-3 px-3.5 py-2.5 rounded-xl border border-ink-200 dark:border-white/10 bg-white dark:bg-ink-900/50 hover:border-accent-400 transition text-left">
{r.name}
{r.is_template && template }
{r.lang==='ar' && عربي }
{String(r.updated_at||'').slice(0,16)}
))}
)}
);
}
/* ---------- Root routed view ---------------------------------------------- */
function ReportsView({ accountId, currentUser }){
const g = useGlobal();
const accounts = g.myAccounts || [];
const [mode, setMode] = useState('home'); // home | setup | builder
const [template, setTemplate] = useState(null);
const [builderDef, setBuilderDef] = useState(null);
const [builderId, setBuilderId] = useState(null);
const canBuild = typeof can==='function' ? can(currentUser,'reports.build') : true;
const openSaved = async (id)=>{
try {
const r = await RelayAPI.get('/report/get?id='+encodeURIComponent(id));
if (r.report && r.report.def){
const def = r.report.def;
def.accountId = def.accountId || r.report.account_id;
def.brandKitId = def.brandKitId || r.report.brand_kit_id;
setBuilderDef(def); setBuilderId(id); setMode('builder');
}
} catch(e){ console.warn(e); }
};
if (!canBuild){
return
Your role cannot build reports — ask an admin for Reporter or Analyst access (or higher).
;
}
if (mode==='builder' && builderDef){
return { setMode('home'); setBuilderDef(null); setBuilderId(null); }}
currentUser={currentUser} accounts={accounts}/>;
}
if (mode==='setup' && template){
return setMode('home')}
onLaunch={(def)=>{ setBuilderDef(def); setBuilderId(null); setMode('builder'); }}/>;
}
return { setTemplate(t); setMode('setup'); }} onOpenSaved={openSaved}/>;
}
Object.assign(window, { ReportsView, ReportBuilderView, ReportSetup, ReportsHome,
BrandKitEditor, FollowersEntry, useReportDataset });