Files
foodlinkk-command-center/cockpit/static/js/analytics.js
T

230 lines
7.9 KiB
JavaScript
Raw Normal View History

/**
* 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 } },
},
},
});
},
};
}