SysOps: deploy-all — 2026-06-09 10:59 UTC

This commit is contained in:
sysops
2026-06-09 10:59:45 +00:00
parent 21ea3a2c81
commit 7f973b7203
9 changed files with 669 additions and 94 deletions
+95 -14
View File
@@ -232,19 +232,65 @@ window.BriefingCharts = (function () {
});
}
function renderKpis(container, stats) {
if (!container) return;
function kpiValue(id, stats) {
var summary = stats.market_summary || {};
var avgPct = Number(summary.avg_change_pct || 0);
var best = summary.best_performer || {};
var items = [
{ label: 'Pipeline', sub: 'actieve deals', icon: '💰', color: '#00e5ff', pct: '72%', val: '€' + Math.round(stats.pipeline_eur || 0).toLocaleString('nl-NL') },
{ label: 'Actieve klanten', sub: 'CRM · zaken mee', icon: '🤝', color: '#22c55e', pct: Math.min(95, Math.round(((stats.clients_active || 0) / Math.max(stats.clients_total || 1, 1)) * 100)) + '%', val: (stats.clients_active || 0) + ' / ' + (stats.clients_total || 0), link: '/clients' },
{ label: 'CRM partnerships', sub: 'actieve filialen', icon: '🏪', color: '#ff9f43', pct: '45%', val: stats.crm_partnerships || 0, link: '/retail' },
{ label: 'Supermarkten', sub: 'Retail 360 DB', icon: '🏪', color: '#b8ff3c', pct: '88%', val: (stats.supermarkets || 0).toLocaleString('nl-NL') },
{ label: 'Food trends', sub: 'RSS live', icon: '📰', color: '#38bdf8', pct: Math.min(95, ((stats.trending_food || stats.food_market_highlights || []).length * 10)) + '%', val: (stats.trending_food || stats.food_market_highlights || []).length || 0, link: '/marketing' },
{ label: 'Goedkeuringen', sub: 'wacht op OK', icon: '✓', color: '#ffd700', pct: '30%', val: stats.pending_approvals || 0 },
];
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 '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 '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.DEFAULT_KPIS) ||
['pipeline', 'clients_active', 'crm_partnerships', 'supermarkets', 'trending_food', 'pending_approvals'];
var items = order.map(function (id) {
var m = meta[id] || { label: id, sub: '', icon: '▪', color: '#94a3b8' };
return {
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 geselecteerd — klik <strong>Dashboard instellen</strong>.</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">' +
@@ -255,6 +301,35 @@ window.BriefingCharts = (function () {
}).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;
var items = (stats.trending_food || stats.food_market_highlights || []).slice(0, 6);
@@ -343,10 +418,12 @@ window.BriefingCharts = (function () {
if (longEl) longEl.innerHTML = parsed.longTerm.length ? parsed.longTerm.map(function (a) { return '<li>' + a + '</li>'; }).join('') : '<li>Halal kant-en-klaar partnerships schalen</li>';
}
function renderLive(root, stats, vizMode) {
function renderLive(root, stats, vizMode, kpiSelection) {
if (!root || !stats) return;
var mode = vizMode || window._dashboardVizMode || 'neo-bars';
renderKpis(document.getElementById('briefing-kpis'), stats);
var kpis = kpiSelection || window._dashboardKpis;
renderKpis(document.getElementById('briefing-kpis'), stats, kpis);
renderRssFeed(document.getElementById('briefing-rss-feed'), stats);
renderFoodHighlights(document.getElementById('briefing-food-highlights'), stats);
renderExecutiveSummary(document.getElementById('briefing-executive-summary'), stats);
renderRetail(document.getElementById('briefing-retail'), stats);
@@ -373,5 +450,9 @@ window.BriefingCharts = (function () {
renderText(root, content, createdAt);
}
return { render: render, renderLive: renderLive, renderText: renderText, renderExecutiveSummary: renderExecutiveSummary, destroyAll: destroyAll };
return {
render: render, renderLive: renderLive, renderText: renderText,
renderExecutiveSummary: renderExecutiveSummary, renderRssFeed: renderRssFeed,
renderKpis: renderKpis, destroyAll: destroyAll,
};
})();
+209 -33
View File
@@ -3,32 +3,81 @@
kpis: { title: 'CEO KPI\'s', icon: '📊' },
executive: { title: 'Alles op een rij', icon: '📋' },
briefing: { title: 'Herman briefing', icon: '📝' },
rss: { title: 'RSS live feed', icon: '📰' },
retail: { title: 'Retail operatie', icon: '🏪' },
analytics: { title: 'Data & analytics', icon: '📈' },
approvals: { title: 'Agent goedkeuringen', icon: '✓' },
feed: { title: 'Agent feed', icon: '⚡' },
};
var KPI_META = {
pipeline: { label: 'Pipeline', sub: 'actieve deals', icon: '💰', color: '#00e5ff', link: '/deals' },
clients_active: { label: 'Actieve klanten', sub: 'CRM · zaken mee', icon: '🤝', color: '#22c55e', link: '/clients' },
clients_total: { label: 'Totaal klanten', sub: 'CRM database', icon: '👥', color: '#4ade80', link: '/clients' },
crm_partnerships: { label: 'CRM partnerships', sub: 'actieve filialen', icon: '🏪', color: '#ff9f43', link: '/retail' },
supermarkets: { label: 'Supermarkten', sub: 'Retail 360 DB', icon: '🛒', color: '#b8ff3c', link: '/retail' },
wholesalers: { label: 'Groothandels', sub: 'Retail 360 DB', icon: '📦', color: '#a78bfa', link: '/retail' },
trending_food: { label: 'Food trends', sub: 'RSS live', icon: '📰', color: '#38bdf8', link: '/marketing' },
rss_items: { label: 'RSS items', sub: 'totaal in DB', icon: '📡', color: '#0ea5e9', link: '/marketing' },
rss_bookmarks: { label: 'RSS bookmarks', sub: 'opgeslagen', icon: '★', color: '#ffd700', link: '/marketing' },
pending_approvals: { label: 'Goedkeuringen', sub: 'wacht op OK', icon: '✓', color: '#ffd700', link: '/' },
deals: { label: 'Deals', sub: 'totaal CRM', icon: '💼', color: '#f472b6', link: '/deals' },
products: { label: 'Producten', sub: 'catalogus', icon: '🥫', color: '#fb923c', link: '/products' },
suppliers: { label: 'Leveranciers', sub: 'sourcing', icon: '🚚', color: '#94a3b8', link: '/suppliers' },
nas_docs: { label: 'NAS documenten', sub: 'geanalyseerd', icon: '📊', color: '#818cf8', link: '/documents' },
promo_campaigns: { label: 'Promo campagnes', sub: 'actief', icon: '📁', color: '#c084fc', link: '/marketing?tab=reclame' },
market_best: { label: 'Aandeel top', sub: 'retail markt', icon: '📈', color: '#34d399', link: '/analytics' },
};
var DEFAULT_LAYOUT = Object.keys(WIDGET_META);
var DEFAULT_KPIS = ['pipeline', 'clients_active', 'crm_partnerships', 'supermarkets', 'trending_food', 'pending_approvals'];
function loadPrefs() {
return fetch('/api/preferences/ui').then(function (r) { return r.json(); }).catch(function () {
return { dashboard_layout: Object.keys(WIDGET_META), global_viz_mode: 'neo-bars', viz_modes: {} };
return {
dashboard_layout: DEFAULT_LAYOUT.slice(),
dashboard_widgets: defaultWidgets(),
dashboard_kpis: DEFAULT_KPIS.slice(),
global_viz_mode: 'neo-bars',
viz_modes: {},
};
});
}
function defaultWidgets() {
var m = {};
DEFAULT_LAYOUT.forEach(function (id) { m[id] = true; });
return m;
}
function savePrefs(patch) {
return fetch('/api/preferences/ui', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
}).then(function (r) { return r.json(); });
}
function saveLayout(order) {
return fetch('/api/preferences/ui', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dashboard_layout: order }),
});
return savePrefs({ dashboard_layout: order });
}
function saveViz(globalMode, vizModes) {
return fetch('/api/preferences/ui', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ global_viz_mode: globalMode, viz_modes: vizModes || {} }),
});
return savePrefs({ global_viz_mode: globalMode, viz_modes: vizModes || {} });
}
function saveDashboardConfig(layout, widgets, kpis) {
var patch = {};
if (layout) patch.dashboard_layout = layout;
if (widgets) patch.dashboard_widgets = widgets;
if (kpis) patch.dashboard_kpis = kpis;
return savePrefs(patch);
}
function visibleLayout(prefs) {
var layout = prefs.dashboard_layout || DEFAULT_LAYOUT.slice();
var widgets = prefs.dashboard_widgets || defaultWidgets();
return layout.filter(function (id) { return widgets[id] !== false; });
}
function applyOrder(container, order) {
@@ -42,33 +91,67 @@
});
}
function initDrag(container, onReorder) {
function applyVisibility(container, widgets) {
if (!container) return;
var dragEl = null;
var wmap = widgets || defaultWidgets();
Array.from(container.querySelectorAll('[data-widget]')).forEach(function (el) {
el.setAttribute('draggable', 'true');
el.classList.add('hm-widget-draggable');
el.addEventListener('dragstart', function (ev) {
dragEl = el;
el.classList.add('hm-widget-dragging');
ev.dataTransfer.effectAllowed = 'move';
});
el.addEventListener('dragend', function () {
el.classList.remove('hm-widget-dragging');
dragEl = null;
var order = Array.from(container.querySelectorAll('[data-widget]')).map(function (n) { return n.dataset.widget; });
if (onReorder) onReorder(order);
});
el.addEventListener('dragover', function (ev) {
ev.preventDefault();
if (!dragEl || dragEl === el) return;
var rect = el.getBoundingClientRect();
var after = ev.clientY > rect.top + rect.height / 2;
if (after) el.after(dragEl); else el.before(dragEl);
});
var id = el.dataset.widget;
if (wmap[id] === false) el.classList.add('hm-widget-hidden');
else el.classList.remove('hm-widget-hidden');
});
}
function initDrag(container, onReorder, editMode) {
if (!container) return function () {};
var dragEl = null;
function setDraggable(on) {
Array.from(container.querySelectorAll('[data-widget]')).forEach(function (el) {
if (el.classList.contains('hm-widget-hidden')) {
el.removeAttribute('draggable');
el.classList.remove('hm-widget-draggable');
return;
}
el.setAttribute('draggable', on ? 'true' : 'false');
el.classList.toggle('hm-widget-draggable', on);
});
}
function bind() {
Array.from(container.querySelectorAll('[data-widget]')).forEach(function (el) {
el.removeEventListener('dragstart', el._hmDragStart);
el.removeEventListener('dragend', el._hmDragEnd);
el.removeEventListener('dragover', el._hmDragOver);
if (el.classList.contains('hm-widget-hidden')) return;
el._hmDragStart = function (ev) {
dragEl = el;
el.classList.add('hm-widget-dragging');
ev.dataTransfer.effectAllowed = 'move';
};
el._hmDragEnd = function () {
el.classList.remove('hm-widget-dragging');
dragEl = null;
var order = Array.from(container.querySelectorAll('[data-widget]:not(.hm-widget-hidden)'))
.map(function (n) { return n.dataset.widget; });
var hidden = Array.from(container.querySelectorAll('[data-widget].hm-widget-hidden'))
.map(function (n) { return n.dataset.widget; });
if (onReorder) onReorder(order.concat(hidden));
};
el._hmDragOver = function (ev) {
ev.preventDefault();
if (!dragEl || dragEl === el) return;
var rect = el.getBoundingClientRect();
var after = ev.clientY > rect.top + rect.height / 2;
if (after) el.after(dragEl); else el.before(dragEl);
};
el.addEventListener('dragstart', el._hmDragStart);
el.addEventListener('dragend', el._hmDragEnd);
el.addEventListener('dragover', el._hmDragOver);
});
}
setDraggable(!!editMode);
bind();
return function (on) { setDraggable(on); bind(); };
}
function buildVizSelector(container, prefs, onChange) {
if (!container || !global.VizEngine) return;
container.innerHTML = '<label class="viz-global-label">Visualisatie:</label>' +
@@ -85,13 +168,106 @@
});
}
function buildCustomizePanel(container, prefs, onChange) {
if (!container) return;
var widgets = prefs.dashboard_widgets || defaultWidgets();
var kpis = prefs.dashboard_kpis || DEFAULT_KPIS.slice();
var catalog = prefs.widget_catalog || Object.keys(WIDGET_META).map(function (id) {
return { id: id, label: (WIDGET_META[id] || {}).title || id, icon: (WIDGET_META[id] || {}).icon || '▪' };
});
var kpiCatalog = prefs.kpi_catalog || Object.keys(KPI_META).map(function (id) {
var m = KPI_META[id];
return { id: id, label: m.label, icon: m.icon };
});
container.innerHTML =
'<div class="hm-customize-panel">' +
'<div class="hm-customize-col">' +
'<h4>Widgets tonen/verbergen</h4>' +
'<p class="hm-customize-hint">Sleep widgets om te ordenen. Vink uit om te verbergen.</p>' +
'<div class="hm-customize-list" id="hm-widget-toggles">' +
catalog.map(function (w) {
var checked = widgets[w.id] !== false ? ' checked' : '';
return '<label class="hm-customize-item"><input type="checkbox" data-widget-toggle="' + w.id + '"' + checked + '> ' +
(w.icon || '') + ' ' + (w.label || w.id) + '</label>';
}).join('') +
'</div>' +
'</div>' +
'<div class="hm-customize-col">' +
'<h4>KPI\'s selecteren</h4>' +
'<p class="hm-customize-hint">Kies welke KPI-kaarten bovenaan staan. Sleep om volgorde te wijzigen.</p>' +
'<div class="hm-kpi-picker" id="hm-kpi-picker">' +
kpiCatalog.map(function (k) {
var on = kpis.indexOf(k.id) >= 0;
return '<label class="hm-kpi-chip' + (on ? ' active' : '') + '" draggable="true" data-kpi-id="' + k.id + '">' +
'<input type="checkbox" data-kpi-toggle="' + k.id + '"' + (on ? ' checked' : '') + '> ' +
(k.icon || '') + ' ' + (k.label || k.id) + '</label>';
}).join('') +
'</div>' +
'</div>' +
'</div>';
var picker = container.querySelector('#hm-kpi-picker');
var dragKpi = null;
container.querySelectorAll('[data-widget-toggle]').forEach(function (cb) {
cb.addEventListener('change', function () {
widgets[cb.dataset.widgetToggle] = cb.checked;
if (onChange) onChange({ widgets: Object.assign({}, widgets) });
});
});
function syncKpiOrder() {
var chips = Array.from(picker.querySelectorAll('.hm-kpi-chip'));
kpis = chips.filter(function (c) { return c.querySelector('input').checked; })
.map(function (c) { return c.dataset.kpiId; });
}
container.querySelectorAll('[data-kpi-toggle]').forEach(function (cb) {
cb.addEventListener('change', function () {
var chip = cb.closest('.hm-kpi-chip');
chip.classList.toggle('active', cb.checked);
syncKpiOrder();
if (onChange) onChange({ kpis: kpis.slice() });
});
});
picker.querySelectorAll('.hm-kpi-chip').forEach(function (chip) {
chip.addEventListener('dragstart', function (ev) {
dragKpi = chip;
chip.classList.add('hm-kpi-dragging');
ev.dataTransfer.effectAllowed = 'move';
});
chip.addEventListener('dragend', function () {
chip.classList.remove('hm-kpi-dragging');
dragKpi = null;
syncKpiOrder();
if (onChange) onChange({ kpis: kpis.slice() });
});
chip.addEventListener('dragover', function (ev) {
ev.preventDefault();
if (!dragKpi || dragKpi === chip) return;
var rect = chip.getBoundingClientRect();
var after = ev.clientX > rect.left + rect.width / 2;
if (after) chip.after(dragKpi); else chip.before(dragKpi);
});
});
}
global.DashboardLayout = {
WIDGET_META: WIDGET_META,
KPI_META: KPI_META,
DEFAULT_KPIS: DEFAULT_KPIS,
loadPrefs: loadPrefs,
saveLayout: saveLayout,
saveViz: saveViz,
savePrefs: savePrefs,
saveDashboardConfig: saveDashboardConfig,
visibleLayout: visibleLayout,
applyOrder: applyOrder,
applyVisibility: applyVisibility,
initDrag: initDrag,
buildVizSelector: buildVizSelector,
buildCustomizePanel: buildCustomizePanel,
};
})(window);