/* Analytics-side: customer-funnel + refill-stats + stock-forecast + eksport-knapper */ const AnalyticsPage = ({ lang, tenant, dataVersion }) => { const [tab, setTab] = React.useState('customer'); return (

{lang === 'no' ? 'Statistikk' : 'Analytics'}

{tenant?.name || (lang === 'no' ? 'Alle' : 'All')}
{[ { k: 'compare', label: lang === 'no' ? 'Sammenlign automater' : 'Compare automats', icon: 'chart' }, { k: 'customer', label: lang === 'no' ? 'Kunde-funnel' : 'Customer funnel', icon: 'users' }, { k: 'refill', label: lang === 'no' ? 'Fyll-statistikk' : 'Refill stats', icon: 'bottle' }, { k: 'stock', label: lang === 'no' ? 'Lager-prognose' : 'Stock forecast', icon: 'inventory' }, { k: 'exports', label: lang === 'no' ? 'CSV-eksport' : 'Exports', icon: 'download' }, ].map(t => (
setTab(t.k)}> {t.label}
))}
{tab === 'compare' && } {tab === 'customer' && } {tab === 'refill' && } {tab === 'stock' && } {tab === 'exports' && }
); }; /* === Sammenlign automater + tid-på-døgnet / ukedag === */ const CompareAutomats = ({ tenant, lang, dataVersion }) => { const allMachines = (DATA.machines || []).filter(m => !tenant || tenant.id === '__all__' || m.tenantId === tenant.id); const [selected, setSelected] = React.useState(() => new Set(allMachines.slice(0, 3).map(m => m.id))); const [days, setDays] = React.useState(30); const [byMachine, setByMachine] = React.useState({}); const [loading, setLoading] = React.useState(false); // Feil per automat VISES (2026-09-05) — før svelget .catch(() => []) 422-feilen // stille og tabellen sto på «0 salg» for alltid. const [errors, setErrors] = React.useState({}); React.useEffect(() => { const ids = [...selected]; if (ids.length === 0) { setByMachine({}); setErrors({}); return; } setLoading(true); // Backend tillater maks limit=500 (Query(le=500)); 2000 ga 422 → alltid tomt. Promise.all(ids.map(aid => window.api.listOrders({ automat_id: aid, days, limit: 500 }) .then(rows => [aid, rows || [], null]) .catch(e => [aid, [], (e && e.message) || String(e)]) )).then(triples => { setByMachine(Object.fromEntries(triples.map(([aid, rows]) => [aid, rows]))); setErrors(Object.fromEntries(triples.filter(x => x[2]).map(([aid, _rows, err]) => [aid, err]))); setLoading(false); }); }, [[...selected].sort().join(','), days, dataVersion]); const toggle = (id) => { const next = new Set(selected); if (next.has(id)) next.delete(id); else next.add(id); setSelected(next); }; // Beregn per-automat aggregater const stats = React.useMemo(() => { const out = {}; for (const [aid, rows] of Object.entries(byMachine)) { const success = rows.filter(r => r.session_state === 'complete' && (r.price_ore || 0) > 0); const sumKr = success.reduce((s, r) => s + (r.price_ore / 100), 0); // Tid-på-døgnet (0..23) const hourly = Array.from({ length: 24 }, () => 0); // Ukedag (0=søn..6=lør) — vi viser man..søn-rekkefølge const weekday = Array.from({ length: 7 }, () => 0); success.forEach(r => { const d = new Date(r.created_at); hourly[d.getHours()]++; weekday[d.getDay()]++; }); // Beste dag + beste time let bestHour = 0, bestHourN = 0; hourly.forEach((n, h) => { if (n > bestHourN) { bestHourN = n; bestHour = h; } }); let bestDay = 0, bestDayN = 0; weekday.forEach((n, d) => { if (n > bestDayN) { bestDayN = n; bestDay = d; } }); out[aid] = { total: rows.length, success: success.length, revenue: sumKr, avgOrder: success.length ? sumKr / success.length : 0, hourly, weekday, bestHour, bestDay, }; } return out; }, [byMachine]); const selList = [...selected]; const DAY_LABELS = lang === 'no' ? ['Søn', 'Man', 'Tir', 'Ons', 'Tor', 'Fre', 'Lør'] : ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; // Max for heat-skalering på tvers av valgte automater const maxHour = Math.max(1, ...selList.flatMap(id => stats[id]?.hourly || [0])); const maxDay = Math.max(1, ...selList.flatMap(id => stats[id]?.weekday || [0])); // Automater som traff backend-taket (500 rader) — tallene er da ufullstendige const nameOf = (aid) => (allMachines.find(m => m.id === aid) || {}).location || aid; const truncated = selList.filter(id => (byMachine[id] || []).length >= 500); return (
{[7, 30, 90, 180].map(d => ( ))}
{selected.size} {lang === 'no' ? 'automater valgt' : 'automats picked'} {loading && ' · ⏳'}
{Object.keys(errors).length > 0 && (
❌ {lang === 'no' ? 'Kunne ikke hente salg for' : 'Could not load sales for'}{' '} {Object.entries(errors).map(([aid, e]) => `${nameOf(aid)} (${e})`).join(', ')}
)} {truncated.length > 0 && (
⚠ {lang === 'no' ? 'Viser de 500 siste salgene for' : 'Showing the latest 500 sales for'}{' '} {truncated.map(nameOf).join(', ')} {lang === 'no' ? ' — velg kortere periode for komplette tall.' : ' — pick a shorter period for complete figures.'}
)} {/* Velg automater */}

{lang === 'no' ? 'Velg automater å sammenligne' : 'Pick automats to compare'}

{allMachines.map(m => ( ))} {allMachines.length === 0 && ( {lang === 'no' ? 'Ingen automater' : 'No automats'} )}
{/* Sammenligning-tabell */} {selList.length > 0 && (

{lang === 'no' ? 'Nøkkeltall' : 'Key figures'}

{selList.map(aid => { const m = allMachines.find(x => x.id === aid); const s = stats[aid] || {}; return ( ); })}
Automat {lang === 'no' ? 'Salg' : 'Sales'} {lang === 'no' ? 'Vellykket' : 'OK'} {lang === 'no' ? 'Omsetning' : 'Revenue'} {lang === 'no' ? 'Snitt/ordre' : 'Avg/order'} {lang === 'no' ? 'Beste dag' : 'Best day'} {lang === 'no' ? 'Beste time' : 'Best hour'}
{m?.location || aid}
{aid}
{s.total || 0} {s.success || 0} {fmtKr(s.revenue || 0, lang)} {fmtKr(s.avgOrder || 0, lang)} {s.success ? DAY_LABELS[s.bestDay] : —} {s.success ? `${String(s.bestHour).padStart(2,'0')}–${String(s.bestHour+1).padStart(2,'0')}` : —}
)} {/* Tid-på-døgnet heatmap (24t) per automat */} {selList.length > 0 && (

{lang === 'no' ? 'Tid på døgnet' : 'Time of day'}

{lang === 'no' ? 'antall salg per time' : 'orders per hour'}
{selList.map(aid => { const m = allMachines.find(x => x.id === aid); const h = stats[aid]?.hourly || []; return (
{m?.location || aid} {(h.reduce((a, b) => a + b, 0))} {lang === 'no' ? 'salg' : 'sales'}
{h.map((n, i) => { const pct = n / maxHour; return (
); })}
0006121823
); })}

{lang === 'no' ? 'Ukedag' : 'Day of week'}

{selList.map(aid => { const m = allMachines.find(x => x.id === aid); const wd = stats[aid]?.weekday || []; // Vis man..søn (1..6, 0) const order = [1, 2, 3, 4, 5, 6, 0]; return (
{m?.location || aid}
{order.map(i => { const n = wd[i] || 0; const pct = n / maxDay; return (
0 ? '#000' : 'var(--text-faint)', }}>
{DAY_LABELS[i]}
{n}
); })}
); })}
)}
); }; const CustomerAnalytics = ({ tenant, lang }) => { const [funnel, setFunnel] = React.useState(null); const [visitors, setVisitors] = React.useState([]); const [days, setDays] = React.useState(7); React.useEffect(() => { const tid = tenant?.id !== '__all__' ? tenant.id : undefined; const params = { days, ...(tid ? { tenant_id: tid } : {}) }; window.api.funnel(params).then(setFunnel).catch(() => {}); window.api.uniqueVisitors(days, tid).then(setVisitors).catch(() => {}); }, [tenant?.id, days]); return (
{[7, 30, 90].map(d => ( ))}

Konverterings-funnel

{funnel?.funnel?.map((step, i) => { const max = funnel.funnel[0]?.sessions || 1; const pct = (step.sessions / max) * 100; return (
{step.step} {step.sessions} ({pct.toFixed(0)}%)
); })} {!funnel?.funnel?.length &&
Ingen data
}

Unike brukere per dag

{visitors.slice(-14).map(v => ( ))} {!visitors.length && }
DatoUnikeØkter
{v.day} {v.unique_users} {v.sessions}
Ingen data
); }; const RefillAnalytics = ({ tenant, lang }) => { const [stats, setStats] = React.useState(null); const [days, setDays] = React.useState(90); React.useEffect(() => { const params = { days, ...(tenant?.id !== '__all__' ? { tenant_id: tenant.id } : {}) }; window.api.refillStats(params).then(setStats).catch(() => {}); }, [tenant?.id, days]); if (!stats) return
⏳ Laster…
; const s = stats.summary; return (
{[30, 90, 180, 365].map(d => ( ))}
Planer
{s.total_plans}
Fullført
{s.completed_plans} ({s.completion_rate_pct}%)
Stops
{s.total_stops}
Avstand
{s.total_distance_km} km
Snitt tid/stop
{s.avg_fill_min_per_stop} min
Flasker satt inn
{s.bottles_inserted}

Per måned

{stats.by_month.map(m => ( ))}
MånedPlanerFullførtStopskmDrive-min
{m.month} {m.plans} {m.completed} {m.stops} {Math.round(m.distance_km)} {m.drive_min}
); }; const StockForecastPanel = ({ tenant, lang }) => { const [data, setData] = React.useState(null); const [busy, setBusy] = React.useState(false); const refresh = React.useCallback(async () => { if (!tenant || tenant.id === '__all__') return; setBusy(true); try { setData(await window.api.stockForecast(tenant.id)); } catch (e) { console.warn(e); } setBusy(false); }, [tenant?.id]); React.useEffect(() => { refresh(); }, [refresh]); const runScan = async () => { try { const r = await window.api.stockForecastScan(); alert(`Skannet ${r.automats_scanned} automater · ${r.alerts_sent} alarmer sendt`); await refresh(); } catch (e) { alert(e.message); } }; if (!tenant || tenant.id === '__all__') { return
Velg en spesifikk tenant
; } if (!data) return
⏳ Laster…
; return (
{/* Sender EKTE alarmer og krever superadmin i backend (403 for andre) — skjules derfor for tenant-admin (2026-09-05). */} {window.isSuperView && window.isSuperView() && ( )}
{data.map((r, i) => ( ))} {!data.length && }
AutomatFlaske På lager Snitt/dag Dager igjen Status
{r.location} {r.bottle_code} {r.current_stock} {r.avg_daily_sales} {r.days_until_empty} {r.severity === 'stockout' && ⚠ Tom snart} {r.severity === 'critical' && Kritisk} {r.severity === 'low' && Lav} {r.severity === 'ok' && OK}
Ingen data
); }; const ExportsPanel = ({ tenant, lang }) => { // Send valgt tenant til alle eksporter som støtter det, så CSV-ene matcher // tenant-konteksten i UI (ikke dumper alle tenants). const tp = tenant?.id !== '__all__' ? { tenant_id: tenant.id } : {}; const downloads = [ { label: 'Ordrer (siste 30 dager)', url: window.api.exportOrdersUrl({ days: 30, ...tp }), file: 'orders.csv' }, { label: 'Refunds (siste 90 dager)', url: window.api.exportRefundsUrl({ days: 90, ...tp }), file: 'refunds.csv' }, // tenant_id sendes også for audit/kamera-helse/e-post (2026-09-05) — før // fikk superadmin plattform-bred CSV selv med én tenant valgt. { label: 'Audit-log (siste 30 dager)', url: window.api.exportAuditUrl({ days: 30, ...tp }), file: 'audit.csv' }, { label: 'Fyll-planer (siste 180 dager)', url: window.api.exportRefillPlansUrl({ days: 180, ...tp }), file: 'refill-plans.csv' }, { label: 'Kamera-helse (siste 30 dager)', url: window.api.exportCameraHealthUrl({ days: 30, ...tp }), file: 'camera-health.csv' }, { label: 'E-post-log (siste 30 dager)', url: window.api.exportEmailLogUrl({ days: 30, ...tp }), file: 'email-log.csv' }, ]; const dl = async (e) => { e.preventDefault(); try { await window.api.downloadWithAuth(e.target.dataset.url, e.target.dataset.file); } catch (err) { alert(err.message); } }; return (

Eksport til CSV

{downloads.map(d => (
{d.label} Last ned
))}
); }; Object.assign(window, { AnalyticsPage, CompareAutomats });