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