/* 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 => (
setDays(d)}>{d} 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 => (
toggle(m.id)}>
{selected.has(m.id) ? '✓ ' : ''}{m.location}
{m.kind === 'legacy' ? 'v1' : 'v2'}
))}
{allMachines.length === 0 && (
{lang === 'no' ? 'Ingen automater' : 'No automats'}
)}
{/* Sammenligning-tabell */}
{selList.length > 0 && (
{lang === 'no' ? 'Nøkkeltall' : 'Key figures'}
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'}
{selList.map(aid => {
const m = allMachines.find(x => x.id === aid);
const s = stats[aid] || {};
return (
{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 (
);
})}
00 06 12 18 23
);
})}
{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 (