5e43b6b246
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).
230 lines
7.9 KiB
JavaScript
230 lines
7.9 KiB
JavaScript
/**
|
||
* 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 } },
|
||
},
|
||
},
|
||
});
|
||
},
|
||
};
|
||
}
|