Files
foodlinkk-command-center/cockpit/static/js/analytics.js
T
Aissa 5e43b6b246 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).
2026-08-01 17:50:45 +00:00

230 lines
7.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
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() {
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 {
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 && 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 } },
},
},
});
},
};
}