2026-06-09 00:41:27 +00:00
window . BriefingCharts = ( function () {
var charts = {};
var typewriterTimer = null ;
2026-07-19 18:25:01 +00:00
var typewriterAbort = null ;
var lastDigestSig = '' ;
2026-06-09 00:41:27 +00:00
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 ;
2026-07-30 11:53:43 +00:00
el . textContent = text || '' ;
return ;
2026-06-09 00:41:27 +00:00
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 ) {
2026-07-30 11:53:43 +00:00
// 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>' ;
2026-06-09 00:41:27 +00:00
}
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 ( '' );
}
2026-06-23 10:04:23 +00:00
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>' ;
}
2026-06-09 00:41:27 +00:00
function renderFoodHighlights ( container , stats ) {
if ( ! container ) return ;
2026-06-23 10:04:23 +00:00
container . innerHTML = rssTeaserHtml ( stats );
2026-06-09 00:41:27 +00:00
}
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 } },
},
},
});
}
2026-06-09 10:59:45 +00:00
function kpiValue ( id , stats ) {
2026-06-09 00:41:27 +00:00
var summary = stats . market_summary || {};
var best = summary . best_performer || {};
2026-06-09 10:59:45 +00:00
var trendCount = ( stats . trending_food || stats . food_market_highlights || []). length ;
switch ( id ) {
case 'pipeline' : return '€' + Math . round ( stats . pipeline_eur || 0 ). toLocaleString ( 'nl-NL' );
2026-07-30 11:53:43 +00:00
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 );
}
2026-06-09 10:59:45 +00:00
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%' ;
2026-07-30 11:53:43 +00:00
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%' ;
2026-06-09 10:59:45 +00:00
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 ||
2026-07-30 11:53:43 +00:00
( window . DashboardLayout && window . DashboardLayout . CEO_KPIS ) ||
2026-06-09 10:59:45 +00:00
( window . DashboardLayout && window . DashboardLayout . DEFAULT_KPIS ) ||
2026-07-30 11:53:43 +00:00
[ '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 );
2026-06-09 10:59:45 +00:00
var items = order . map ( function ( id ) {
var m = meta [ id ] || { label : id , sub : '' , icon : '▪' , color : '#94a3b8' };
return {
2026-07-30 11:53:43 +00:00
id : id , label : m . label , sub : m . sub , icon : m . icon , color : m . color || '#94a3b8' ,
2026-06-09 10:59:45 +00:00
pct : kpiPct ( id , stats ), val : kpiValue ( id , stats ), link : m . link ,
};
});
if ( ! items . length ) {
2026-07-30 11:53:43 +00:00
container . innerHTML = '<p class="empty-state">Geen KPI\'s</p>' ;
2026-06-09 10:59:45 +00:00
return ;
}
2026-06-09 00:41:27 +00:00
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 ( '' );
}
2026-07-30 11:53:43 +00:00
2026-06-09 10:59:45 +00:00
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 ();
});
}
2026-06-09 00:41:27 +00:00
function renderRetail ( container , stats ) {
if ( ! container ) return ;
2026-06-23 10:04:23 +00:00
container . innerHTML = rssTeaserHtml ( stats );
2026-06-09 00:41:27 +00:00
}
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 ;
2026-07-30 11:53:43 +00:00
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>' ;
2026-06-09 00:41:27 +00:00
}
2026-07-30 11:53:43 +00:00
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>' ;
2026-06-09 00:41:27 +00:00
});
2026-07-30 11:53:43 +00:00
html += '</div></div>' ;
2026-06-09 00:41:27 +00:00
}
2026-07-30 11:53:43 +00:00
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>' ;
2026-06-09 10:41:13 +00:00
});
2026-07-30 11:53:43 +00:00
html += '</ul></div>' ;
2026-06-09 10:41:13 +00:00
}
2026-07-30 11:53:43 +00:00
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>' ;
2026-06-09 00:41:27 +00:00
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' , ' ' );
2026-07-19 18:25:01 +00:00
var sumEl = document . getElementById ( 'briefing-summary-text' );
2026-07-30 11:53:43 +00:00
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>' ;
2026-07-19 18:25:01 +00:00
}
function escHtml ( s ) {
return String ( s || '' ). replace ( /&/g , '&' ). replace ( /</g , '<' ). replace ( />/g , '>' );
}
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 ) {
2026-07-30 11:53:43 +00:00
// Typewriter uit — altijd direct tonen
fillDigestInstant ( container , digest );
2026-07-19 18:25:01 +00:00
}
function typewriterListItems ( listEl , items , opts ) {
2026-07-30 11:53:43 +00:00
fillListInstant ( listEl , items || []);
2026-07-19 18:25:01 +00:00
}
2026-07-30 11:53:43 +00:00
2026-07-19 18:25:01 +00:00
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 ( '' );
}
2026-07-30 11:53:43 +00:00
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>' ;
2026-07-19 18:25:01 +00:00
return ;
}
2026-07-30 11:53:43 +00:00
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 ( '' );
}
2026-07-19 18:25:01 +00:00
2026-07-30 11:53:43 +00:00
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>' ;
}
2026-07-19 18:25:01 +00:00
2026-07-30 11:53:43 +00:00
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 ;
});
2026-07-19 18:25:01 +00:00
}
2026-07-30 11:53:43 +00:00
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' );
}
2026-07-19 18:25:01 +00:00
2026-07-30 11:53:43 +00:00
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' ;
2026-07-19 18:25:01 +00:00
}
2026-07-30 11:53:43 +00:00
if ( document . querySelector ( '.hm-ceo-pro' )) {
renderCeoDigest ( digest );
return ;
2026-07-19 18:25:01 +00:00
}
2026-07-30 11:53:43 +00:00
var sumEl = document . getElementById ( 'briefing-summary-text' );
if ( sumEl ) fillDigestInstant ( sumEl , digest );
2026-06-09 00:41:27 +00:00
}
2026-07-19 18:25:01 +00:00
function renderLive ( root , stats , vizMode , kpiSelection , liveDigest , opts ) {
2026-06-09 00:41:27 +00:00
if ( ! root || ! stats ) return ;
2026-07-19 18:25:01 +00:00
opts = opts || {};
2026-06-09 10:41:13 +00:00
var mode = vizMode || window . _dashboardVizMode || 'neo-bars' ;
2026-06-09 10:59:45 +00:00
var kpis = kpiSelection || window . _dashboardKpis ;
2026-07-19 18:25:01 +00:00
if ( liveDigest ) renderLiveSummary ( liveDigest , !! opts . pollRefresh );
else if ( stats . live_digest ) renderLiveSummary ( stats . live_digest , !! opts . pollRefresh );
2026-06-09 10:59:45 +00:00
renderKpis ( document . getElementById ( 'briefing-kpis' ), stats , kpis );
2026-06-09 00:41:27 +00:00
renderExecutiveSummary ( document . getElementById ( 'briefing-executive-summary' ), stats );
2026-07-30 11:53:43 +00:00
// activity log removed from CEO overview
2026-06-09 00:41:27 +00:00
renderPipeline ( document . getElementById ( 'chart-pipeline' ), stats );
2026-07-30 11:53:43 +00:00
// 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 );
}
2026-06-09 10:41:13 +00:00
}
2026-06-09 00:41:27 +00:00
}
2026-06-09 10:41:13 +00:00
function render ( root , stats , content , createdAt , vizMode ) {
renderLive ( root , stats , vizMode );
2026-06-09 00:41:27 +00:00
renderText ( root , content , createdAt );
}
2026-06-09 10:59:45 +00:00
return {
2026-07-19 18:25:01 +00:00
render : render , renderLive : renderLive , renderText : renderText , renderLiveSummary : renderLiveSummary ,
2026-06-09 10:59:45 +00:00
renderExecutiveSummary : renderExecutiveSummary , renderRssFeed : renderRssFeed ,
renderKpis : renderKpis , destroyAll : destroyAll ,
};
2026-06-09 00:41:27 +00:00
})();