Ship Herman chat, Hermes KPI explorer, and intelligence analytics.
Adds floating Herman assistant with voice, Hermes technical KPI dashboard with file explorer, and a wins/kansen analytics pulse with Hermes findings hooks. Includes optional local HTTPS via Caddy for mic permissions (public certs only).
This commit is contained in:
+202
-172
@@ -1,199 +1,229 @@
|
||||
window.AnalyticsCharts = (function () {
|
||||
var charts = {};
|
||||
var colors = {
|
||||
gold: 'rgba(252, 211, 77, 0.85)', cyan: 'rgba(56, 189, 248, 0.85)',
|
||||
green: 'rgba(74, 222, 128, 0.85)', red: 'rgba(251, 113, 133, 0.85)',
|
||||
purple: 'rgba(168, 85, 247, 0.85)', orange: 'rgba(255, 159, 67, 0.85)',
|
||||
gray: 'rgba(159, 176, 196, 0.85)', grid: 'rgba(159, 176, 196, 0.12)', text: '#c8d4e0',
|
||||
};
|
||||
var palette = [colors.gold, colors.cyan, colors.green, colors.purple, colors.orange, colors.red, colors.gray];
|
||||
|
||||
function destroyAll() {
|
||||
Object.keys(charts).forEach(function (k) {
|
||||
if (charts[k]) { charts[k].destroy(); charts[k] = null; }
|
||||
});
|
||||
}
|
||||
|
||||
function barChart(id, labels, values, label, horizontal) {
|
||||
var canvas = document.getElementById(id);
|
||||
if (!canvas || typeof Chart === 'undefined') return;
|
||||
if (charts[id]) charts[id].destroy();
|
||||
charts[id] = new Chart(canvas, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{ label: label, data: values, backgroundColor: palette.slice(0, labels.length), borderRadius: 6 }],
|
||||
},
|
||||
options: {
|
||||
indexAxis: horizontal ? 'y' : 'x',
|
||||
responsive: true,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { ticks: { color: colors.text, maxRotation: 45 }, grid: { color: colors.grid } },
|
||||
y: { ticks: { color: colors.text }, grid: { color: colors.grid } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function doughnut(id, labels, values) {
|
||||
var canvas = document.getElementById(id);
|
||||
if (!canvas || typeof Chart === 'undefined') return;
|
||||
if (charts[id]) charts[id].destroy();
|
||||
charts[id] = new Chart(canvas, {
|
||||
type: 'doughnut',
|
||||
data: { labels: labels, datasets: [{ data: values, backgroundColor: palette, borderWidth: 0 }] },
|
||||
options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { color: colors.text, boxWidth: 12 } } } },
|
||||
});
|
||||
}
|
||||
|
||||
function lineChart(id, labels, values) {
|
||||
var canvas = document.getElementById(id);
|
||||
if (!canvas || typeof Chart === 'undefined') return;
|
||||
if (charts[id]) charts[id].destroy();
|
||||
charts[id] = new Chart(canvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{ data: values, borderColor: colors.cyan, backgroundColor: 'rgba(56,189,248,0.15)', fill: true, tension: 0.3 }],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { ticks: { color: colors.text }, grid: { color: colors.grid } },
|
||||
y: { ticks: { color: colors.text }, grid: { color: colors.grid } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderKpis(container, kpis) {
|
||||
if (!container || !kpis) return;
|
||||
var items = [
|
||||
['Klanten totaal', kpis.clients_total], ['Actieve klanten', kpis.clients_active],
|
||||
['Pipeline €', '€' + Math.round(kpis.pipeline_eur || 0).toLocaleString('nl-NL')],
|
||||
['Supermarkten', kpis.supermarkets], ['Groothandels', kpis.wholesalers],
|
||||
['CRM actief', kpis.crm_partnerships], ['RSS items', kpis.rss_items],
|
||||
['Bookmarks', kpis.rss_bookmarks], ['Promo's', kpis.promo_campaigns],
|
||||
['Agent events', kpis.agent_events], ['Goedkeuringen', kpis.pending_approvals],
|
||||
['Contacten SM', kpis.contacts_supermarket], ['NAS docs', kpis.nas_docs],
|
||||
];
|
||||
container.innerHTML = items.map(function (it) {
|
||||
return '<div class="kpi-card"><span>' + it[0] + '</span><strong>' + it[1] + '</strong></div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderTables(data) {
|
||||
var dealsEl = document.getElementById('analytics-deals-table');
|
||||
var eventsEl = document.getElementById('analytics-events-table');
|
||||
if (dealsEl) {
|
||||
dealsEl.innerHTML = (data.recent_deals || []).map(function (d) {
|
||||
return '<tr><td>' + (d.title || '—') + '</td><td>' + (d.stage || '') + '</td><td>€' + Math.round(Number(d.value || 0)).toLocaleString('nl-NL') + '</td></tr>';
|
||||
}).join('') || '<tr><td colspan="3">Geen deals</td></tr>';
|
||||
}
|
||||
if (eventsEl) {
|
||||
eventsEl.innerHTML = (data.recent_events || []).map(function (e) {
|
||||
var t = (e.created_at || '').substring(0, 16).replace('T', ' ');
|
||||
return '<tr><td>' + (e.agent_name || '') + '</td><td>' + (e.title || e.event_type || '') + '</td><td>' + t + '</td></tr>';
|
||||
}).join('') || '<tr><td colspan="3">Geen events</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function render(data, mode) {
|
||||
if (!data) return;
|
||||
mode = mode || window.__vizMode || 'neo-bars';
|
||||
window.__analyticsLastData = data;
|
||||
renderKpis(document.getElementById('analytics-kpis'), data.kpis);
|
||||
var useRing = mode === 'neo-rings';
|
||||
var bar = function (id, labels, values, label, horizontal) {
|
||||
if (useRing) doughnut(id, labels, values);
|
||||
else barChart(id, labels, values, label, horizontal);
|
||||
};
|
||||
bar('chart-deals', (data.deals_by_stage || []).map(function (r) { return r.stage; }),
|
||||
(data.deals_by_stage || []).map(function (r) { return Number(r.total || r.cnt || 0); }), 'EUR');
|
||||
bar('chart-clients', (data.clients_by_stage || []).map(function (r) { return r.stage; }),
|
||||
(data.clients_by_stage || []).map(function (r) { return Number(r.cnt || 0); }), 'Klanten');
|
||||
bar('chart-chains', (data.supermarkets_by_chain || []).map(function (r) { return r.chain; }),
|
||||
(data.supermarkets_by_chain || []).map(function (r) { return Number(r.cnt || 0); }), 'Filialen');
|
||||
if (useRing) {
|
||||
doughnut('chart-provinces', (data.supermarkets_by_province || []).map(function (r) { return r.province; }),
|
||||
(data.supermarkets_by_province || []).map(function (r) { return Number(r.cnt || 0); }));
|
||||
} else barChart('chart-provinces', (data.supermarkets_by_province || []).map(function (r) { return r.province; }),
|
||||
(data.supermarkets_by_province || []).map(function (r) { return Number(r.cnt || 0); }), 'Filialen');
|
||||
doughnut('chart-partnerships', (data.partnership_breakdown || []).map(function (r) { return r.status; }),
|
||||
(data.partnership_breakdown || []).map(function (r) { return Number(r.cnt || 0); }));
|
||||
bar('chart-agents', (data.events_by_agent || []).map(function (r) { return r.agent_name; }),
|
||||
(data.events_by_agent || []).map(function (r) { return Number(r.cnt || 0); }), 'Events', true);
|
||||
if (mode === 'neo-table') {
|
||||
lineChart('chart-timeline', [], []);
|
||||
} else {
|
||||
lineChart('chart-timeline', (data.events_timeline || []).map(function (r) { return String(r.day || '').substring(5); }),
|
||||
(data.events_timeline || []).map(function (r) { return Number(r.cnt || 0); }));
|
||||
}
|
||||
doughnut('chart-rss', (data.rss_by_category || []).map(function (r) { return r.category || 'other'; }),
|
||||
(data.rss_by_category || []).map(function (r) { return Number(r.cnt || 0); }));
|
||||
bar('chart-wholesale', (data.wholesalers_by_province || []).map(function (r) { return r.province; }),
|
||||
(data.wholesalers_by_province || []).map(function (r) { return Number(r.cnt || 0); }), 'GH');
|
||||
doughnut('chart-sentiment', (data.sentiment_distribution || []).map(function (r) { return r.sentiment_label || 'neutral'; }),
|
||||
(data.sentiment_distribution || []).map(function (r) { return Number(r.cnt || 0); }));
|
||||
bar('chart-words', (data.top_words || []).slice(0, 10).map(function (r) { return r.lemma; }),
|
||||
(data.top_words || []).slice(0, 10).map(function (r) { return Number(r.total || 0); }), 'Count', true);
|
||||
bar('chart-opportunities', (data.top_opportunities || []).map(function (r) { return (r.chain || '') + ' ' + (r.city || ''); }),
|
||||
(data.top_opportunities || []).map(function (r) { return Math.round(Number(r.halal_opportunity_score || 0)); }), 'Score', true);
|
||||
bar('chart-promo', (data.promo_by_chain || []).map(function (r) { return r.chain || '?'; }),
|
||||
(data.promo_by_chain || []).map(function (r) { return Number(r.cnt || 0); }), 'Promo');
|
||||
doughnut('chart-milestones', (data.milestones_by_status || []).map(function (r) { return r.status; }),
|
||||
(data.milestones_by_status || []).map(function (r) { return Number(r.cnt || 0); }));
|
||||
renderTables(data);
|
||||
}
|
||||
|
||||
document.addEventListener('foodlinkk:vizmode', function (ev) {
|
||||
if (window.__analyticsLastData) render(window.__analyticsLastData, ev.detail.mode);
|
||||
});
|
||||
|
||||
return { render: render, destroyAll: destroyAll };
|
||||
})();
|
||||
|
||||
/**
|
||||
* Foodlinkk Intelligence Analytics hub
|
||||
*/
|
||||
function analyticsHub() {
|
||||
const charts = {};
|
||||
|
||||
function destroyCharts() {
|
||||
Object.keys(charts).forEach((k) => {
|
||||
try { charts[k].destroy(); } catch (e) {}
|
||||
delete charts[k];
|
||||
});
|
||||
}
|
||||
|
||||
function euro(n) {
|
||||
const v = Math.round(Number(n || 0));
|
||||
return '€' + v.toLocaleString('nl-NL');
|
||||
}
|
||||
|
||||
return {
|
||||
loading: false,
|
||||
showFilters: false,
|
||||
updatedAt: '—',
|
||||
liveLabel: 'Live',
|
||||
f: { chain: '', province: '', stage: '', agent: '', days: 90 },
|
||||
meta: {},
|
||||
data: {},
|
||||
kpis: {},
|
||||
pulse: { wins: [], opportunities: [], attention: [], score: 0 },
|
||||
findings: [],
|
||||
goals: {},
|
||||
signals: [],
|
||||
_pollStop: null,
|
||||
init() {
|
||||
var initial = window.ANALYTICS_INITIAL || {};
|
||||
this.meta = initial.filter_meta || {};
|
||||
AnalyticsCharts.render(initial);
|
||||
this.updatedAt = (initial.generated_at || '').substring(0, 19).replace('T', ' ');
|
||||
this._pollStop = CockpitLive.startPolling(function () { return this.refresh(false); }.bind(this), 30000);
|
||||
|
||||
fmt(n) {
|
||||
return Number(n || 0).toLocaleString('nl-NL');
|
||||
},
|
||||
|
||||
formatTime(iso) {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
return new Date(iso).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' });
|
||||
} catch (e) {
|
||||
return String(iso).slice(0, 16).replace('T', ' ');
|
||||
}
|
||||
},
|
||||
|
||||
shortGoal(t) {
|
||||
const s = String(t || '').replace(/\s+/g, ' ').trim();
|
||||
return s.length > 90 ? s.slice(0, 87) + '…' : s;
|
||||
},
|
||||
|
||||
ringDash(score) {
|
||||
const s = Math.max(0, Math.min(100, Number(score || 0)));
|
||||
const c = 2 * Math.PI * 52;
|
||||
return ((s / 100) * c).toFixed(1);
|
||||
},
|
||||
|
||||
applyData(payload) {
|
||||
this.data = payload || {};
|
||||
this.kpis = this.data.kpis || {};
|
||||
this.pulse = this.data.pulse || { wins: [], opportunities: [], attention: [], score: 0 };
|
||||
this.findings = this.data.hermes_findings || [];
|
||||
this.goals = this.data.goals || {};
|
||||
this.meta = this.data.filter_meta || this.meta;
|
||||
this.updatedAt = (this.data.generated_at || '').substring(0, 19).replace('T', ' ') || '—';
|
||||
this.signals = [
|
||||
{ key: 'margin', label: 'Marge / mnd', value: euro(this.kpis.margin_month), hint: (this.kpis.active_projects || 0) + ' deals', tone: 'good' },
|
||||
{ key: 'partners', label: 'CRM actief', value: this.fmt(this.kpis.crm_partnerships), hint: (this.pulse.cover_pct || 0) + '% dekking', tone: 'good' },
|
||||
{ key: 'gap', label: 'White space', value: this.fmt(this.pulse.white_space), hint: 'winkels zonder partnership', tone: 'warn' },
|
||||
{ key: 'stores', label: 'Supermarkten', value: this.fmt(this.kpis.supermarkets), hint: this.fmt(this.kpis.wholesalers) + ' groothandels' },
|
||||
{ key: 'rss', label: 'Markt signalen', value: this.fmt(this.kpis.rss_items), hint: this.fmt(this.kpis.promo_campaigns) + ' promo’s' },
|
||||
{ key: 'agents', label: 'Agent events', value: this.fmt(this.kpis.agent_events), hint: this.fmt(this.kpis.pending_approvals) + ' approvals', tone: this.kpis.pending_approvals ? 'warn' : '' },
|
||||
];
|
||||
this.$nextTick(() => this.renderCharts());
|
||||
},
|
||||
|
||||
init() {
|
||||
this.applyData(window.ANALYTICS_INITIAL || {});
|
||||
if (window.CockpitLive && CockpitLive.startPolling) {
|
||||
this._pollStop = CockpitLive.startPolling(() => this.refresh(false), 45000);
|
||||
} else {
|
||||
this._pollStop = setInterval(() => this.refresh(false), 45000);
|
||||
}
|
||||
},
|
||||
|
||||
params() {
|
||||
var p = new URLSearchParams();
|
||||
Object.entries(this.f).forEach(function (e) {
|
||||
if (e[1] !== null && e[1] !== '' && e[1] !== undefined) p.set(e[0], e[1]);
|
||||
const p = new URLSearchParams();
|
||||
Object.entries(this.f).forEach(([k, v]) => {
|
||||
if (v !== null && v !== '' && v !== undefined) p.set(k, v);
|
||||
});
|
||||
return p.toString();
|
||||
},
|
||||
|
||||
async refresh(toast) {
|
||||
this.loading = true;
|
||||
try {
|
||||
var data = await fetch('/analytics/api/data?' + this.params()).then(function (r) { return r.json(); });
|
||||
this.meta = data.filter_meta || this.meta;
|
||||
AnalyticsCharts.render(data);
|
||||
this.updatedAt = (data.generated_at || '').substring(0, 19).replace('T', ' ');
|
||||
if (toast !== false) Cockpit.toast('Analytics bijgewerkt', 'success');
|
||||
const data = await fetch('/analytics/api/data?' + this.params()).then((r) => r.json());
|
||||
this.applyData(data);
|
||||
if (toast !== false && window.Cockpit) Cockpit.toast('Intelligence bijgewerkt', 'success');
|
||||
} catch (e) {
|
||||
if (toast !== false) Cockpit.toast(e.message, 'error');
|
||||
if (toast !== false && window.Cockpit) Cockpit.toast(e.message || 'Laden mislukt', 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
resetFilters() {
|
||||
this.f = { chain: '', province: '', stage: '', agent: '', days: 90 };
|
||||
this.refresh();
|
||||
},
|
||||
|
||||
renderCharts() {
|
||||
if (typeof Chart === 'undefined') return;
|
||||
destroyCharts();
|
||||
const grid = 'rgba(148,163,184,0.12)';
|
||||
const tick = '#94a3b8';
|
||||
const d = this.data;
|
||||
|
||||
const mk = (id, cfg) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
charts[id] = new Chart(el, cfg);
|
||||
};
|
||||
|
||||
mk('ax-chart-timeline', {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: (d.events_timeline || []).map((r) => String(r.day || '').substring(5)),
|
||||
datasets: [{
|
||||
data: (d.events_timeline || []).map((r) => Number(r.cnt || 0)),
|
||||
borderColor: '#38bdf8',
|
||||
backgroundColor: 'rgba(56,189,248,0.15)',
|
||||
fill: true,
|
||||
tension: 0.35,
|
||||
pointRadius: 0,
|
||||
borderWidth: 2,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { ticks: { color: tick, maxTicksLimit: 10 }, grid: { color: grid } },
|
||||
y: { ticks: { color: tick }, grid: { color: grid } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mk('ax-chart-partners', {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: (d.partnership_breakdown || []).map((r) => r.status),
|
||||
datasets: [{
|
||||
data: (d.partnership_breakdown || []).map((r) => Number(r.cnt || 0)),
|
||||
backgroundColor: ['#64748b', '#b8ff3c', '#fbbf24', '#38bdf8', '#fb7185'],
|
||||
borderWidth: 0,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'bottom', labels: { color: tick, boxWidth: 10, font: { size: 10 } } } },
|
||||
},
|
||||
});
|
||||
|
||||
mk('ax-chart-opps', {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: (d.top_opportunities || []).slice(0, 8).map((r) => (r.chain || '') + ' ' + (r.city || '')),
|
||||
datasets: [{
|
||||
data: (d.top_opportunities || []).slice(0, 8).map((r) => Math.round(Number(r.halal_opportunity_score || 0))),
|
||||
backgroundColor: 'rgba(251,191,36,0.55)',
|
||||
borderColor: '#fbbf24',
|
||||
borderWidth: 1,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y', responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { ticks: { color: tick }, grid: { color: grid } },
|
||||
y: { ticks: { color: '#cbd5e1', font: { size: 10 } }, grid: { display: false } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mk('ax-chart-chains', {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: (d.supermarkets_by_chain || []).slice(0, 8).map((r) => r.chain),
|
||||
datasets: [{
|
||||
data: (d.supermarkets_by_chain || []).slice(0, 8).map((r) => Number(r.cnt || 0)),
|
||||
backgroundColor: 'rgba(56,189,248,0.45)',
|
||||
borderColor: '#38bdf8',
|
||||
borderWidth: 1,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y', responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { ticks: { color: tick }, grid: { color: grid } },
|
||||
y: { ticks: { color: '#cbd5e1', font: { size: 10 } }, grid: { display: false } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mk('ax-chart-prov', {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: (d.supermarkets_by_province || []).slice(0, 8).map((r) => r.province),
|
||||
datasets: [{
|
||||
data: (d.supermarkets_by_province || []).slice(0, 8).map((r) => Number(r.cnt || 0)),
|
||||
backgroundColor: 'rgba(184,255,60,0.4)',
|
||||
borderColor: '#b8ff3c',
|
||||
borderWidth: 1,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { ticks: { color: tick, maxRotation: 45 }, grid: { display: false } },
|
||||
y: { ticks: { color: tick }, grid: { color: grid } },
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user