2026-08-01 17:50:45 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* Foodlinkk Intelligence Analytics hub
|
|
|
|
|
|
*/
|
|
|
|
|
|
function analyticsHub() {
|
|
|
|
|
|
const charts = {};
|
2026-06-09 00:41:27 +00:00
|
|
|
|
|
2026-08-01 17:50:45 +00:00
|
|
|
|
function destroyCharts() {
|
|
|
|
|
|
Object.keys(charts).forEach((k) => {
|
|
|
|
|
|
try { charts[k].destroy(); } catch (e) {}
|
|
|
|
|
|
delete charts[k];
|
2026-06-09 00:41:27 +00:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-01 17:50:45 +00:00
|
|
|
|
function euro(n) {
|
|
|
|
|
|
const v = Math.round(Number(n || 0));
|
|
|
|
|
|
return '€' + v.toLocaleString('nl-NL');
|
2026-06-09 00:41:27 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
loading: false,
|
2026-08-01 17:50:45 +00:00
|
|
|
|
showFilters: false,
|
2026-06-09 00:41:27 +00:00
|
|
|
|
updatedAt: '—',
|
|
|
|
|
|
liveLabel: 'Live',
|
|
|
|
|
|
f: { chain: '', province: '', stage: '', agent: '', days: 90 },
|
|
|
|
|
|
meta: {},
|
2026-08-01 17:50:45 +00:00
|
|
|
|
data: {},
|
|
|
|
|
|
kpis: {},
|
|
|
|
|
|
pulse: { wins: [], opportunities: [], attention: [], score: 0 },
|
|
|
|
|
|
findings: [],
|
|
|
|
|
|
goals: {},
|
|
|
|
|
|
signals: [],
|
2026-06-09 00:41:27 +00:00
|
|
|
|
_pollStop: null,
|
2026-08-01 17:50:45 +00:00
|
|
|
|
|
|
|
|
|
|
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());
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-06-09 00:41:27 +00:00
|
|
|
|
init() {
|
2026-08-01 17:50:45 +00:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-06-09 00:41:27 +00:00
|
|
|
|
},
|
2026-08-01 17:50:45 +00:00
|
|
|
|
|
2026-06-09 00:41:27 +00:00
|
|
|
|
params() {
|
2026-08-01 17:50:45 +00:00
|
|
|
|
const p = new URLSearchParams();
|
|
|
|
|
|
Object.entries(this.f).forEach(([k, v]) => {
|
|
|
|
|
|
if (v !== null && v !== '' && v !== undefined) p.set(k, v);
|
2026-06-09 00:41:27 +00:00
|
|
|
|
});
|
|
|
|
|
|
return p.toString();
|
|
|
|
|
|
},
|
2026-08-01 17:50:45 +00:00
|
|
|
|
|
2026-06-09 00:41:27 +00:00
|
|
|
|
async refresh(toast) {
|
|
|
|
|
|
this.loading = true;
|
|
|
|
|
|
try {
|
2026-08-01 17:50:45 +00:00
|
|
|
|
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');
|
2026-06-09 00:41:27 +00:00
|
|
|
|
} catch (e) {
|
2026-08-01 17:50:45 +00:00
|
|
|
|
if (toast !== false && window.Cockpit) Cockpit.toast(e.message || 'Laden mislukt', 'error');
|
2026-06-09 00:41:27 +00:00
|
|
|
|
} finally {
|
|
|
|
|
|
this.loading = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-08-01 17:50:45 +00:00
|
|
|
|
|
2026-06-09 00:41:27 +00:00
|
|
|
|
resetFilters() {
|
|
|
|
|
|
this.f = { chain: '', province: '', stage: '', agent: '', days: 90 };
|
|
|
|
|
|
this.refresh();
|
|
|
|
|
|
},
|
2026-08-01 17:50:45 +00:00
|
|
|
|
|
|
|
|
|
|
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 } },
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
2026-06-09 00:41:27 +00:00
|
|
|
|
};
|
|
|
|
|
|
}
|