2026-06-23 10:04:23 +00:00
{% 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=17" />
{% endblock %}
{% block content %}
< div class = "herman-shell hm-neo" >
< div class = "herman-hero" >
< h1 > CEO Dashboard · Cucina < span class = "live-badge" > Live</ span ></ h1 >
< p class = "hero-sub" > Business overzicht · retail · deals · briefing · marketing</ p >
< div class = "hm-hero-links beurs-mini-row" style = "margin-top:0.85rem" >
< 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" >
< 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-bar" x-show = "loading || liveLog.length" x-cloak :class = "loading ? 'hm-live-bar-active' : 'hm-live-bar-done'" >
< div class = "hm-live-bar-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 = []" > ✕</ button >
</ div >
< ol id = "hm-live-log" class = "hm-live-log" >
< 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</ h4 >
< p id = "briefing-summary-text" class = "briefing-typewriter" ></ p >
</ div >
< div class = "briefing-card" style = "max-height:220px;overflow-y:auto" >
< h4 > 📋 Herman activiteitenlog</ h4 >
< ul id = "briefing-activity-log" class = "hm-exec-list" style = "margin:0;padding-left:1.1rem;font-size:0.82rem" ></ 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-scroll" ></ 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 & M& 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 & 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 >
2026-07-19 18:25:01 +00:00
< script src = "/static/js/briefing-charts.js?v=14" ></ script >
2026-06-23 10:04:23 +00:00
< 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 || '' ,
2026-07-19 18:25:01 +00:00
liveDigest : INITIAL_BRIEFING . live_digest || null ,
2026-06-23 10:04:23 +00:00
_pollStop : null , _wsStop : null , _vizMode : 'neo-bars' , _stats : null ,
_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 );
2026-07-19 18:25:01 +00:00
if ( this . _stats ) BriefingCharts . renderLive ( dash , this . _stats , this . _vizMode , window . _dashboardKpis , this . liveDigest );
2026-06-23 10:04:23 +00:00
},
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 ();
2026-07-19 18:25:01 +00:00
if ( this . liveDigest ) {
BriefingCharts . renderLiveSummary ( this . liveDigest , true );
} else if ( this . content ) {
BriefingCharts . renderText ( dash , this . content , this . createdAt );
}
2026-06-23 10:04:23 +00:00
window . _refreshBriefingLive = ( t ) => this . refreshLive ( t );
this . _pollStop = CockpitLive . startPolling (() => { this . refreshLive ( false ); }, 30000 );
2026-07-19 18:25:01 +00:00
this . _wsStop = CockpitLive . connectFeed (() => { this . refreshLive ( false ); });
2026-06-23 10:04:23 +00:00
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 ;
el . innerHTML = ( entries || []). length
? entries . slice ( 0 , 12 ). map ( e => '<li>' + e + '</li>' ). join ( '' )
: '<li class="muted">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>' ;
},
async refreshLive ( toast ) {
if ( toast !== false ) Cockpit . toast ( 'Live data ophalen…' , 'info' );
try {
const r = await fetch ( '/api/herman/briefing/stats' ). then ( x => x . json ());
if ( r . stats ) {
this . _stats = r . stats ;
2026-07-19 18:25:01 +00:00
if ( r . live_digest ) this . liveDigest = r . live_digest ;
2026-06-23 10:04:23 +00:00
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 ) {}
2026-07-19 18:25:01 +00:00
BriefingCharts . renderLive ( document . getElementById ( 'briefing-dashboard' ), this . _stats , this . _vizMode , window . _dashboardKpis , this . liveDigest );
2026-06-23 10:04:23 +00:00
this . renderClientsMini ( this . _stats );
this . renderActivityLog ( this . _stats . activity_log || []);
document . getElementById ( 'briefing-meta' ). textContent = 'Live sync: ' + ( r . at || '' ). substring ( 0 , 19 ). replace ( 'T' , ' ' ) + ' UTC' ;
}
if ( toast !== false ) Cockpit . toast ( 'Live data bijgewerkt' , 'success' );
} catch ( e ) { if ( toast !== false ) Cockpit . toast ( e . message , 'error' ); }
},
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 ;
const bar = document . getElementById ( 'hm-live-bar' );
if ( bar && this . loading ) bar . scrollIntoView ({ behavior : 'smooth' , block : 'start' });
});
},
async generate () {
this . loading = true ;
this . liveLog = [];
this . liveProgress = '' ;
this . loadingMsg = 'Herman start dagrapport…' ;
window . scrollTo ({ top : 0 , behavior : 'smooth' });
const bar = document . getElementById ( 'hm-live-bar' );
if ( bar ) bar . scrollIntoView ({ behavior : 'smooth' , block : 'start' });
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 ;
2026-07-19 18:25:01 +00:00
if ( result . stats && result . stats . live_digest ) this . liveDigest = result . stats . live_digest ;
2026-06-23 10:04:23 +00:00
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 %}