/* Report Builder — exporters: PPTX (native/editable), PDF (16:9 snapshot),
XLSX (multi-sheet) + optional ZIP-of-CSVs. All three ship together (v1).
Heavy libraries lazy-load on first export so the dashboard stays light.
Everything derives from (reportDef, dataset, brandKit) — same source as
the live preview. PDF page = 13.333×7.5in landscape, never A4.
======================================================================== */
/* ---------- Lazy CDN loader ------------------------------------------- */
const R_LIBS = {
pptx: { url:'https://cdn.jsdelivr.net/npm/pptxgenjs@3.12.0/dist/pptxgen.bundle.js', test:()=>window.PptxGenJS },
jspdf: { url:'https://cdn.jsdelivr.net/npm/jspdf@2.5.1/dist/jspdf.umd.min.js', test:()=>window.jspdf },
html2canvas: { url:'https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js',test:()=>window.html2canvas },
jszip: { url:'https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js', test:()=>window.JSZip },
};
const _rLibP = {};
function rLoadLib(name){
const lib = R_LIBS[name];
if (lib.test()) return Promise.resolve();
if (_rLibP[name]) return _rLibP[name];
_rLibP[name] = new Promise((res, rej)=>{
const s = document.createElement('script');
s.src = lib.url; s.async = true;
s.onload = ()=> lib.test() ? res() : rej(new Error(name+' failed to initialise'));
s.onerror = ()=> rej(new Error('Could not load '+name+' (network)'));
document.head.appendChild(s);
});
return _rLibP[name];
}
/* ---------- Shared helpers --------------------------------------------- */
function rExportFilename(def, ds, ext){
const brand = (def.brandName || (ds.account && ds.account.name) || 'Report').trim();
const period = def.scope.start ? (def.scope.start + ' to ' + def.scope.end) : '';
const base = `${brand} — ${period} — Performance Report`.replace(/[\\/:*?"<>|]/g,'-');
return base + '.' + ext;
}
function rDownloadBlob(blob, filename){
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a); a.click();
setTimeout(()=>{ URL.revokeObjectURL(a.href); a.remove(); }, 2000);
}
const _imgCache = {};
async function rImgData(path){
if (!path) return null;
if (_imgCache[path]) return _imgCache[path];
try {
const res = await fetch(path, { credentials:'include' });
if (!res.ok) return null;
const blob = await res.blob();
const data = await new Promise((r)=>{ const fr = new FileReader(); fr.onload=()=>r(fr.result); fr.readAsDataURL(blob); });
const dims = await new Promise((r)=>{ const im = new Image(); im.onload=()=>r({w:im.naturalWidth,h:im.naturalHeight}); im.onerror=()=>r(null); im.src=data; });
_imgCache[path] = { data, dims };
return _imgCache[path];
} catch(e){ return null; }
}
/* Platform brand icons for PPTX: render the React SVG offscreen, rasterize
to PNG. color resolves `currentColor` (use #fff on branded slides). */
const _iconPngCache = {};
async function rBrandIconPng(icon, color){
const key = icon + '|' + color;
if (_iconPngCache[key] !== undefined) return _iconPngCache[key];
let out = null;
const host = document.createElement('div');
host.style.cssText = 'position:fixed;left:-9999px;top:0;color:' + color + ';';
document.body.appendChild(host);
const root = ReactDOM.createRoot(host);
try {
await new Promise(res=>{ root.render(React.createElement(BrandIcon, { name:icon, size:256 })); setTimeout(res, 80); });
const svg = host.querySelector('svg');
if (svg){
svg.setAttribute('xmlns','http://www.w3.org/2000/svg');
const ser = new XMLSerializer().serializeToString(svg).replace(/currentColor/g, color);
out = await new Promise(res=>{
const im = new Image();
im.onload = ()=>{
try {
const c = document.createElement('canvas'); c.width = 256; c.height = 256;
c.getContext('2d').drawImage(im, 0, 0, 256, 256);
res(c.toDataURL('image/png'));
} catch(e){ res(null); }
};
im.onerror = ()=>res(null);
im.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(ser);
});
}
} catch(e){ out = null; }
root.unmount(); host.remove();
_iconPngCache[key] = out;
return out;
}
/* Platform mark for exports: official PNG first, rasterized SVG fallback. */
async function rPlatformImg(platform, fallbackColor){
const p = typeof rPlatformLogo==='function' ? rPlatformLogo(platform) : null;
if (p){ const im = await rImgData(p); if (im) return im.data; }
return await rBrandIconPng(rPlatform(platform).icon, fallbackColor || '#1A1A17');
}
/* Preflight: slides referencing platforms with no rows in range. */
function rPreflight(def, ds){
const have = new Set(rDatasetPlatforms(ds));
const warned = new Set();
const out = [];
def.slides.forEach((s,i)=>{
const plats = new Set();
if (s.platform) plats.add(s.platform);
(s.blocks||[]).forEach(b=>{ if (b.binding && b.binding.platform) plats.add(b.binding.platform); });
plats.forEach(p=>{
if (!have.has(p) && !warned.has(p+':'+i)){
warned.add(p+':'+i);
out.push({ slide:i+1, title:s.title, platform:p,
msg:`Slide ${i+1} “${s.title}” references ${rPlatform(p).label}, which has no data in this range.` });
}
});
});
return out;
}
/* ============================ XLSX / CSV ============================ */
const R_XLSX_METRICS = ['spend','impressions','reach','clicks','link_clicks','ctr','cpc','cpm',
'conversions','cpa','cvr','revenue','roas','video_views','engagements','leads'];
function _sheetName(s){ return s.replace(/[\\/*?:\[\]]/g,'').slice(0,31); }
function _entityTable(ds, level, platform, currency){
const XM = R_XLSX_METRICS.filter(k=>!rMetricBlockedFor(ds.account.id, k));
const { rows } = rEntityRows(ds, level, { platform, sortKey:'spend', sortDir:'desc' });
const head = [ level==='campaign'?'Campaign':level==='adset'?'Ad Set':'Ad',
...(level!=='campaign' ? ['Parent'] : []), 'Status',
...XM.map(k=>rMetric(k).label + (rMetric(k).fmt.startsWith('currency')?` (${currency})`:'')) ];
const campaignsById = Object.fromEntries((ds.entities.campaigns||[]).map(c=>[c.id,c.name]));
const adsetsById = Object.fromEntries((ds.entities.adsets||[]).map(a=>[a.id,a.name]));
const body = rows.map(r=>[
r.name,
...(level==='adset' ? [campaignsById[r.campaign_id]||''] :
level==='ad' ? [adsetsById[r.adset_id]||''] : []),
r.status||'',
...XM.map(k=>{
const v = rMetricValue(r.cur,k);
return rMetric(k).fmt==='pct' || rMetric(k).fmt==='x' || rMetric(k).fmt==='currency2'
? +v.toFixed(3) : Math.round(v*100)/100;
}),
]);
return [head, ...body];
}
function rBuildWorkbookSheets(def, ds){
const currency = ds.account.currency || 'USD';
/* per-account blocklist (e.g. Al Munawarah: no ROAS in any export) */
const XM = R_XLSX_METRICS.filter(k=>!rMetricBlockedFor(ds.account.id, k));
const sheets = []; // [name, aoa]
/* Summary */
const plats = rByPlatform(ds);
const tot = rTotals(ds);
const summary = [
['Account', ds.account.name],
['Brand', def.brandName || ds.account.name],
['Currency', currency],
['Period', ds.range.start + ' → ' + ds.range.end],
['Comparison period', ds.compare.start + ' → ' + ds.compare.end],
['Generated', new Date().toISOString().slice(0,16).replace('T',' ')],
[],
['Platform', ...XM.map(k=>rMetric(k).label), 'Δ Spend %', 'Δ Results %'],
...plats.map(p=>{
const dS = rDelta(rMetricValue(p.cur,'spend'), rMetricValue(p.prev,'spend'),'spend');
const dC = rDelta(rMetricValue(p.cur,'conversions'), rMetricValue(p.prev,'conversions'),'conversions');
return [ rPlatform(p.platform).label,
...XM.map(k=>+rMetricValue(p.cur,k).toFixed(3)),
dS?+dS.pct.toFixed(1):'', dC?+dC.pct.toFixed(1):'' ];
}),
[ 'TOTAL', ...XM.map(k=>+rMetricValue(tot.cur,k).toFixed(3)),
(()=>{ const d=rDelta(rMetricValue(tot.cur,'spend'),rMetricValue(tot.prev,'spend'),'spend'); return d?+d.pct.toFixed(1):''; })(),
(()=>{ const d=rDelta(rMetricValue(tot.cur,'conversions'),rMetricValue(tot.prev,'conversions'),'conversions'); return d?+d.pct.toFixed(1):''; })() ],
];
const brand = rBrand(ds);
if (brand.hasData){
summary.push([], ['Followers gained (period)']);
brand.platforms.forEach(p=>summary.push([rPlatform(p).label, brand.gained[p]||0,
brand.latest[p]!=null?('total '+brand.latest[p]):'' ]));
summary.push(['Combined', brand.totalGained, brand.totalFollowers||'']);
}
sheets.push(['Summary', summary]);
/* Per-platform entity sheets in locked order */
rDatasetPlatforms(ds).forEach(p=>{
const P = rPlatform(p).label;
[['campaign','Campaigns'],['adset','Ad Sets'],['ad','Ads']].forEach(([lvl,lbl])=>{
const table = _entityTable(ds, lvl, p, currency);
if (table.length > 1) sheets.push([_sheetName(`${P} ${lbl}`), table]);
});
});
/* Daily data */
const daily = [ ['Date','Platform',...XM.filter(k=>rMetric(k).agg==='sum').map(k=>rMetric(k).label)],
...(ds.daily||[]).map(r=>[ r.date, rPlatform(r.platform).label,
...XM.filter(k=>rMetric(k).agg==='sum').map(k=>Number(r[k])||0) ]) ];
sheets.push(['Daily Data', daily]);
/* Definitions */
const defs = [ ['Metric','Definition / formula'],
...rAllMetrics().map(m=>{
let f = 'Reported by platform (summed daily)';
if (m.agg && m.agg.num) f = `${rMetric(m.agg.num).label} ÷ ${rMetric(m.agg.den).label}` + (m.agg.mult?` × ${m.agg.mult}`:'');
if (m.agg && m.agg.formula) f = 'Custom: ' + m.agg.formula;
if (m.agg === 'last') f = 'Latest reported value (not summed)';
return [m.label, f + (m.note ? ' — ' + m.note : '')];
}) ];
sheets.push(['Definitions', defs]);
return sheets;
}
async function rExportXLSX(def, ds, opts){
const sheets = rBuildWorkbookSheets(def, ds);
if (opts && opts.csvZip){
await rLoadLib('jszip');
const zip = new JSZip();
sheets.forEach(([name, aoa])=>{
const ws = XLSX.utils.aoa_to_sheet(aoa);
zip.file(name.replace(/[^\w-ۿ -]/g,'') + '.csv', '' + XLSX.utils.sheet_to_csv(ws));
});
const blob = await zip.generateAsync({ type:'blob' });
rDownloadBlob(blob, rExportFilename(def, ds, 'zip'));
return;
}
const wb = XLSX.utils.book_new();
sheets.forEach(([name, aoa])=>{
const ws = XLSX.utils.aoa_to_sheet(aoa);
ws['!cols'] = (aoa[0]||[]).map((_,i)=>({ wch: i===0?34:14 }));
XLSX.utils.book_append_sheet(wb, ws, name);
});
const out = XLSX.write(wb, { bookType:'xlsx', type:'array' });
rDownloadBlob(new Blob([out], {type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}),
rExportFilename(def, ds, 'xlsx'));
}
/* ============================ PDF ============================ */
/* Mounts selected slides offscreen at full 1280×720, snapshots each at 2x. */
async function rExportPDF(def, ds, kit, slideIds, onProgress){
await Promise.all([rLoadLib('jspdf'), rLoadLib('html2canvas')]);
const theme = rTheme(kit, def.lang);
const currency = ds.account.currency || 'USD';
const slides = def.slides.filter(s=>slideIds.includes(s.id));
const host = document.createElement('div');
host.style.cssText = 'position:fixed;left:-99999px;top:0;z-index:-1;';
document.body.appendChild(host);
const root = ReactDOM.createRoot(host);
const renderAll = ()=> new Promise(res=>{
root.render(
);
setTimeout(res, 650); // charts render synchronously (animations off); settle fonts/images
});
try {
await renderAll();
try { await (document.fonts && document.fonts.ready); } catch(e){}
const { jsPDF } = window.jspdf;
const pdf = new jsPDF({ orientation:'landscape', unit:'in', format:[13.333, 7.5] });
for (let i=0; i{
doc.querySelectorAll('.r-slide, .r-slide *').forEach(el=>{
el.style.letterSpacing = 'normal';
});
} });
const img = canvas.toDataURL('image/jpeg', 0.92);
if (i > 0) pdf.addPage([13.333,7.5], 'landscape');
pdf.addImage(img, 'JPEG', 0, 0, 13.333, 7.5);
}
pdf.save(rExportFilename(def, ds, 'pdf'));
} finally {
root.unmount();
host.remove();
}
}
/* ============================ PPTX ============================ */
const IN_W = 13.333, IN_H = 7.5, MARGIN = 0.55;
const PX = (px)=> px / R_SLIDE_W * IN_W; // logical px → inches
function _hex(c){ return String(c||'#000000').replace('#',''); }
async function rExportPPTX(def, ds, kit, slideIds, onProgress){
await rLoadLib('pptx');
const theme = rTheme(kit, def.lang);
const lang = def.lang, rtl = theme.rtl;
const currency = ds.account.currency || 'USD';
const pptx = new PptxGenJS();
pptx.defineLayout({ name:'WIDE', width:IN_W, height:IN_H });
pptx.layout = 'WIDE';
pptx.author = 'Relay';
pptx.title = def.name;
const T = (o)=> Object.assign({ fontFace: rtl ? 'Sakkal Majalla' : 'Calibri',
lang: rtl ? 'ar-SA' : 'en-US', rtlMode: rtl }, o);
const alignStart = rtl ? 'right' : 'left';
const bgImg = rBrandedBg(theme) ? await rImgData(rBrandedBg(theme)) : null;
const logo = theme.logo ? await rImgData(theme.logo) : null;
const logoDark= theme.logoDark ? await rImgData(theme.logoDark) : null;
const logoRatio = (img)=> img && img.dims ? img.dims.w/img.dims.h : 3;
const periodStr = def.scope.start ? rDateLabel(def.scope.start,lang)+' – '+rDateLabel(def.scope.end,lang) : '';
const slides = def.slides.filter(s=>slideIds.includes(s.id));
const middle = def.slides.filter(s=>!['cover','agenda','thank_you'].includes(s.type));
const brandedBase = (s)=>{
if (bgImg){ s.background = { data: bgImg.data }; return; }
s.background = { color:_hex(theme.main) };
/* brand-derived geometry when the kit has no cover artwork */
const ex = rtl ? -2.2 : IN_W - 4.3;
s.addShape(pptx.ShapeType.ellipse, { x:ex+1.5, y:-2.4, w:6.5, h:6.5,
fill:{ color:_hex(theme.secondary), transparency:86 }, line:{ type:'none' } });
s.addShape(pptx.ShapeType.ellipse, { x:ex+2.2, y:4.6, w:4.5, h:4.5,
fill:{ color:_hex(theme.main), transparency:100 }, line:{ color:_hex(rMix(theme.tertiary, theme.main, 0.35)), width:2 } });
s.addShape(pptx.ShapeType.ellipse, { x:ex, y:1.15, w:1.9, h:1.9,
fill:{ color:_hex(theme.tertiary), transparency:82 }, line:{ type:'none' } });
};
const onBrand = _hex(theme.onMain);
/* agency credit ("Prepared by FLNT") — separate from the client brand */
const agency = def.agency && def.agency.enabled ? def.agency : null;
const agencyLogo = agency && agency.logo ? await rImgData(agency.logo) : null;
const addAgency = (s, x, y, align)=>{
if (!agency) return;
let ax = x;
if (agencyLogo){
const w = Math.min(0.24 * (agencyLogo.dims ? agencyLogo.dims.w/agencyLogo.dims.h : 3), 0.9);
s.addImage({ data:agencyLogo.data, x: align==='center' ? ax - w/2 - 0.9 : ax + 1.05, y:y-0.015, h:0.24, w });
}
s.addText([
{ text:'Prepared by ', options:{ transparency:30 } },
{ text: agency.name || 'FLNT', options:{ bold:true } },
], T({ x: align==='center' ? ax-2 : ax, y, w:4, h:0.3, fontSize:10.5, color:onBrand,
align: align || 'left', rtlMode:false, lang:'en-US' }));
};
for (let i=0; i 3.8 ? 3.8 : 1.15*logoRatio(lg) });
if (def.brandName) s.addText(def.brandName.toUpperCase(), T({ x:MARGIN, y:2.6, w:IN_W-MARGIN*2, h:0.45,
fontSize:15, color:onBrand, transparency:20, charSpacing:4, bold:true, align:alignStart }));
s.addText(sl.title || rStr('performance_report',lang), T({ x:MARGIN, y:3.0, w:IN_W-MARGIN*2, h:1.7,
fontSize:40, bold:true, color:onBrand, align:alignStart }));
if (periodStr) s.addText(rStr('exec_summary',lang)+' — '+periodStr, T({ x:MARGIN, y:4.75, w:IN_W-MARGIN*2, h:0.55,
fontSize:16, color:onBrand, transparency:10, align:alignStart }));
s.addShape(pptx.ShapeType.roundRect, { x: rtl ? IN_W-MARGIN-1.0 : MARGIN, y:5.45, w:1.0, h:0.08,
fill:{ color:_hex(theme.secondary) }, line:{ type:'none' }, rectRadius:0.04 });
addAgency(s, MARGIN, IN_H-0.62, 'left');
} else if (sl.type === 'thank_you'){
brandedBase(s);
const lg = logoDark || logo;
if (lg){ const w = Math.min(1.55*logoRatio(lg), 4.2);
s.addImage({ data:lg.data, x:(IN_W-w)/2, y:1.75, h:1.55, w }); }
s.addText(sl.title || rStr('thank_you',lang), T({ x:1, y:3.65, w:IN_W-2, h:1.1,
fontSize:40, bold:true, color:onBrand, align:'center' }));
if (def.brandName) s.addText(def.brandName, T({ x:1, y:4.85, w:IN_W-2, h:0.55,
fontSize:16, color:onBrand, transparency:15, align:'center' }));
addAgency(s, IN_W/2, 5.55, 'center');
} else if (sl.type === 'divider'){
brandedBase(s);
const dividers = def.slides.filter(x=>x.type==='divider');
const secNum = String(dividers.indexOf(sl)+1).padStart(2,'0');
/* oversized section-number watermark */
s.addText(secNum, T({ x: rtl ? 0.1 : IN_W-4.7, y:IN_H-3.3, w:4.6, h:3.3,
fontSize:190, bold:true, color:onBrand, transparency:91,
align: rtl?'left':'right', rtlMode:false, lang:'en-US' }));
/* platform logo (official PNG in a white chip) */
if (sl.platform){
const ic = await rPlatformImg(sl.platform, '#ffffff');
if (ic){
s.addShape(pptx.ShapeType.roundRect, { x: rtl ? IN_W-MARGIN-1.05 : MARGIN, y:1.5, w:1.05, h:1.05,
fill:{ color:'FFFFFF', transparency:8 }, line:{ type:'none' }, rectRadius:0.18,
shadow:{ type:'outer', blur:8, offset:2, angle:90, color:'000000', opacity:0.3 } });
s.addImage({ data:ic, x: (rtl ? IN_W-MARGIN-1.05 : MARGIN)+0.17, y:1.67, w:0.71, h:0.71 });
}
}
s.addText((rtl ? '' : 'SECTION ') + secNum, T({ x:MARGIN, y:2.72, w:IN_W-MARGIN*2, h:0.35,
fontSize:12, bold:true, color:onBrand, transparency:35, charSpacing:3, align:alignStart, rtlMode:false, lang:'en-US' }));
s.addText(sl.title, T({ x:MARGIN, y:3.05, w:IN_W-MARGIN*2, h:1.4,
fontSize:40, bold:true, color:onBrand, align:alignStart }));
s.addShape(pptx.ShapeType.roundRect, { x: rtl ? IN_W-MARGIN-1.2 : MARGIN, y:4.6, w:1.2, h:0.1,
fill:{ color:_hex(theme.secondary) }, line:{ type:'none' }, rectRadius:0.05 });
} else if (sl.type === 'agenda'){
await _pptxHeader(pptx, s, sl.title || rStr('agenda',lang), theme, T, alignStart, periodStr, logo, null);
const rowsPerCol = Math.ceil(middle.length/2);
const rowH = Math.min(0.72, 5.4/Math.max(rowsPerCol,1)); // spread down the slide
middle.forEach((m, idx)=>{
const col = Math.floor(idx/rowsPerCol), row = idx % rowsPerCol;
let x = MARGIN + col * ((IN_W-MARGIN*2)/2 + 0.15);
const y = 1.7 + row * rowH;
s.addShape(pptx.ShapeType.roundRect, { x: rtl ? IN_W - x - 0.42 : x, y:y+0.03, w:0.42, h:0.42,
fill:{ color:_hex(theme.main) }, line:{ type:'none' }, rectRadius:0.08 });
s.addText(String(idx+1).padStart(2,'0'), T({ x: rtl ? IN_W - x - 0.42 : x, y:y+0.03, w:0.42, h:0.42,
fontSize:12, bold:true, color:onBrand, align:'center', valign:'middle' }));
s.addText((m.type==='divider' ? (lang==='ar'?'قسم: ':'Section: ') : '') + m.title,
T({ x: rtl ? IN_W - x - ((IN_W-MARGIN*2)/2 - 0.3) : x+0.58, y, w:(IN_W-MARGIN*2)/2 - 0.65, h:0.48,
fontSize:15, bold:true, color:'33332E', align:alignStart, valign:'middle' }));
});
} else {
await _pptxHeader(pptx, s, sl.title, theme, T, alignStart, periodStr, logo, sl.platform || null);
/* block layout cursor mirroring the DOM 12-col grid;
a single block gets the whole content area (tall) */
let y = 1.6;
const blocks = sl.blocks || [];
const tall = blocks.length === 1;
let bi = 0;
while (bi < blocks.length && y < IN_H - 0.6){
const blk = blocks[bi];
const meta = R_BLOCKS[blk.type] || { span:12 };
const span = (blk.options && blk.options.span) || meta.span;
const next = blocks[bi+1];
const nextSpan = next ? ((next.options && next.options.span) || (R_BLOCKS[next.type]||{span:12}).span) : 12;
if (span <= 6 && nextSpan <= 6 && next){
const wHalf = (IN_W - MARGIN*2 - 0.3)/2;
const h1 = await _pptxBlock(pptx, s, blk, def, ds, theme, T, { x:MARGIN, y, w:wHalf }, currency, alignStart);
const h2 = await _pptxBlock(pptx, s, next, def, ds, theme, T, { x:MARGIN+wHalf+0.3, y, w:wHalf }, currency, alignStart);
y += Math.max(h1, h2) + 0.25;
bi += 2;
} else {
const h = await _pptxBlock(pptx, s, blk, def, ds, theme, T, { x:MARGIN, y, w:IN_W-MARGIN*2, tall }, currency, alignStart);
y += h + 0.25;
bi += 1;
}
}
}
/* light-slide chrome: top brand strip + footer (brand · agency · page) */
if (!['cover','thank_you','divider'].includes(sl.type)){
s.addShape(pptx.ShapeType.rect, { x:0, y:0, w:IN_W, h:0.055,
fill:{ color:_hex(theme.main) }, line:{ type:'none' } });
s.addShape(pptx.ShapeType.rect, { x:IN_W*0.55, y:0, w:IN_W*0.45, h:0.055,
fill:{ color:_hex(theme.tertiary) }, line:{ type:'none' } });
s.addShape(pptx.ShapeType.rect, { x:MARGIN, y:IN_H-0.42, w:IN_W-MARGIN*2, h:0.008,
fill:{ color:'ECECE6' }, line:{ type:'none' } });
s.addText(def.brandName || '', T({ x:MARGIN, y:IN_H-0.38, w:4, h:0.28,
fontSize:9.5, bold:true, color:'A3A39A', align:'left', rtlMode:false }));
const pageTxt = agency
? [{ text:'Prepared by ', options:{ color:'A3A39A' } },
{ text:(agency.name||'FLNT'), options:{ bold:true, color:'8B8B80' } },
{ text:' · ' + (def.slides.indexOf(sl)+1) + ' / ' + def.slides.length, options:{ color:'A3A39A' } }]
: [{ text:(def.slides.indexOf(sl)+1) + ' / ' + def.slides.length, options:{ color:'A3A39A' } }];
s.addText(pageTxt, T({ x:IN_W-5-MARGIN, y:IN_H-0.38, w:5, h:0.28,
fontSize:9.5, align:'right', rtlMode:false, lang:'en-US' }));
}
}
onProgress && onProgress('Writing file…');
await pptx.writeFile({ fileName: rExportFilename(def, ds, 'pptx') });
}
async function _pptxHeader(pptx, s, title, theme, T, alignStart, periodStr, logo, platform){
s.background = { color:'FBFBF9' };
const rtl = theme.rtl;
s.addShape(pptx.ShapeType.roundRect, { x: rtl ? IN_W-MARGIN-0.07 : MARGIN, y:0.45, w:0.07, h:0.55,
fill:{ color:_hex(theme.main) }, line:{ type:'none' }, rectRadius:0.035 });
s.addText(title, T({ x: rtl ? MARGIN : MARGIN+0.2, y:0.38, w:IN_W-MARGIN*2-1.8, h:0.6,
fontSize:24, bold:true, color:'22221E', align:alignStart }));
if (periodStr) s.addText(periodStr, T({ x: rtl ? MARGIN : MARGIN+0.2, y:0.95, w:IN_W-MARGIN*2-1.8, h:0.32,
fontSize:11, color:'9A9A90', align:alignStart }));
/* platform logo (before the kit logo, on the header's end side) */
let endX = rtl ? MARGIN : IN_W - MARGIN;
if (logo){
const w = Math.min(0.5 * (logo.dims ? logo.dims.w/logo.dims.h : 3), 1.8);
s.addImage({ data:logo.data, x: rtl ? endX : endX - w, y:0.42, h:0.5, w });
endX = rtl ? endX + w + 0.18 : endX - w - 0.18;
}
if (platform){
const ic = await rPlatformImg(platform, '#1A1A17');
if (ic) s.addImage({ data:ic, x: rtl ? endX : endX - 0.46, y:0.44, w:0.46, h:0.46 });
}
}
/* Render one block natively; returns consumed height in inches. */
async function _pptxBlock(pptx, s, blk, def, ds, theme, T, box, currency, alignStart){
const lang = def.lang;
const b = blk.binding || {};
const type = blk.type;
if (type === 'scorecard' || type === 'metric_grid'){
const t = b.platform ? rTotals(ds, b.platform) : rTotals(ds);
const metrics = (b.metrics && b.metrics.length ? b.metrics : ['spend','conversions','cpa','roas']);
const cols = metrics.length <= 4 ? metrics.length : Math.ceil(metrics.length/2);
const rows = Math.ceil(metrics.length/cols);
const big = box.tall && metrics.length <= 4; // solo scorecards fill the slide
const gap = big ? 0.26 : 0.2;
const cardW = (box.w - gap*(cols-1))/cols;
const cardH = big ? 3.4 : 1.45;
const cy0 = big ? box.y + 0.6 : box.y;
metrics.forEach((k, i)=>{
const m = rMetric(k); if (!m) return;
const cx = box.x + (i%cols)*(cardW+gap), cy = cy0 + Math.floor(i/cols)*(cardH+gap);
/* Total Followers = brand metric (brand_metrics_daily), not ad sums */
let cur, prev, d, followersSub = null;
if (k === 'followers'){
const br = rBrand(ds);
cur = br.totalFollowers || br.totalGained || 0;
prev = null; d = null;
followersSub = br.totalGained ? '+' + br.totalGained.toLocaleString('en-US') + ' ' + rStr('followers_by_platform',lang) : null;
} else {
cur = rMetricValue(t.cur,k); prev = rMetricValue(t.prev,k);
d = rDelta(cur, prev, k);
}
const padTop = big ? 0.85 : 0.1;
s.addShape(pptx.ShapeType.roundRect, { x:cx, y:cy, w:cardW, h:cardH,
fill:{ color:'FFFFFF' }, line:{ color:'E7E7E2', width:1 }, rectRadius:0.1 });
s.addShape(pptx.ShapeType.roundRect, { x:cx+0.16, y:cy+(big?0.55:0.09), w:big?0.55:0.42, h:0.05,
fill:{ color:_hex(theme.ramp[i % theme.ramp.length]) }, line:{ type:'none' }, rectRadius:0.025 });
s.addText(rMetricLabel(k,lang), T({ x:cx+0.16, y:cy+padTop, w:cardW-0.32, h:0.34,
fontSize: big?13:11, bold:true, color:'6D6D64', align:alignStart }));
s.addText(rCompact(cur, k==='followers' ? 'int' : m.fmt, currency, lang),
T({ x:cx+0.16, y:cy+padTop+0.32, w:cardW-0.32, h:big?0.7:0.5,
fontSize: big?30:23, bold:true, color:_hex(theme.main), align:alignStart }));
if (d) s.addText((d.up?'▲ ':'▼ ') + Math.abs(d.pct).toFixed(1) + '% ' + rStr('vs_prev',lang),
T({ x:cx+0.16, y:cy+padTop+(big?1.1:0.86), w:cardW-0.32, h:0.3, fontSize: big?11:9.5, bold:true,
color: d.tone==='good' ? '0C7A43' : d.tone==='bad' ? 'B42323' : '5E5E55', align:alignStart }));
else if (followersSub) s.addText(followersSub,
T({ x:cx+0.16, y:cy+padTop+(big?1.1:0.86), w:cardW-0.32, h:0.3, fontSize: big?11:9.5, bold:true,
color:'0C7A43', align:alignStart }));
});
return big ? 5.2 : rows*(cardH+gap);
}
if (type === 'trend'){
const keys = (b.metrics && b.metrics.length ? b.metrics : ['spend']);
const data = rSeries(ds, keys, b.platform);
if (!data.length) return _pptxEmpty(pptx, s, box, T, lang);
const labels = data.map(r=>r.label);
const series = keys.map((k,i)=>({ name: rMetricLabel(k,lang), labels, values: data.map(r=>+(r[k]||0).toFixed(3)) }));
const showCompare = blk.options && blk.options.showCompare !== false;
if (showCompare) keys.forEach((k)=>{
series.push({ name: rMetricLabel(k,lang)+' (prev)', labels, values: data.map(r=>+(r['_cmp_'+k]||0).toFixed(3)) });
});
const colors = keys.map((k,i)=>_hex(theme.ramp[i%theme.ramp.length]));
const h = box.tall ? 5.3 : 3.2;
s.addChart(pptx.ChartType.line, series, { x:box.x, y:box.y, w:box.w, h,
chartColors: showCompare ? [...colors, ...colors] : colors,
lineSize: 2.25, lineSmooth: true, lineDataSymbol:'none',
catAxisLabelFontSize: 10, valAxisLabelFontSize: 10, legendFontSize: 11,
showLegend: true, legendPos:'b',
valGridLine:{ color:'E5E5E0', style:'solid', size:1 }, catGridLine:{ style:'none' } });
return h;
}
if (type === 'bar'){
const key = b.metric || 'spend';
let items;
if ((b.dimension||'platform')==='platform'){
items = rByPlatform(ds).map(p=>({ name:(lang==='ar'?rPlatform(p.platform).ar:rPlatform(p.platform).label),
v:rMetricValue(p.cur,key) }));
} else {
const { rows } = rEntityRows(ds, b.level||'campaign', { platform:b.platform, sortKey:key, topN:b.topN||8 });
items = rows.map(r=>({ name:r.name, v:rMetricValue(r.cur,key) }));
}
items = items.filter(d=>d.v>0);
if (!items.length) return _pptxEmpty(pptx, s, box, T, lang);
const h = box.tall ? 5.3 : 3.0;
s.addChart(pptx.ChartType.bar, [{ name:rMetricLabel(key,lang), labels:items.map(d=>d.name),
values:items.map(d=>+d.v.toFixed(2)) }], { x:box.x, y:box.y, w:box.w, h,
chartColors: items.map((_,i)=>_hex(theme.ramp[i%theme.ramp.length])),
barDir:'col', showLegend:false, catAxisLabelFontSize:10, valAxisLabelFontSize:10,
valGridLine:{ color:'E5E5E0', style:'solid', size:1 } });
return h;
}
if (type === 'donut'){
const key = b.metric || 'spend';
const items = rByPlatform(ds).map(p=>({ name:(lang==='ar'?rPlatform(p.platform).ar:rPlatform(p.platform).label),
v:rMetricValue(p.cur,key) })).filter(d=>d.v>0);
if (!items.length) return _pptxEmpty(pptx, s, box, T, lang);
const h = box.tall ? 5.2 : 3.0;
s.addText(rStr('spend_share',lang), T({ x:box.x, y:box.y-0.05, w:box.w, h:0.32, fontSize:12.5, bold:true, color:'4B4B44', align:alignStart }));
s.addChart(pptx.ChartType.doughnut, [{ name:rMetricLabel(key,lang), labels:items.map(d=>d.name),
values:items.map(d=>+d.v.toFixed(2)) }], { x:box.x, y:box.y+0.3, w:box.w, h:h-0.35,
chartColors: items.map((_,i)=>_hex(theme.ramp[i%theme.ramp.length])),
holeSize: 60, showLegend:true, legendPos: theme.rtl?'l':'r', legendFontSize:11,
showPercent:true, dataLabelFontSize:10 });
return h;
}
if (type === 'table'){
const metrics = b.metrics && b.metrics.length ? b.metrics : ['spend','impressions','clicks','ctr','conversions','cpa'];
const res = rEntityRows(ds, b.level||'campaign', { platform:b.platform,
sortKey:b.sortKey||'spend', sortDir:b.sortDir||'desc', topN:Math.min(b.topN||10, 12), filters:b.filters||[] });
const rows = res.others ? [...res.rows, res.others] : res.rows;
if (!rows.length) return _pptxEmpty(pptx, s, box, T, lang);
const heat = (blk.options && blk.options.heatmap) || [];
const maxOf = {}; heat.forEach(k=>{ maxOf[k] = Math.max(...rows.map(r=>rMetricValue(r.cur,k)), 0.0001); });
const nameHead = b.level==='ad'?rStr('ads',lang):b.level==='adset'?rStr('adsets',lang):rStr('campaigns',lang);
const headFill = _hex(rMix('#FFFFFF', theme.main, 0.08));
const headRow = [ { text:nameHead, options:{ bold:true, color:'55554E', fill:{color:headFill}, align:alignStart } },
...metrics.map(k=>({ text:rMetricLabel(k,lang), options:{ bold:true, color:'55554E', fill:{color:headFill}, align: theme.rtl?'left':'right' } })) ];
const bodyRows = rows.map((r,ri)=>[
{ text:String(r.name).slice(0,48), options:{ bold:true, color:'33332E', align:alignStart, italic:!!r._others,
fill: ri%2===1 ? { color:'FAFAF8' } : undefined } },
...metrics.map(k=>{
const m = rMetric(k), v = rMetricValue(r.cur,k);
let fill = ri%2===1 ? { color:'FAFAF8' } : undefined;
if (heat.includes(k) && !r._others){
fill = { color:_hex(rMix('#FFFFFF', theme.tertiary, 0.14 + 0.5*Math.min(1, v/maxOf[k]))) };
}
return { text:rFmt(v, m?m.fmt:'int', currency, lang), options:{ align: theme.rtl?'left':'right', fill, color:'3D3D37' } };
}),
]);
const rowH = box.tall ? Math.min(0.44, 5.3/(rows.length+1)) : 0.32;
const h = (rows.length+1)*rowH + 0.12;
s.addTable([headRow, ...bodyRows], { x:box.x, y:box.y, w:box.w,
colW: [box.w*0.3, ...metrics.map(()=> box.w*0.7/metrics.length)],
fontSize: box.tall ? 11 : 10, fontFace: T({}).fontFace, border:{ type:'solid', color:'EFEFE9', pt:0.5 },
rowH, valign:'middle', margin:0.05 });
return h;
}
if (type === 'ranked_list'){
const key = b.metric || 'impressions', sec = b.secondary || 'ctr';
const mk = rMetric(key), ms = rMetric(sec);
const { rows } = rEntityRows(ds, b.level||'campaign', { platform:b.platform, sortKey:key, sortDir:'desc', topN:b.topN||5 });
if (!rows.length) return _pptxEmpty(pptx, s, box, T, lang);
const total = rows.reduce((a,r)=>a+rMetricValue(r.cur,key),0);
const rowH = box.tall ? Math.min(0.95, (5.3 - 0.14*rows.length)/rows.length) : 0.72;
rows.forEach((r,i)=>{
const y = box.y + i*(rowH+0.14);
const v = rMetricValue(r.cur,key), share = total>0? v/total*100 : 0;
s.addShape(pptx.ShapeType.roundRect, { x:box.x, y, w:box.w, h:rowH,
fill:{color:'FFFFFF'}, line:{color:'E7E7E2', width:1}, rectRadius:0.08 });
const badge = Math.min(0.52, rowH-0.2);
const bx = theme.rtl ? box.x + box.w - badge - 0.16 : box.x + 0.16;
s.addShape(pptx.ShapeType.ellipse, { x:bx, y:y+(rowH-badge)/2, w:badge, h:badge, fill:{color:_hex(theme.secondary)}, line:{type:'none'} });
s.addText(String(i+1), T({ x:bx, y:y+(rowH-badge)/2, w:badge, h:badge, fontSize:15, bold:true,
color:_hex(rContrastText(theme.secondary)), align:'center', valign:'middle' }));
const tx = theme.rtl ? box.x + 0.18 : box.x + badge + 0.34;
const tw = box.w - badge - 0.6;
s.addText(String(r.name).slice(0,60), T({ x:tx, y:y+0.07, w:tw, h:rowH*0.45, fontSize:14, bold:true, color:'2B2B26', align:alignStart }));
s.addText(`${rCompact(v, mk?mk.fmt:'int', currency, lang)} — ${share.toFixed(1)}% • ${rMetricLabel(sec,lang)} ${rFmt(rMetricValue(r.cur,sec), ms?ms.fmt:'pct', currency, lang)}`,
T({ x:tx, y:y+rowH*0.5, w:tw, h:rowH*0.4, fontSize:10.5, color:'7C7C72', align:alignStart }));
});
return rows.length*(rowH+0.14);
}
if (type === 'brand'){
const br = rBrand(ds);
if (!br.hasData) return _pptxEmpty(pptx, s, box, T, lang);
s.addText(rStr('followers_by_platform',lang), T({ x:box.x, y:box.y, w:box.w, h:0.28, fontSize:10.5, bold:true, color:'4B4B44', align:alignStart }));
let y = box.y + 0.32;
br.platforms.forEach(p=>{
s.addText((lang==='ar'?rPlatform(p).ar:rPlatform(p).label), T({ x:box.x, y, w:box.w*0.55, h:0.26, fontSize:10, bold:true, color:'3D3D37', align:alignStart }));
s.addText((br.gained[p]?'+'+br.gained[p].toLocaleString('en-US'):'—') +
(br.latest[p]!=null ? ` (${Number(br.latest[p]).toLocaleString('en-US')})` : ''),
T({ x: theme.rtl ? box.x : box.x+box.w*0.5, y, w:box.w*0.5, h:0.26, fontSize:10, bold:true,
color:_hex(theme.main), align: theme.rtl?'left':'right' }));
y += 0.28;
});
s.addText(rStr('total_followers',lang) + ' ' +
(br.totalFollowers ? br.totalFollowers.toLocaleString('en-US') : '+'+br.totalGained.toLocaleString('en-US')),
T({ x:box.x, y:y+0.05, w:box.w, h:0.28, fontSize:10.5, bold:true, color:_hex(theme.main), align:alignStart }));
return (y + 0.4) - box.y;
}
if (type === 'heatmap'){
/* approximate: compact table of weeks × days with fills */
const key = b.metric || 'spend';
const series = rSeries(ds, [key], b.platform);
if (!series.length) return _pptxEmpty(pptx, s, box, T, lang);
const weeks = {};
series.forEach(r=>{
const d = new Date(r.date+'T00:00:00'); const dow = d.getDay();
const ws = new Date(d); ws.setDate(d.getDate()-dow);
const wk = ws.toISOString().slice(0,10);
(weeks[wk] = weeks[wk] || Array(7).fill(null))[dow] = r[key];
});
const wkeys = Object.keys(weeks).sort();
const max = Math.max(...series.map(r=>r[key]||0), 0.0001);
const days = lang==='ar' ? ['أحد','إثن','ثلا','أرب','خمي','جمع','سبت'] : ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
const tRows = [
[ {text:'', options:{fill:{color:'FFFFFF'}}}, ...wkeys.map(w=>({ text:new Date(w+'T00:00:00').toLocaleDateString('en-US',{month:'short',day:'numeric'}),
options:{ fontSize:7, color:'9A9A90', align:'center' } })) ],
...days.map((dl,dow)=>[
{ text:dl, options:{ fontSize:7.5, color:'8B8B80' } },
...wkeys.map(w=>{
const v = weeks[w][dow];
const fill = v==null ? 'F4F4F0' : _hex(rMix('#FFFFFF', theme.main, 0.12+0.72*Math.min(1,v/max)));
return { text: v!=null && v/max>0.02 ? rCompact(v,'int',currency,'en') : '',
options:{ fill:{color:fill}, fontSize:6.5, align:'center',
color:_hex(rContrastText('#'+fill)) } };
}),
]),
];
const hmRowH = box.tall ? 0.6 : 0.3;
const h = 8*hmRowH + 0.1;
s.addTable(tRows, { x:box.x, y:box.y, w:box.w, rowH:hmRowH, border:{ type:'solid', color:'FFFFFF', pt:1 },
fontFace:T({}).fontFace, margin:0.02, valign:'middle' });
return h;
}
if (type === 'perf_flags'){
const perf = rPerfInsights(ds, b.level||'ad', { platform:b.platform, lang, currency });
if (!perf) return _pptxEmpty(pptx, s, box, T, lang);
const half = (box.w - 0.25) / 2;
const card = (x, tone, label, name, reasons)=>{
s.addShape(pptx.ShapeType.roundRect, { x, y:box.y, w:half, h:1.55,
fill:{ color:tone.bg }, line:{ color:tone.border, width:1 }, rectRadius:0.1 });
s.addText(label, T({ x:x+0.15, y:box.y+0.08, w:half-0.3, h:0.3,
fontSize:10, bold:true, color:tone.fg, charSpacing:2, align:alignStart }));
s.addText(String(name).slice(0,60), T({ x:x+0.15, y:box.y+0.38, w:half-0.3, h:0.34,
fontSize:13, bold:true, color:'26261F', align:alignStart }));
s.addText(reasons.slice(0,3).join(' • '), T({ x:x+0.15, y:box.y+0.74, w:half-0.3, h:0.72,
fontSize:9.5, color:tone.fg, align:alignStart, valign:'top' }));
};
const bestLbl = lang==='ar' ? '👑 الأفضل أداءً' : (lang==='both' ? '👑 الأفضل أداءً · TOP PERFORMER' : '👑 TOP PERFORMER');
const worstLbl = lang==='ar' ? '⚠️ يحتاج معالجة' : (lang==='both' ? '⚠️ يحتاج معالجة · NEEDS ATTENTION' : '⚠️ NEEDS ATTENTION');
card(box.x, { bg:'F2F9F2', border:'BFE3BF', fg:'1D7A34' }, bestLbl, perf.best.name, perf.best.reasons);
card(box.x+half+0.25, { bg:'FDF5F0', border:'F0CDB8', fg:'B4560F' }, worstLbl, perf.worst.name, perf.worst.reasons);
return 1.7;
}
if (type === 'priorities'){
const items = (blk.options && blk.options.items && blk.options.items.length)
? blk.options.items : rRecommendations(ds, lang, currency);
if (!items.length) return _pptxEmpty(pptx, s, box, T, lang);
const PRI = { high:{ bar:'D9482B', label: lang==='ar'?'عالية':'HIGH' },
med: { bar:'E2A615', label: lang==='ar'?'متوسطة':'MED' },
low: { bar:'4F9A67', label: lang==='ar'?'منخفضة':'LOW' } };
const cols = 2, gap = 0.22;
const cardW = (box.w - gap)/cols;
const rowsN = Math.ceil(items.length/cols);
const cardH = Math.min(1.65, (5.3 - gap*(rowsN-1)) / rowsN);
items.forEach((it, i)=>{
const p = PRI[it.pri] || PRI.med;
const cx = box.x + (i%cols)*(cardW+gap), cy = box.y + Math.floor(i/cols)*(cardH+gap);
s.addShape(pptx.ShapeType.roundRect, { x:cx, y:cy, w:cardW, h:cardH,
fill:{ color:'FFFFFF' }, line:{ color:'E7E7E2', width:1 }, rectRadius:0.09 });
s.addShape(pptx.ShapeType.rect, { x: theme.rtl ? cx+cardW-0.07 : cx, y:cy, w:0.07, h:cardH,
fill:{ color:p.bar }, line:{ type:'none' } });
s.addText(String(it.title), T({ x:cx+0.18, y:cy+0.06, w:cardW-1.0, h:0.3,
fontSize:12, bold:true, color:'26261F', align:alignStart }));
s.addText(p.label, T({ x: theme.rtl ? cx+0.14 : cx+cardW-0.85, y:cy+0.08, w:0.68, h:0.24,
fontSize:8, bold:true, color:p.bar, align:'center', rtlMode:false }));
s.addText([
{ text: String(it.why).replace(/\n/g,' '), options:{ color:'55554E', breakLine:true } },
{ text: '→ ' + String(it.action).replace(/\n/g,' '), options:{ bold:true, color:_hex(theme.main) } },
], T({ x:cx+0.18, y:cy+0.38, w:cardW-0.36, h:cardH-0.46, fontSize:9, align:alignStart, valign:'top' }));
});
return rowsN*(cardH+gap);
}
if (type === 'text'){
const o = blk.options || {};
let bullets = o.bullets && o.bullets.length ? o.bullets : (o.autoInsights ? rAutoInsights(ds, lang, currency) : []);
const items = [];
if (o.text) items.push({ text:o.text, options:T({ fontSize:15.5, color:'3A3A34', align:alignStart, breakLine:true }) });
bullets.forEach(t=>items.push({ text:t, options:T({ fontSize:15, color:'3A3A34', align:alignStart,
bullet:{ code:'2022', indent:16 }, breakLine:true, paraSpaceAfter:12 }) }));
if (!items.length) return 0.1;
const h = Math.min(0.6 + items.length*(box.tall?0.9:0.65), 5.3);
s.addText(items, T({ x:box.x, y:box.y, w:box.w, h, valign:'top' }));
return h;
}
if (type === 'image'){
const o = blk.options || {};
if (!o.src) return 0.1;
const img = await rImgData(o.src);
if (!img) return 0.1;
const ratio = img.dims ? img.dims.w/img.dims.h : 1.6;
const h = Math.min(3.4, box.w/ratio);
s.addImage({ data:img.data, x:box.x + (box.w - h*ratio)/2, y:box.y, w:h*ratio, h });
return h;
}
return 0.1;
}
function _pptxEmpty(pptx, s, box, T, lang){
s.addShape(pptx.ShapeType.roundRect, { x:box.x, y:box.y, w:box.w, h:0.8,
fill:{color:'FAFAF7'}, line:{ color:'D9D9D4', width:1, dashType:'dash' }, rectRadius:0.09 });
s.addText(rStr('no_data',lang), T({ x:box.x, y:box.y, w:box.w, h:0.8, fontSize:10.5,
color:'8B8B80', align:'center', valign:'middle' }));
return 0.9;
}
/* ============================ Export modal ============================ */
function ReportExportModal({ open, onClose, def, ds, kit }){
const lang = def ? def.lang : 'en';
const [format, setFormat] = useState('pptx');
const [csvZip, setCsvZip] = useState(false);
const [sel, setSel] = useState({});
const [busy, setBusy] = useState(false);
const [progress, setProgress] = useState('');
const [error, setError] = useState('');
useEffect(()=>{
if (open && def) setSel(Object.fromEntries(def.slides.map(s=>[s.id, true])));
setError(''); setProgress('');
}, [open]);
if (!open || !def || !ds) return null;
const warnings = rPreflight(def, ds);
const selIds = def.slides.filter(s=>sel[s.id]).map(s=>s.id);
const allOn = selIds.length === def.slides.length;
const typeIcon = { cover:'file', agenda:'columns', exec_summary:'sparkles', divider:'chev-right',
content:'columns', thank_you:'check' };
const doExport = async ()=>{
setBusy(true); setError('');
try {
if (format==='xlsx') await rExportXLSX(def, ds, { csvZip });
else if (format==='pdf') await rExportPDF(def, ds, kit, selIds, setProgress);
else await rExportPPTX(def, ds, kit, selIds, setProgress);
onClose();
} catch(e){
console.error('[report export]', e);
setError(e.message || 'Export failed');
}
setBusy(false); setProgress('');
};
const fmts = [
{ id:'pptx', label:'PowerPoint (.pptx)', desc:'Native editable slides & charts' },
{ id:'pdf', label:'PDF (16:9)', desc:'Pixel-identical to the preview' },
{ id:'xlsx', label:'Excel (.xlsx)', desc:'Multi-sheet data workbook' },
];
return (
{}:onClose} width={640}>
Export report
{def.name}
{fmts.map(f=>(
))}
{format==='xlsx' && (
)}
{format!=='xlsx' && (
)}
{warnings.length > 0 && (
{warnings.slice(0,3).map((w,i)=>(
{w.msg}
))}
)}
{error &&
{error}
}
{busy ? progress || 'Working…' : rExportFilename(def, ds, format==='xlsx' ? (csvZip?'zip':'xlsx') : format)}
Cancel
{busy ? 'Exporting…' : 'Export'}
);
}
Object.assign(window, {
rExportXLSX, rExportPDF, rExportPPTX, rPreflight, rExportFilename, rLoadLib,
ReportExportModal,
});