Files
Aissa 8712f503b1 Fix sidebar scrollbar between nav and content.
Remove independent sidebar scroll so the page scrolls as one unit; add dark themed scrollbars and bump CSS cache versions.
2026-07-19 18:31:43 +00:00

584 lines
30 KiB
HTML

{% extends "base.html" %}
{% block extra_head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<link rel="stylesheet" href="/static/css/hermes.css" />
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=23" />
{% endblock %}
{% block content %}
<div class="herman-shell hm-neo">
<div class="herman-hero hm-hero-pro">
<div class="hm-hero-top">
<div>
<p class="hm-hero-eyebrow">Foodlinkk Command Center</p>
<h1>CEO Dashboard <span class="live-badge hm-live-badge">Live</span></h1>
<p class="hero-sub">Cucina B2C · retail · pipeline · Herman briefing</p>
</div>
<div class="hm-hero-status" id="hero-live-status">
<span class="hm-live-dot"></span>
<span id="briefing-meta-hero">Live data laden…</span>
</div>
</div>
<div class="hm-hero-links beurs-mini-row">
<a href="/clients" class="beurs-mini-chip">👥 Klanten</a>
<a href="/deals" class="beurs-mini-chip">💼 Deals</a>
<a href="/packaging" class="beurs-mini-chip">📐 Packaging</a>
<a href="/marketing?tab=publish" class="beurs-mini-chip">🚀 Automatisering</a>
<a href="/marketing?tab=reclame" class="beurs-mini-chip">📁 Reclame folders</a>
<a href="/projects" class="beurs-mini-chip">📁 Projecten</a>
<a href="/retail" class="beurs-mini-chip">🛒 Retail</a>
<a href="/analytics" class="beurs-mini-chip">📊 Analytics</a>
</div>
</div>
<section class="panel herman-briefing" x-data="dashboardBriefing()" x-init="init()">
<nav class="herman-dash-tabs" aria-label="Herman weergaven">
<button type="button" class="herman-dash-tab" :class="{ active: hermanTab === 'overview' }" @click="setHermanTab('overview')">
<span aria-hidden="true">📊</span> Overzicht
</button>
<button type="button" class="herman-dash-tab" :class="{ active: hermanTab === 'rss' }" @click="setHermanTab('rss')">
<span aria-hidden="true">📰</span> RSS Feeds
<span class="herman-dash-tab-badge" x-show="rssTotal > 0" x-text="rssTotal"></span>
</button>
</nav>
<header class="herman-briefing-header hm-command-bar">
<div>
<h2>Dagelijkse briefing</h2>
<div class="briefing-meta" id="briefing-meta"></div>
</div>
<div class="herman-briefing-actions">
<button type="button" class="btn btn-sm" :class="editLayout ? 'btn-pulse' : ''" @click="toggleEditLayout()" x-text="editLayout ? '✓ Klaar' : '⚙ Dashboard instellen'"></button>
<button type="button" class="btn btn-pulse" @click="refreshLive()" :disabled="loading">↻ Live data</button>
<button type="button" class="btn btn-pulse-green" @click="generate()" :disabled="loading">✨ Genereer dagrapport</button>
<a href="/clients" class="btn btn-pulse-green">CRM Clients</a>
<a href="/marketing?tab=reclame" class="btn btn-pulse-purple">Reclame folders</a>
<a href="/analytics" class="btn">Analytics</a>
</div>
</header>
<div id="hm-live-bar" class="hm-live-dock" x-show="loading || liveLog.length" x-cloak :class="loading ? 'hm-live-dock-active' : 'hm-live-dock-done'">
<div class="hm-live-dock-head">
<span class="hm-live-dot"></span>
<strong class="hm-live-title" x-text="loading ? loadingMsg : 'Dagrapport voltooid'"></strong>
<span class="hm-live-progress" x-show="loading" x-text="liveProgress"></span>
<button type="button" class="btn btn-sm hm-live-close" x-show="!loading && liveLog.length" @click="liveLog = []" title="Sluiten"></button>
</div>
<ol id="hm-live-log" class="hm-live-log hm-live-log-compact">
<template x-for="(entry, idx) in liveLog" :key="idx">
<li :class="'hm-live-entry hm-live-' + (entry.status || 'ok') + (entry.type === 'agent_msg' ? ' hm-live-agent' : '')">
<span class="hm-live-src" x-text="entry.source || entry.agent"></span>
<span class="hm-live-msg" x-text="entry.message"></span>
<small class="hm-live-detail" x-show="entry.detail" x-text="entry.detail"></small>
</li>
</template>
</ol>
</div>
<div id="viz-toolbar" class="viz-toolbar"></div>
<div id="dashboard-customize" class="hm-customize-wrap" x-show="editLayout" x-cloak></div>
<div id="briefing-dashboard" class="hm-dash-layout" :class="editLayout ? 'hm-dash-editing' : ''" x-show="hermanTab === 'overview'">
<section class="hm-dash-section hm-dash-full" data-widget="kpis">
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · CEO KPI's</div>
<h3 class="hm-section-title"><span class="hm-live-dot"></span> CEO KPI's <small class="hm-widget-hint" x-show="editLayout">— kies KPI's hieronder</small></h3>
<div id="briefing-kpis" class="hm-neo-kpi-row"></div>
</section>
<section class="hm-dash-section hm-dash-full" data-widget="executive">
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · Executive</div>
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Alles op een rij</h3>
<div class="hm-chart-panel">
<div class="hm-chart-head"><h4>Executive samenvatting</h4><small>live database</small></div>
<div id="briefing-executive-summary"></div>
</div>
<div id="briefing-clients-mini" class="beurs-mini-row" style="margin-top:0.75rem"></div>
</section>
<section class="hm-dash-section hm-dash-full" data-widget="briefing">
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · Briefing</div>
<h3 class="hm-section-title">Herman briefing</h3>
<div class="briefing-summary-grid">
<div class="briefing-card summary">
<h4>Samenvatting · live</h4>
<div id="briefing-summary-text" class="live-digest-body"></div>
</div>
<div class="briefing-card hm-activity-card">
<div class="hm-card-head">
<h4>Activiteitenlog</h4>
<a href="/agents" class="hm-card-link">Agents →</a>
</div>
<ul id="briefing-activity-log" class="hm-activity-timeline"></ul>
</div>
<div class="briefing-horizon-col">
<div class="horizon-card short">
<h4>⚡ Korte termijn</h4>
<ul id="briefing-actions-list"></ul>
</div>
<div class="horizon-card long">
<h4>🎯 Lange termijn</h4>
<ul id="briefing-longterm-list"></ul>
</div>
</div>
</div>
</section>
<section class="hm-dash-section" data-widget="retail">
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · Retail</div>
<h3 class="hm-section-title">Retail operatie</h3>
<div class="hm-dash-two-col">
<div class="hm-chart-panel">
<div class="hm-chart-head"><h4>Trending food retail</h4><small><a href="/marketing">Marketing Hub →</a></small></div>
<div id="briefing-retail"></div>
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</div>
<div class="hm-chart-panel">
<div class="hm-chart-head"><h4>Sales milestones</h4><small>live DB</small></div>
<div id="briefing-milestones"></div>
</div>
</div>
<div class="hm-chart-panel hm-dash-trends-only" style="margin-top:1rem">
<div class="hm-chart-head"><h4>Markt highlights</h4><small>M&A · retail</small></div>
<div id="briefing-food-highlights" class="hm-highlight-grid"></div>
</div>
</section>
<section class="hm-dash-section hm-dash-full hm-dash-analytics" data-widget="analytics">
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · Analytics</div>
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Data & analytics</h3>
<div class="hm-neo-charts">
<div class="hm-chart-panel hm-analytics-panel">
<div class="hm-chart-head"><h4>Pipeline</h4><small>live deals</small></div>
<div class="hm-chart-wrap"><canvas id="chart-pipeline"></canvas></div>
<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</div>
<div class="hm-chart-panel hm-analytics-panel page-viz-panel viz-mode-chart">
<div class="hm-chart-head"><h4>NAS sentiment</h4><small id="viz-label-sentiment">neo-bars</small></div>
<div class="page-viz-body">
<div class="hm-chart-wrap page-viz-canvas"><canvas id="chart-sentiment"></canvas></div>
<div id="viz-sentiment-alt" class="viz-alt"></div>
</div>
</div>
<div class="hm-chart-panel hm-analytics-panel">
<div class="hm-chart-head"><h4>NAS top woorden</h4><small>corpus</small></div>
<div class="hm-chart-wrap"><canvas id="chart-words"></canvas></div>
<div class="hm-eq hm-eq-cyan"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
</div>
</div>
</section>
<div class="hub-grid hm-dash-feed" data-widget="feed">
<div class="hm-widget-handle" x-show="editLayout">⋮⋮ Sleep · Feed</div>
<section class="panel hm-dash-full">
<h2>Projecten <a href="/projects" class="btn btn-sm" style="float:right">Alle projecten →</a></h2>
<div id="dash-projects-mini"><p class="muted">Laden…</p></div>
</section>
</div>
</div>
<!-- ═══ RSS FEEDS TAB ═══ -->
<div class="herman-rss-hub" x-show="hermanTab === 'rss'" x-cloak>
<header class="herman-rss-head">
<div>
<h2>RSS Feeds</h2>
<p class="page-tip">Alle live feeds op één plek — filter op categorie of zoek op titel</p>
</div>
<div class="herman-rss-actions">
<button type="button" class="btn btn-sm btn-pulse" @click="refreshRss(true)" :disabled="rssLoading">↻ RSS ophalen</button>
<a class="btn btn-sm" href="/marketing?tab=live">Marketing Hub</a>
<a class="btn btn-sm" href="/marketing?tab=bookmarks">★ Bookmarks</a>
</div>
</header>
<div class="herman-rss-filters">
<input type="search" class="form-input" placeholder="Zoek in titels…" x-model="rssSearch" />
<select class="form-input" x-model="rssCategory" @change="loadRssTab()">
<option value="">Alle categorieën</option>
<option value="kant-en-klaar">Kant-en-klaar</option>
<option value="supermarkt">Supermarkt</option>
<option value="food">Food</option>
<option value="markt">Markt &amp; M&amp;A</option>
<option value="regelgeving">Regelgeving</option>
<option value="cbs">CBS</option>
</select>
<span class="herman-rss-count" x-text="filteredRss().length + ' van ' + rssTotal + ' items'"></span>
</div>
<div class="herman-rss-grid">
<section class="herman-rss-col herman-rss-col-main">
<h3 class="hm-section-title"><span class="hm-live-dot"></span> Live feed</h3>
<div class="herman-rss-list">
<template x-for="r in filteredRss()" :key="r.id || (r.link + r.title)">
<article class="feed-card herman-rss-card">
<div class="source" x-text="(r.feed_name || 'RSS') + ' · ' + (r.category || 'feed')"></div>
<a :href="r.link" target="_blank" rel="noopener"><strong x-text="r.title"></strong></a>
<p class="muted" x-show="r.description" x-text="(r.description || '').slice(0, 160) + ((r.description || '').length > 160 ? '…' : '')"></p>
<div class="feed-actions">
<small class="muted" x-text="(r.published_at || '').substring(0, 16).replace('T', ' ')"></small>
<a class="btn-source" :href="r.feed_url || r.link" target="_blank" rel="noopener">Bron feed →</a>
</div>
</article>
</template>
<p class="empty-state" x-show="!rssLoading && !filteredRss().length">Geen items — klik <strong>RSS ophalen</strong> of pas filters aan.</p>
<p class="empty-state" x-show="rssLoading">Feeds laden…</p>
</div>
</section>
<aside class="herman-rss-col herman-rss-col-side">
<section class="hm-chart-panel" x-show="(rssRegulations.regelgeving || []).length">
<h4>Regelgeving</h4>
<template x-for="r in (rssRegulations.regelgeving || []).slice(0, 12)" :key="'reg'+r.id">
<div class="herman-rss-side-item">
<a :href="r.link" target="_blank" rel="noopener" x-text="r.title"></a>
<small x-text="r.feed_name"></small>
</div>
</template>
</section>
<section class="hm-chart-panel" x-show="(rssRegulations.cbs || []).length">
<h4>CBS &amp; data</h4>
<template x-for="r in (rssRegulations.cbs || []).slice(0, 8)" :key="'cbs'+r.id">
<div class="herman-rss-side-item">
<a :href="r.link" target="_blank" rel="noopener" x-text="r.title"></a>
<small x-text="r.feed_name"></small>
</div>
</template>
</section>
<section class="hm-chart-panel" x-show="rssBookmarks.length">
<h4>★ Opgeslagen</h4>
<template x-for="b in rssBookmarks.slice(0, 10)" :key="'bm'+b.id">
<div class="herman-rss-side-item">
<a :href="b.link" target="_blank" rel="noopener" x-text="b.title"></a>
<small x-text="b.feed_name"></small>
</div>
</template>
</section>
</aside>
</div>
</div>
</section>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/viz-engine.js?v=3"></script>
<script src="/static/js/dashboard-layout.js?v=4"></script>
<script src="/static/js/briefing-charts.js?v=19"></script>
<script>
localStorage.setItem('foodlinkk-persona', 'ceo');
const INITIAL_BRIEFING = {{ briefing_payload | tojson }};
function dashboardBriefing() {
return {
loading: false, loadingMsg: 'Herman werkt…', liveLog: [], liveProgress: '',
editLayout: false,
hermanTab: 'overview',
rssItems: [], rssTotal: 0, rssCategory: '', rssSearch: '', rssLoading: false,
rssRegulations: {}, rssBookmarks: [],
content: INITIAL_BRIEFING.content || '', createdAt: INITIAL_BRIEFING.created_at || '',
liveDigest: INITIAL_BRIEFING.live_digest || null,
_pollStop: null, _wsStop: null, _vizMode: 'neo-bars', _stats: null,
_liveRefreshing: false, _pollTick: 0,
_prefs: null, _setDragMode: null, _saveTimer: null,
applyDashboardPrefs(prefs) {
this._prefs = prefs;
window._dashboardKpis = prefs.dashboard_kpis || DashboardLayout.DEFAULT_KPIS;
const dash = document.getElementById('briefing-dashboard');
const order = DashboardLayout.visibleLayout(prefs);
DashboardLayout.applyOrder(dash, order);
DashboardLayout.applyVisibility(dash, prefs.dashboard_widgets);
if (this._setDragMode) this._setDragMode(this.editLayout);
if (this._stats) BriefingCharts.renderLive(dash, this._stats, this._vizMode, window._dashboardKpis, this.liveDigest);
},
scheduleSave(patch) {
if (!this._prefs) return;
if (patch.widgets) this._prefs.dashboard_widgets = patch.widgets;
if (patch.kpis) this._prefs.dashboard_kpis = patch.kpis;
if (patch.layout) this._prefs.dashboard_layout = patch.layout;
clearTimeout(this._saveTimer);
this._saveTimer = setTimeout(async () => {
await DashboardLayout.saveDashboardConfig(
this._prefs.dashboard_layout,
this._prefs.dashboard_widgets,
this._prefs.dashboard_kpis
);
this.applyDashboardPrefs(this._prefs);
}, 400);
},
toggleEditLayout() {
this.editLayout = !this.editLayout;
if (this._setDragMode) this._setDragMode(this.editLayout);
},
setHermanTab(tab) {
this.hermanTab = tab;
try {
const url = new URL(location.href);
if (tab === 'rss') url.searchParams.set('tab', 'rss');
else url.searchParams.delete('tab');
history.replaceState({}, '', url);
} catch (e) {}
if (tab === 'rss') this.loadRssTab();
},
filteredRss() {
const q = (this.rssSearch || '').toLowerCase().trim();
if (!q) return this.rssItems;
return this.rssItems.filter(function (r) {
return [r.title, r.feed_name, r.category, r.description].some(function (v) {
return v && String(v).toLowerCase().includes(q);
});
});
},
async loadRssTab() {
this.rssLoading = true;
try {
const params = new URLSearchParams({ limit: '100' });
if (this.rssCategory) params.set('category', this.rssCategory);
const [live, reg, bookmarks] = await Promise.all([
fetch('/api/retail/rss/live?' + params).then(function (x) { return x.json(); }),
fetch('/api/retail/regulations?limit=40').then(function (x) { return x.json(); }).catch(function () { return {}; }),
fetch('/api/retail/rss/bookmarks?limit=50').then(function (x) { return x.json(); }).catch(function () { return { items: [] }; }),
]);
this.rssItems = live.items || [];
this.rssTotal = live.count || this.rssItems.length;
this.rssRegulations = reg || {};
this.rssBookmarks = bookmarks.items || [];
if (this._stats) {
this._stats.rss_live = this.rssItems;
this._stats.rss_items = this.rssTotal;
}
} catch (e) {
Cockpit.toast(e.message || 'RSS laden mislukt', 'error');
}
this.rssLoading = false;
},
async init() {
const qsTab = new URLSearchParams(location.search).get('tab');
if (qsTab === 'rss') this.hermanTab = 'rss';
window._dashboardVizMode = this._vizMode;
const prefs = await DashboardLayout.loadPrefs();
this._prefs = prefs;
this._vizMode = prefs.global_viz_mode || 'neo-bars';
window._dashboardVizMode = this._vizMode;
window._dashboardKpis = prefs.dashboard_kpis || DashboardLayout.DEFAULT_KPIS;
const dash = document.getElementById('briefing-dashboard');
this.applyDashboardPrefs(prefs);
this._setDragMode = DashboardLayout.initDrag(dash, (order) => {
const widgets = this._prefs.dashboard_widgets || {};
const hidden = (this._prefs.dashboard_layout || []).filter(id => widgets[id] === false);
const full = order.concat(hidden.filter(id => order.indexOf(id) < 0));
this._prefs.dashboard_layout = full;
this.scheduleSave({ layout: full });
}, false);
DashboardLayout.buildCustomizePanel(document.getElementById('dashboard-customize'), prefs, (patch) => {
this.scheduleSave(patch);
if (patch.widgets) this.applyDashboardPrefs(this._prefs);
if (patch.kpis && this._stats) BriefingCharts.renderKpis(document.getElementById('briefing-kpis'), this._stats, patch.kpis);
});
DashboardLayout.buildVizSelector(document.getElementById('viz-toolbar'), prefs, async (mode) => {
this._vizMode = mode;
window._dashboardVizMode = mode;
await DashboardLayout.saveViz(mode);
document.getElementById('viz-label-sentiment').textContent = mode;
document.getElementById('viz-label-agents').textContent = mode;
if (this._stats) BriefingCharts.renderLive(dash, this._stats, mode, window._dashboardKpis);
});
window._refreshRssDash = () => this.refreshRss(true);
await this.refreshLive(false);
if (!sessionStorage.getItem('rss_auto_refreshed')) {
const cnt = (this._stats && (this._stats.rss_items || (this._stats.rss_live || []).length)) || 0;
if (cnt < 3) { sessionStorage.setItem('rss_auto_refreshed', '1'); await this.refreshRss(false); }
}
await this.loadProjectsMini();
if (this.liveDigest) {
BriefingCharts.renderLiveSummary(this.liveDigest, false);
} else if (this.content) {
BriefingCharts.renderText(dash, this.content, this.createdAt);
}
window._refreshBriefingLive = (t) => this.refreshLive(t);
this._pollStop = CockpitLive.startPolling(() => { this.refreshLive(false); }, 20000);
this._wsStop = CockpitLive.connectFeed(() => { this.refreshLive(false); });
if (this.hermanTab === 'rss') await this.loadRssTab();
},
async refreshRss(toast) {
if (toast) Cockpit.toast('RSS feeds ophalen…', 'info');
try {
await fetch('/api/retail/rss/refresh', { method: 'POST' });
await this.loadRssTab();
if (this._stats) {
BriefingCharts.renderRetail(document.getElementById('briefing-retail'), this._stats);
BriefingCharts.renderFoodHighlights(document.getElementById('briefing-food-highlights'), this._stats);
BriefingCharts.renderKpis(document.getElementById('briefing-kpis'), this._stats, window._dashboardKpis);
}
if (toast) Cockpit.toast('RSS bijgewerkt (' + (this.rssTotal || 0) + ' items)', 'success');
} catch (e) { if (toast) Cockpit.toast(e.message || 'RSS fout', 'error'); }
},
async loadApprovals() {
try {
const r = await fetch('/api/agents/approvals?status=pending&limit=10').then(x => x.json());
const el = document.getElementById('dash-approvals-list');
const badge = document.getElementById('approvals-pending-badge');
if (!el) return;
const items = r.items || [];
if (badge) {
badge.style.display = items.length ? 'inline' : 'none';
badge.textContent = items.length + ' open';
}
const actionLabel = (t) => ({ maintenance_scan: '🔍 Update-scan', config_backup: '💾 Gitea backup' }[t] || t);
if (!items.length) { el.innerHTML = '<p class="empty-state">Geen open goedkeuringen 🎉</p>'; return; }
el.innerHTML = '<table class="data-table"><thead><tr><th>Agent</th><th>Type</th><th>Verzoek</th><th></th></tr></thead><tbody>' +
items.map(i => '<tr class="'+(i.action_type==='maintenance_scan'?'approval-row-scan':'')+'"><td>@'+i.agent_key+'</td><td>'+actionLabel(i.action_type)+'</td><td>'+i.title+'</td><td class="approval-dash-row">' +
'<button class="btn btn-sm btn-approve" data-aid="'+i.id+'">✓ Goedkeuren</button>' +
'<button class="btn btn-sm btn-reject" data-rid="'+i.id+'">✗</button></td></tr>').join('') +
'</tbody></table><p class="page-tip" style="margin-top:0.5rem"><a href="/agents?tab=approvals">Volledige queue →</a></p>';
el.querySelectorAll('[data-aid]').forEach(btn => btn.addEventListener('click', async () => {
await Cockpit.api('/api/agents/approvals/' + btn.dataset.aid + '/approve', { method: 'POST', body: '{}' });
Cockpit.toast('Goedgekeurd', 'success'); await this.loadApprovals();
}));
el.querySelectorAll('[data-rid]').forEach(btn => btn.addEventListener('click', async () => {
await Cockpit.api('/api/agents/approvals/' + btn.dataset.rid + '/reject', { method: 'POST', body: JSON.stringify({ reason: '' }) });
Cockpit.toast('Afgewezen', 'info'); await this.loadApprovals();
}));
} catch (e) {}
},
async loadProjectsMini() {
try {
const r = await fetch('/api/projects?limit=5').then(x => x.json());
const el = document.getElementById('dash-projects-mini');
if (!el) return;
const items = r.items || [];
el.innerHTML = items.length ? items.map(p =>
'<a href="/projects?id='+p.id+'" class="beurs-mini-chip"><strong>'+p.name+'</strong> · '+(p.asset_count||0)+' assets</a>'
).join('') : '<p class="muted">Nog geen projecten — <a href="/projects">maak aan</a></p>';
} catch (e) {}
},
renderActivityLog(entries) {
const el = document.getElementById('briefing-activity-log');
if (!el) return;
const rows = (entries || []).slice(0, 10);
el.innerHTML = rows.length
? rows.map(function (e, i) {
return '<li class="hm-activity-item"><span class="hm-activity-dot"></span><span class="hm-activity-text">' + e + '</span></li>';
}).join('')
: '<li class="hm-activity-empty">Nog geen agent-acties vandaag — Herman documenteert automatisch.</li>';
},
renderClientsMini(stats) {
const el = document.getElementById('briefing-clients-mini');
if (!el) return;
const active = stats.clients_active || 0, total = stats.clients_total || stats.clients || 0;
el.innerHTML = '<a href="/clients" class="beurs-mini-chip up"><strong>' + active + '</strong> actief</a>' +
'<a href="/clients" class="beurs-mini-chip"><strong>' + total + '</strong> totaal klanten</a>' +
'<a href="/retail" class="beurs-mini-chip"><strong>' + (stats.crm_partnerships||0) + '</strong> partnerships</a>' +
'<a href="/marketing?tab=reclame" class="beurs-mini-chip"><strong>' + (stats.promo_campaigns||0) + '</strong> promo\'s</a>';
},
updateLiveMeta(at, digest) {
const tsRaw = at || (digest && digest.updated_at) || '';
const ts = String(tsRaw).substring(0, 19).replace('T', ' ');
const n = digest && digest.activity_count != null ? digest.activity_count + ' activiteiten' : '';
const line = ts ? ('Ververst ' + ts + ' UTC' + (n ? ' · ' + n : '')) : 'Live data laden…';
const meta = document.getElementById('briefing-meta');
if (meta) meta.textContent = line;
const heroMeta = document.getElementById('briefing-meta-hero');
if (heroMeta) heroMeta.textContent = line;
const badge = document.querySelector('.hm-live-badge');
if (badge && tsRaw) {
try {
badge.textContent = 'Live · ' + new Date(tsRaw).toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' });
} catch (e) {}
}
},
async refreshLive(toast) {
if (this._liveRefreshing) return;
this._liveRefreshing = true;
const isPoll = toast === false;
if (isPoll) this._pollTick = (this._pollTick || 0) + 1;
if (toast !== false) Cockpit.toast('Live data ophalen…', 'info');
try {
const light = isPoll && (this._pollTick % 6 !== 0);
const url = light ? '/api/herman/briefing/stats?light=1' : '/api/herman/briefing/stats';
const r = await fetch(url).then(x => x.json());
if (r.stats) {
this._stats = r.stats;
if (r.live_digest) this.liveDigest = r.live_digest;
try {
const live = await fetch('/api/retail/rss/live?limit=25').then(x => x.json());
if (live.items && live.items.length) {
this._stats.rss_live = live.items;
this._stats.rss_items = live.count || live.items.length;
this.rssTotal = this._stats.rss_items;
}
} catch (e) {}
BriefingCharts.renderLive(document.getElementById('briefing-dashboard'), this._stats, this._vizMode, window._dashboardKpis, this.liveDigest, { pollRefresh: isPoll });
this.renderClientsMini(this._stats);
this.renderActivityLog(this._stats.activity_log || []);
this.updateLiveMeta(r.at, this.liveDigest);
}
if (toast !== false) Cockpit.toast('Live data bijgewerkt', 'success');
} catch (e) { if (toast !== false) Cockpit.toast(e.message, 'error'); }
finally { this._liveRefreshing = false; }
},
pushLiveEntry(entry) {
this.liveLog.push(entry);
this.loadingMsg = entry.message || this.loadingMsg;
if (entry.type === 'step') {
const done = this.liveLog.filter(e => e.type === 'step' && e.status === 'ok').length;
this.liveProgress = done + ' bronnen geladen';
}
this.$nextTick(() => {
const log = document.getElementById('hm-live-log');
if (log) log.scrollTop = log.scrollHeight;
});
},
async generate() {
this.loading = true;
this.liveLog = [];
this.liveProgress = '';
this.loadingMsg = 'Herman start dagrapport…';
try {
const resp = await fetch('/api/herman/briefing/stream', { method: 'POST' });
if (!resp.ok) throw new Error('Stream mislukt (' + resp.status + ')');
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = '';
let result = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const parts = buf.split('\n\n');
buf = parts.pop() || '';
for (const chunk of parts) {
const line = chunk.trim();
if (!line.startsWith('data:')) continue;
const evt = JSON.parse(line.replace(/^data:\s*/, ''));
if (evt.type === 'step' || evt.type === 'agent_msg') {
this.pushLiveEntry(evt);
} else if (evt.type === 'done') {
result = evt;
this.pushLiveEntry({ type: 'step', status: 'ok', source: 'Herman', message: '✓ Dagrapport klaar!' });
} else if (evt.type === 'error') {
throw new Error(evt.message || 'Onbekende fout');
}
}
}
if (!result) throw new Error('Geen resultaat van Herman');
this.content = result.content || '';
this.createdAt = result.generated_at || new Date().toISOString();
this._stats = result.stats;
if (result.stats && result.stats.live_digest) this.liveDigest = result.stats.live_digest;
BriefingCharts.render(document.getElementById('briefing-dashboard'), result.stats, result.content, this.createdAt, this._vizMode);
this.renderClientsMini(result.stats || {});
this.renderActivityLog((result.stats || {}).activity_log || []);
document.getElementById('briefing-meta').textContent = 'Rapport: ' + this.createdAt.substring(0, 19).replace('T', ' ') + ' UTC';
Cockpit.toast('Dagrapport klaar!', 'success');
} catch (e) {
this.pushLiveEntry({ type: 'step', status: 'warn', source: 'Fout', message: e.message || String(e) });
Cockpit.toast(e.message || 'Fout bij genereren', 'error');
}
this.loading = false;
this.loadingMsg = 'Klaar';
}
};
}
</script>
{% endblock %}