Files
foodlinkk-command-center/cockpit/static/js/briefing-charts.js
T
Aissa 62239f0a4e Improve CEO cockpit clarity and Revenue visuals, with Hermes briefing.
Tighten overview KPIs/pipeline height, add selectable Revenue views and a resizable sidebar, and keep supporting Hermes/Wereldexport updates.
2026-07-30 11:54:54 +00:00

675 lines
32 KiB
JavaScript

window.BriefingCharts = (function () {
var charts = {};
var typewriterTimer = null;
var typewriterAbort = null;
var lastDigestSig = '';
function destroyAll() {
Object.keys(charts).forEach(function (k) {
if (charts[k]) { charts[k].destroy(); charts[k] = null; }
});
}
function parseContent(content) {
var parts = (content || '').split(/\n---\n/);
var ai = parts[0] || '';
var summary = '', actions = [], longTerm = [];
var sm = ai.match(/##\s*Samenvatting\s*\n([\s\S]*?)(?=##\s*Actiepunten|##\s*Lange termijn|$)/i);
if (sm) summary = sm[1].trim().replace(/\*\*/g, '');
var am = ai.match(/##\s*Actiepunten[^\n]*\n([\s\S]*?)(?=##\s*Lange termijn|$)/i);
if (am) actions = am[1].split('\n').map(function (l) { return l.replace(/^[-*]\s*/, '').trim(); }).filter(Boolean);
var lm = ai.match(/##\s*Lange termijn[^\n]*\n([\s\S]*)/i);
if (lm) longTerm = lm[1].split('\n').map(function (l) { return l.replace(/^[-*]\s*/, '').trim(); }).filter(Boolean);
if (!summary && ai.trim()) summary = ai.trim().slice(0, 800).replace(/\*\*/g, '');
return { summary: summary, actions: actions, longTerm: longTerm };
}
function typewriter(el, text, speed) {
if (!el) return;
el.textContent = text || '';
return;
if (typewriterTimer) clearInterval(typewriterTimer);
el.textContent = '';
if (!text) { el.textContent = 'Klik «Genereer dagrapport» voor je persoonlijke Herman briefing.'; return; }
var i = 0;
typewriterTimer = setInterval(function () {
if (i < text.length) { el.textContent += text.charAt(i); i++; }
else clearInterval(typewriterTimer);
}, speed || 8);
}
function countUp(el, end, prefix, suffix) {
if (!el) return;
prefix = prefix || ''; suffix = suffix || '';
var start = 0, dur = 600, t0 = performance.now();
function step(t) {
var p = Math.min(1, (t - t0) / dur);
var v = Math.round(start + (end - start) * p);
el.textContent = prefix + v.toLocaleString('nl-NL') + suffix;
if (p < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
function chartColors() {
return {
gold: 'rgba(252, 211, 77, 0.9)', cyan: 'rgba(56, 189, 248, 0.9)',
green: 'rgba(74, 222, 128, 0.9)', red: 'rgba(251, 113, 133, 0.9)',
gray: 'rgba(159, 176, 196, 0.85)', grid: 'rgba(159, 176, 196, 0.15)', text: '#c8d4e0',
};
}
function renderPipeline(canvas, stats) {
// Compact HTML bars — geen vaste canvas-hoogte / geen Chart.js
if (!canvas) return;
if (charts.pipeline) {
try { charts.pipeline.destroy(); } catch (e) {}
charts.pipeline = null;
}
var host = canvas.parentElement;
if (!host) return;
canvas.style.display = 'none';
canvas.setAttribute('hidden', '');
var panel = host.querySelector('.hm-pipeline-bars');
if (!panel) {
panel = document.createElement('div');
panel.className = 'hm-pipeline-bars';
host.appendChild(panel);
}
var rev = (stats.revenue && stats.revenue.stats) || {};
var crmRows = stats.pipeline_by_stage || stats.deal_stages || [];
var crmTotal = Math.round(stats.pipeline_eur || 0);
var rows = [];
// Prefer revenue top deals when CRM pipeline is empty/useless
var projects = (stats.revenue && (stats.revenue.projects || stats.revenue.top_projects)) || [];
if (!projects.length && stats.revenue && stats.revenue.by_brand) {
var bb = stats.revenue.by_brand;
projects = [].concat(bb.cucina || [], bb.foodlinkk || [], bb.shared || [], bb.other || []);
}
if ((!crmRows || !crmRows.length || crmTotal === 0) && projects.length) {
rows = projects
.filter(function (p) { return Number(p.margin_month || 0) > 0; })
.sort(function (a, b) { return Number(b.margin_month || 0) - Number(a.margin_month || 0); })
.slice(0, 6)
.map(function (p) {
return { name: p.name || 'Deal', count: 1, value: Number(p.margin_month || 0) };
});
var sub = document.getElementById('ceo-pipeline-sub');
if (sub) sub.textContent = 'top marge/mnd · Revenue';
} else if (crmRows && crmRows.length) {
rows = crmRows.map(function (r) {
return {
name: r.stage || r.name || 'Stage',
count: r.count || r.deals || 0,
value: Number(r.value || r.eur || r.pipeline_eur || 0),
};
});
var sub2 = document.getElementById('ceo-pipeline-sub');
if (sub2) sub2.textContent = 'CRM stages';
}
if (!rows.length) {
var dealCount = stats.deals || rev.active_deals || 0;
panel.innerHTML =
'<div class="hm-pipeline-head"><strong>' + dealCount + ' deals</strong><span>geen stage-waarde</span></div>' +
'<p class="hm-pipeline-foot"><a href="/revenue-cockpit">Revenue Cockpit →</a> · <a href="/deals">CRM deals →</a></p>';
return;
}
var maxV = Math.max.apply(null, rows.map(function (r) { return r.value || 0 }).concat([1]));
var totalEur = rows.reduce(function (s, r) { return s + (r.value || 0); }, 0);
var totalCnt = rows.reduce(function (s, r) { return s + (r.count || 0); }, 0);
panel.innerHTML =
'<div class="hm-pipeline-head">' +
'<strong>€' + Math.round(totalEur).toLocaleString('nl-NL') + '</strong>' +
'<span>' + (totalCnt || rows.length) + ' regels</span>' +
'</div>' +
'<div class="hm-pipeline-list">' + rows.map(function (r) {
var pct = Math.max(4, Math.round(((r.value || 0) / maxV) * 100));
return '<div class="hm-pipeline-row">' +
'<div class="hm-pipeline-meta"><span class="hm-pipeline-stage">' + String(r.name).replace(/</g, '') + '</span>' +
'<span class="hm-pipeline-vals">€' + Math.round(r.value || 0).toLocaleString('nl-NL') + '</span></div>' +
'<div class="hm-pipeline-track"><div class="hm-pipeline-fill" style="width:' + pct + '%"></div></div>' +
'</div>';
}).join('') + '</div>' +
'<p class="hm-pipeline-foot"><a href="/revenue-cockpit">Revenue Cockpit →</a> · <a href="/deals">CRM deals →</a></p>';
}
function renderSentiment(canvas, stats) {
if (!canvas || typeof Chart === 'undefined') return;
var files = stats.nas_files || [], counts = { positive: 0, neutral: 0, negative: 0 };
files.forEach(function (f) { var s = (f.sentiment_label || 'neutral').toLowerCase(); if (counts[s] !== undefined) counts[s]++; });
if (!files.length) counts.neutral = 1;
var c = chartColors();
if (charts.sentiment) charts.sentiment.destroy();
charts.sentiment = new Chart(canvas, {
type: 'doughnut',
data: { labels: ['Positief', 'Neutraal', 'Negatief'], datasets: [{ data: [counts.positive, counts.neutral, counts.negative], backgroundColor: [c.green, c.gray, c.red], borderWidth: 0 }] },
options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { color: c.text } }, title: { display: true, text: 'NAS sentiment', color: c.text } } },
});
}
function renderWords(canvas, stats) {
if (!canvas || typeof Chart === 'undefined') return;
var rows = (stats.top_words || []).slice(0, 8), c = chartColors();
if (charts.words) charts.words.destroy();
charts.words = new Chart(canvas, {
type: 'bar',
data: { labels: rows.map(function (r) { return r.lemma; }), datasets: [{ data: rows.map(function (r) { return Number(r.total) || 0; }), backgroundColor: c.cyan, borderRadius: 6 }] },
options: { indexAxis: 'y', responsive: true, plugins: { legend: { display: false }, title: { display: true, text: 'Top woorden NAS', color: c.text } },
scales: { x: { ticks: { color: c.text }, grid: { color: c.grid } }, y: { ticks: { color: c.text }, grid: { display: false } } } },
});
}
function renderAgents(canvas, stats) {
if (!canvas || typeof Chart === 'undefined') return;
var map = {};
(stats.recent_events || []).forEach(function (e) { var a = e.agent_name || 'other'; map[a] = (map[a] || 0) + 1; });
var labels = Object.keys(map), c = chartColors();
if (charts.agents) charts.agents.destroy();
charts.agents = new Chart(canvas, {
type: 'polarArea',
data: { labels: labels, datasets: [{ data: labels.map(function (k) { return map[k]; }), backgroundColor: [c.gold, c.cyan, c.green, c.red, c.gray] }] },
options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { color: c.text } }, title: { display: true, text: 'Agent activiteit', color: c.text } },
scales: { r: { ticks: { display: false }, grid: { color: c.grid } } } },
});
}
function eqBars() {
return '<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>';
}
function sparklineSvg(values, trend) {
if (!values || !values.length) return '';
var min = Math.min.apply(null, values), max = Math.max.apply(null, values);
var range = max - min || 1;
var pts = values.map(function (v, i) {
var x = (i / (values.length - 1 || 1)) * 100;
var y = 100 - ((v - min) / range) * 80 - 10;
return x.toFixed(1) + ',' + y.toFixed(1);
}).join(' ');
var color = trend === 'down' ? '#fb7185' : '#4ade80';
return '<svg class="hm-spark" viewBox="0 0 100 100" preserveAspectRatio="none"><polyline fill="none" stroke="' + color + '" stroke-width="3" points="' + pts + '"/></svg>';
}
function stockEqBars(trend) {
var cls = trend === 'down' ? ' hm-eq-down' : '';
return '<div class="hm-eq' + cls + '"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>';
}
function renderStocksMini(container, stats) {
if (!container) return;
var stocks = (stats.market_stocks || []).slice(0, 4);
var summary = stats.market_summary || {};
if (!stocks.length) {
container.innerHTML = '<a href="/beurs" class="btn btn-sm">Beurs openen →</a>';
return;
}
container.innerHTML = stocks.map(function (s) {
var pct = Number(s.change_pct || 0);
return '<a href="/beurs" class="beurs-mini-chip ' + (pct >= 0 ? 'up' : 'down') + '">' +
'<strong>' + (s.symbol || s.name) + '</strong> ' +
(pct >= 0 ? '+' : '') + pct.toFixed(2) + '%</a>';
}).join('') + '<small style="display:block;margin-top:0.35rem;color:#64748b">Gem. ' +
(summary.avg_change_pct || 0) + '% · <a href="/beurs">alle koersen →</a></small>';
}
function renderStocks(container, stats) {
if (!container) return;
var stocks = stats.market_stocks || [];
var summary = stats.market_summary || {};
var meta = document.getElementById('market-updated-at');
if (meta) {
var avg = summary.avg_change_pct;
meta.textContent = stocks.length ? ('Gem. ' + (avg >= 0 ? '+' : '') + Number(avg || 0).toFixed(2) + '% · ' + (summary.quote_count || stocks.length) + ' quotes') : 'Beurs data laden…';
}
if (!stocks.length) {
container.innerHTML = '<p class="empty-state">Beursdata tijdelijk niet beschikbaar — probeer Live data opnieuw.</p>';
return;
}
container.innerHTML = stocks.map(function (s) {
var pct = Number(s.change_pct || 0);
var up = pct >= 0;
var price = s.price != null ? Number(s.price).toFixed(2) : '—';
var cur = s.currency || 'EUR';
return '<div class="hm-stock-card hm-stock-' + (s.trend || (up ? 'up' : 'down')) + '">' +
'<div class="hm-stock-head"><div><strong>' + (s.symbol || '') + '</strong><small>' + (s.name || '') + '</small></div>' +
'<span class="hm-stock-pct ' + (up ? 'up' : 'down') + '">' + (up ? '▲' : '▼') + ' ' + Math.abs(pct).toFixed(2) + '%</span></div>' +
'<div class="hm-stock-price">' + price + ' <small>' + cur + '</small></div>' +
'<div class="hm-stock-chain">' + (s.chain || s.market || '') + '</div>' +
sparklineSvg(s.sparkline || [], s.trend) + stockEqBars(s.trend) + '</div>';
}).join('');
}
function rssTeaserHtml(stats) {
var n = stats.rss_items || (stats.rss_live || []).length || 0;
return '<p class="hm-rss-teaser">' + n + ' RSS artikelen beschikbaar. ' +
'<a href="/?tab=rss">Open RSS tab →</a></p>';
}
function renderFoodHighlights(container, stats) {
if (!container) return;
container.innerHTML = rssTeaserHtml(stats);
}
function renderRegulations(container, stats) {
if (!container) return;
var items = stats.regulation_highlights || [];
if (!items.length) {
container.innerHTML = '<p class="empty-state">Regelgeving feeds — klik RSS refresh in Retail 360</p>';
return;
}
container.innerHTML = items.map(function (r) {
var cat = r.category === 'cbs' ? 'CBS' : 'REG';
return '<div class="hm-highlight-item"><span class="hm-cat-badge hm-cat-' + (r.category || 'reg') + '">' + cat + '</span>' +
'<a href="' + (r.link || '#') + '" target="_blank" rel="noopener"><strong>' + (r.title || '') + '</strong></a>' +
'<small>' + (r.feed_name || '') + '</small></div>';
}).join('');
}
function renderTrends(canvas, stats) {
if (!canvas || typeof Chart === 'undefined') return;
var rows = stats.market_trends || [];
var c = chartColors();
if (charts.trends) charts.trends.destroy();
if (!rows.length) return;
charts.trends = new Chart(canvas, {
type: 'bar',
data: {
labels: rows.map(function (r) { return (r.trend_name || '?').slice(0, 18); }),
datasets: [{
label: 'Kans %',
data: rows.map(function (r) { return Math.round(Number(r.opportunity_score || 0) * 100); }),
backgroundColor: [c.green, c.cyan, c.gold, c.purple || '#a855f7'],
borderRadius: 6,
}],
},
options: {
responsive: true,
plugins: { legend: { display: false }, title: { display: true, text: 'Markt trend scores', color: c.text } },
scales: {
y: { max: 100, ticks: { color: c.text, callback: function (v) { return v + '%'; } }, grid: { color: c.grid } },
x: { ticks: { color: c.text, maxRotation: 45 }, grid: { display: false } },
},
},
});
}
function kpiValue(id, stats) {
var summary = stats.market_summary || {};
var best = summary.best_performer || {};
var trendCount = (stats.trending_food || stats.food_market_highlights || []).length;
switch (id) {
case 'pipeline': return '€' + Math.round(stats.pipeline_eur || 0).toLocaleString('nl-NL');
case 'margin_month': {
var rev = (stats.revenue && stats.revenue.stats) || {};
return '€' + Math.round(rev.total_margin_month || 0).toLocaleString('nl-NL');
}
case 'margin_year': {
var revY = (stats.revenue && stats.revenue.stats) || {};
return '€' + Math.round(revY.total_margin_year || 0).toLocaleString('nl-NL');
}
case 'open_steps': {
var revO = (stats.revenue && stats.revenue.stats) || {};
return String(revO.open_objectives || 0);
}
case 'revenue_deals': {
var revD = (stats.revenue && stats.revenue.stats) || {};
return String(revD.active_deals || revD.active_projects || 0);
}
case 'clients_active': return (stats.clients_active || 0) + ' / ' + (stats.clients_total || stats.clients || 0);
case 'clients_total': return String(stats.clients_total || stats.clients || 0);
case 'crm_partnerships': return String(stats.crm_partnerships || 0);
case 'supermarkets': return (stats.supermarkets || 0).toLocaleString('nl-NL');
case 'wholesalers': return String(stats.wholesalers || 0);
case 'trending_food': return String(trendCount || 0);
case 'rss_items': return String(stats.rss_items || (stats.rss_live || []).length || 0);
case 'rss_bookmarks': return String((stats.rss_bookmarks || []).length || stats.rss_bookmarks_count || 0);
case 'pending_approvals': return String(stats.pending_approvals || 0);
case 'deals': return String(stats.deals || 0);
case 'products': return String(stats.products || 0);
case 'suppliers': return String(stats.suppliers || 0);
case 'nas_docs': return String(stats.nas_docs || 0);
case 'promo_campaigns': return String(stats.promo_campaigns || 0);
case 'market_best': return best.symbol ? (best.symbol + ' ' + Number(best.change_pct || 0).toFixed(1) + '%') : '—';
default: return '—';
}
}
function kpiPct(id, stats) {
var summary = stats.market_summary || {};
var trendCount = (stats.trending_food || stats.food_market_highlights || []).length;
switch (id) {
case 'pipeline': return '72%';
case 'margin_month': return '90%';
case 'margin_year': return '85%';
case 'open_steps': {
var ro = (stats.revenue && stats.revenue.stats) || {};
return Math.min(95, Math.round((ro.open_objectives || 0) / 50 * 100)) + '%';
}
case 'revenue_deals': return '80%';
case 'clients_active':
return Math.min(95, Math.round(((stats.clients_active || 0) / Math.max(stats.clients_total || 1, 1)) * 100)) + '%';
case 'crm_partnerships': return '45%';
case 'supermarkets': return '88%';
case 'trending_food': return Math.min(95, trendCount * 10) + '%';
case 'rss_items': return Math.min(95, ((stats.rss_items || 0) / 10)) + '%';
case 'pending_approvals': return '30%';
case 'market_best': return Math.min(95, Math.abs(Number(summary.avg_change_pct || 0)) * 10) + '%';
default: return '50%';
}
}
function renderKpis(container, stats, selectedKpis) {
if (!container) return;
var meta = (window.DashboardLayout && window.DashboardLayout.KPI_META) || {};
var order = selectedKpis || window._dashboardKpis ||
(window.DashboardLayout && window.DashboardLayout.CEO_KPIS) ||
(window.DashboardLayout && window.DashboardLayout.DEFAULT_KPIS) ||
['margin_month', 'margin_year', 'open_steps', 'revenue_deals', 'crm_partnerships', 'supermarkets'];
if (window.DashboardLayout && window.DashboardLayout.ceoKpis) {
order = window.DashboardLayout.ceoKpis({ dashboard_kpis: order });
}
order = order.slice(0, 6);
var items = order.map(function (id) {
var m = meta[id] || { label: id, sub: '', icon: '▪', color: '#94a3b8' };
return {
id: id, label: m.label, sub: m.sub, icon: m.icon, color: m.color || '#94a3b8',
pct: kpiPct(id, stats), val: kpiValue(id, stats), link: m.link,
};
});
if (!items.length) {
container.innerHTML = '<p class="empty-state">Geen KPI\'s</p>';
return;
}
container.className = 'hm-neo-kpi-row';
container.innerHTML = items.map(function (it) {
var inner = '<div class="hm-neo-kpi"><div class="hm-neo-kpi-top">' +
'<div class="hm-neo-ring" style="--ring-color:' + it.color + ';--ring-pct:' + it.pct + '"><div class="hm-neo-ring-inner">' + it.icon + '</div></div>' +
'<div><div class="hm-neo-kpi-label">' + it.label + '</div><div class="hm-neo-kpi-sub">' + it.sub + '</div></div></div>' +
'<div class="hm-neo-kpi-value">' + it.val + '</div>' + eqBars() + '</div>';
return it.link ? '<a href="' + it.link + '" class="hm-neo-kpi-link">' + inner + '</a>' : inner;
}).join('');
}
function renderRssFeed(container, stats) {
if (!container) return;
var items = stats.rss_live || stats.trending_food || stats.food_market_highlights || [];
var count = stats.rss_items || items.length;
var head = '<div class="hm-rss-head"><span class="hm-live-dot"></span> ' + count + ' items in database' +
' · <button type="button" class="btn btn-sm" id="btn-rss-refresh-dash">↻ RSS ophalen</button>' +
' · <a href="/marketing">Marketing Hub →</a></div>';
if (!items.length) {
container.innerHTML = head + '<p class="empty-state">Geen RSS items — klik <strong>RSS ophalen</strong> of ga naar <a href="/marketing">Marketing Hub</a>.</p>';
var btn = container.querySelector('#btn-rss-refresh-dash');
if (btn) btn.addEventListener('click', function () {
if (window._refreshRssDash) window._refreshRssDash();
});
return;
}
container.innerHTML = head + '<div class="hm-rss-list">' + items.slice(0, 20).map(function (r) {
var when = (r.published_at || '').substring(0, 16).replace('T', ' ');
var cat = (r.category || 'feed').toUpperCase();
return '<a href="' + (r.link || '#') + '" target="_blank" rel="noopener" class="hm-rss-item">' +
'<span class="hm-rss-cat">' + cat + '</span>' +
'<span class="hm-rss-body"><strong>' + (r.title || '') + '</strong>' +
'<small>' + (r.feed_name || 'RSS') + (when ? ' · ' + when : '') + '</small></span></a>';
}).join('') + '</div>';
var refreshBtn = container.querySelector('#btn-rss-refresh-dash');
if (refreshBtn) refreshBtn.addEventListener('click', function () {
if (window._refreshRssDash) window._refreshRssDash();
});
}
function renderRetail(container, stats) {
if (!container) return;
container.innerHTML = rssTeaserHtml(stats);
}
function renderMilestones(container, stats) {
if (!container) return;
var ms = stats.milestones_pending || [];
if (!ms.length) {
container.innerHTML = '<p class="empty-state">Nog geen milestones — voeg toe via <a href="/retail">Retail 360 → Sales tab</a></p>';
return;
}
container.innerHTML = ms.map(function (m) {
return '<div class="milestone-item"><span class="milestone-dot"></span><div><strong>' + (m.title || '') + '</strong><br><small>' + (m.chain || '') + ' ' + (m.store_name || '') + '</small></div></div>';
}).join('');
}
function renderExecutiveSummary(container, stats) {
if (!container) return;
var rev = (stats.revenue && stats.revenue.stats) || {};
var by = (stats.revenue && stats.revenue.by_brand) || {};
var cu = by.cucina || [];
var fl = by.foodlinkk || [];
var clients = stats.recent_clients || [];
var opp = stats.top_opportunities || [];
var deals = stats.deals || 0;
var pipeline = Math.round(stats.pipeline_eur || 0);
var marginM = Math.round(rev.total_margin_month || 0);
var openObj = rev.open_objectives || 0;
// Narrative context
var narrative = [];
if (marginM > 0) narrative.push('Cucina + Foodlinkk draaien €' + marginM.toLocaleString('nl-NL') + '/mnd marge over ' + (rev.active_projects || 0) + ' projecten.');
if (openObj) narrative.push(openObj + ' open stappen vragen opvolging.');
if ((stats.crm_partnerships || 0) > 0) narrative.push(stats.crm_partnerships + ' retail-partnerships · ' + (stats.supermarkets || 0).toLocaleString('nl-NL') + ' filialen bereikbaar.');
if (pipeline === 0 && deals > 0) narrative.push(deals + ' CRM-deals staan klaar — pipeline-waarde nog €0 (nog geen getekende budgetten).');
if (!narrative.length) narrative.push('Live business data laden…');
var topDeals = cu.filter(function (p) { return p.margin_month; }).slice(0, 3);
var html = '';
html += '<div class="hm-context-banner"><p>' + escHtml(narrative.join(' ')) + '</p></div>';
html += '<div class="hm-exec-grid hm-exec-grid-rich">' +
[
{ icon: '💶', label: 'Marge / mnd', val: '€' + marginM.toLocaleString('nl-NL'), sub: (rev.active_projects || 0) + ' projecten', link: '/revenue-cockpit' },
{ icon: '💰', label: 'CRM pipeline', val: '€' + pipeline.toLocaleString('nl-NL'), sub: deals + ' deals', link: '/deals' },
{ icon: '🤝', label: 'Klanten', val: String(stats.clients_total || stats.clients || 0), sub: (stats.clients_active || 0) + ' actief', link: '/clients' },
{ icon: '🏪', label: 'Partnerships', val: String(stats.crm_partnerships || 0), sub: (stats.supermarkets || 0).toLocaleString('nl-NL') + ' filialen', link: '/retail' },
].map(function (it) {
return '<a href="' + it.link + '" class="hm-exec-item hm-exec-rich">' +
'<span class="hm-exec-icon">' + it.icon + '</span>' +
'<div><strong>' + it.label + '</strong><div class="hm-exec-val">' + it.val + '</div>' +
'<div class="hm-exec-sub">' + it.sub + '</div></div></a>';
}).join('') + '</div>';
if (topDeals.length) {
html += '<div class="hm-context-block"><h4>Top Cucina deals</h4><div class="hm-deal-chips">';
topDeals.forEach(function (d) {
html += '<a class="hm-deal-chip" href="/revenue-cockpit"><strong>' + escHtml(d.name) + '</strong>' +
'<span>€' + Math.round(d.margin_month || 0).toLocaleString('nl-NL') + '/m</span>' +
(d.open_objectives ? '<em>' + d.open_objectives + ' stappen</em>' : '') + '</a>';
});
html += '</div></div>';
}
if (clients.length) {
html += '<div class="hm-context-block"><h4>Recente klanten</h4><div class="hm-deal-chips">';
clients.slice(0, 4).forEach(function (c) {
html += '<a class="hm-deal-chip hm-deal-chip-client" href="/clients"><strong>' + escHtml(c.name || '') + '</strong>' +
'<span>' + escHtml(c.stage || '') + '</span></a>';
});
html += '</div></div>';
}
if (opp.length) {
html += '<div class="hm-context-block"><h4>Halal-kansen</h4><ul class="hm-context-list">';
opp.slice(0, 3).forEach(function (o) {
var score = Math.round(o.halal_opportunity_score || o.score || 0);
html += '<li><strong>' + escHtml(o.chain || o.name || '') + '</strong> · ' +
escHtml(o.city || '') + ' <span class="hm-score">score ' + score + '</span></li>';
});
html += '</ul></div>';
}
html += '<p class="hm-exec-footer">' +
'<a href="/revenue-cockpit">Revenue Cockpit</a> · <a href="/retail">Retail 360</a> · ' +
'<a href="/clients">Clients</a> · <a href="/deals">Deals</a></p>';
container.innerHTML = html;
}
function renderText(root, content, createdAt) {
if (!root) return;
var meta = document.getElementById('briefing-meta');
if (meta && createdAt) meta.textContent = 'Laatst bijgewerkt: ' + String(createdAt).substring(0, 19).replace('T', ' ');
var sumEl = document.getElementById('briefing-summary-text');
if (!sumEl || !content) return;
sumEl.dataset.aiMemo = '1';
// Markdown-achtige tekst direct tonen (geen typewriter)
var html = escHtml(content)
.replace(/^### (.+)$/gm, '<h5>$1</h5>')
.replace(/^## (.+)$/gm, '<h4>$1</h4>')
.replace(/^# (.+)$/gm, '<h3>$1</h3>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/^\- (.+)$/gm, '<li>$1</li>')
.replace(/(<li>.*<\/li>\n?)+/g, function (m) { return '<ul class="live-digest-list">' + m + '</ul>'; })
.replace(/\n\n/g, '</p><p>')
.replace(/\n/g, '<br>');
sumEl.innerHTML = '<div class="hm-ceo-memo-md"><p>' + html + '</p></div>';
}
function escHtml(s) {
return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function digestSignature(digest) {
if (!digest) return '';
return JSON.stringify({
sections: digest.sections || [],
actions: digest.actions || [],
long_term: digest.long_term || [],
});
}
function fillDigestInstant(container, digest) {
var sections = (digest && digest.sections) || [];
if (!sections.length) {
container.innerHTML = '<p class="muted">Live samenvatting laden…</p>';
return;
}
container.innerHTML = sections.map(function (sec) {
var lines = (sec.lines || []).filter(Boolean);
var lis = lines.length
? lines.map(function (l) { return '<li>' + escHtml(l) + '</li>'; }).join('')
: '<li class="muted">Geen data</li>';
return '<div class="live-digest-section brand-' + escHtml(sec.brand || 'shared') + '">' +
'<h5>' + escHtml((sec.icon || '') + ' ' + (sec.title || '')) + '</h5>' +
'<ul class="live-digest-list">' + lis + '</ul></div>';
}).join('');
}
function typewriterLiveDigest(container, digest, opts) {
// Typewriter uit — altijd direct tonen
fillDigestInstant(container, digest);
}
function typewriterListItems(listEl, items, opts) {
fillListInstant(listEl, items || []);
}
function fillListInstant(listEl, items) {
if (!listEl) return;
var rows = (items && items.length) ? items : ['Geen urgente acties — check agents & retail'];
listEl.innerHTML = rows.map(function (t) { return '<li>' + escHtml(t) + '</li>'; }).join('');
}
function fillSimpleList(el, items, emptyMsg) {
if (!el) return;
var rows = (items || []).filter(Boolean);
if (!rows.length) {
el.innerHTML = '<li class="muted">' + escHtml(emptyMsg || 'Geen items') + '</li>';
return;
}
el.innerHTML = rows.map(function (x) {
if (typeof x === 'string') return '<li><span class="hm-ceo-bullet">→</span> ' + escHtml(x) + '</li>';
var title = escHtml(x.title || x);
var link = x.link ? ('<a href="' + escHtml(x.link) + '" target="_blank" rel="noopener">' + title + '</a>') : title;
var src = x.source ? ('<small>' + escHtml(x.source) + '</small>') : '';
return '<li>' + link + src + '</li>';
}).join('');
}
function fillBrandCard(el, lines) {
if (!el) return;
var list = el.querySelector('.hm-ceo-brand-list') || el;
var rows = (lines || []).filter(Boolean);
list.innerHTML = rows.length
? rows.map(function (l) { return '<li>' + escHtml(l) + '</li>'; }).join('')
: '<li class="muted">Geen data</li>';
}
function renderCeoDigest(digest) {
if (!digest) return;
var b2b = digest.b2b, b2c = digest.b2c, focus = digest.focus, trends = digest.trends, news = digest.news;
if (!b2b || !b2c) {
(digest.sections || []).forEach(function (s) {
if (s.brand === 'foodlinkk') b2b = s.lines;
if (s.brand === 'cucina') b2c = s.lines;
if (s.brand === 'platform' || (s.title || '').indexOf('Focus') >= 0) focus = s.lines;
});
}
fillBrandCard(document.getElementById('ceo-b2b'), b2b);
fillBrandCard(document.getElementById('ceo-b2c'), b2c);
fillSimpleList(document.getElementById('ceo-focus-list'), focus || digest.actions, 'Geen urgente acties');
fillSimpleList(document.getElementById('ceo-trends-list'), trends, 'Geen trends');
fillSimpleList(document.getElementById('ceo-news-list'), news, 'Geen nieuws — open RSS tab');
}
function renderLiveSummary(digest, pollRefresh) {
if (!digest) return;
var meta = document.getElementById('briefing-meta');
lastDigestSig = digestSignature(digest);
if (meta) {
var ts = String(digest.updated_at || '').substring(0, 19).replace('T', ' ');
meta.textContent = 'Live · ververst ' + ts + ' UTC';
}
if (document.querySelector('.hm-ceo-pro')) {
renderCeoDigest(digest);
return;
}
var sumEl = document.getElementById('briefing-summary-text');
if (sumEl) fillDigestInstant(sumEl, digest);
}
function renderLive(root, stats, vizMode, kpiSelection, liveDigest, opts) {
if (!root || !stats) return;
opts = opts || {};
var mode = vizMode || window._dashboardVizMode || 'neo-bars';
var kpis = kpiSelection || window._dashboardKpis;
if (liveDigest) renderLiveSummary(liveDigest, !!opts.pollRefresh);
else if (stats.live_digest) renderLiveSummary(stats.live_digest, !!opts.pollRefresh);
renderKpis(document.getElementById('briefing-kpis'), stats, kpis);
renderExecutiveSummary(document.getElementById('briefing-executive-summary'), stats);
// activity log removed from CEO overview
renderPipeline(document.getElementById('chart-pipeline'), stats);
// CEO overview: geen sentiment/words/agents-charts (lege hoogte + troep)
if (!document.querySelector('.hm-ceo-clean')) {
renderWords(document.getElementById('chart-words'), stats);
if (window.VizEngine) {
VizEngine.renderSentiment(mode, document.getElementById('chart-sentiment'), document.getElementById('viz-sentiment-alt'), stats);
VizEngine.renderAgents(mode, document.getElementById('chart-agents'), document.getElementById('viz-agents-alt'), stats);
} else {
renderSentiment(document.getElementById('chart-sentiment'), stats);
renderAgents(document.getElementById('chart-agents'), stats);
}
}
}
function render(root, stats, content, createdAt, vizMode) {
renderLive(root, stats, vizMode);
renderText(root, content, createdAt);
}
return {
render: render, renderLive: renderLive, renderText: renderText, renderLiveSummary: renderLiveSummary,
renderExecutiveSummary: renderExecutiveSummary, renderRssFeed: renderRssFeed,
renderKpis: renderKpis, destroyAll: destroyAll,
};
})();