/* Tenant overview page: KPIs, machines grid/list, alarms */ const Overview = ({ lang, onOpenMachine, tenant, dataVersion }) => { const t = I18N[lang]; // Bruk filtrert liste (basert på valgt tenant), faller tilbake til alle. // Filtrer eksplisitt fra prop for å unngå å lese mutert DATA.machinesView // som ikke trigger re-render ved tenant-bytte. const activeTenant = tenant || DATA.activeTenant || { id: '__all__' }; const machines = React.useMemo(() => { const all = DATA.machines || []; if (!activeTenant || activeTenant.id === '__all__') return all; return all.filter(m => m.tenantId === activeTenant.id); }, [activeTenant.id, dataVersion]); // Varsler KUN for valgt tenants automater (2026-09-04: før lekket varsler // fra alle tenants inn i oversikten og telleren når superadmin så som // tenant / brukeren var medlem av flere tenants). const ALARMS = React.useMemo(() => { const ids = new Set(machines.map(m => m.id)); return (DATA.ALARMS || []).filter(a => ids.has(a.machineId)); }, [machines]); const [view, setView] = React.useState('grid'); const [filter, setFilter] = React.useState('all'); // Område-filter + multi-select for bulk-redigering const [areaFilter, setAreaFilter] = React.useState(''); const [selectedIds, setSelectedIds] = React.useState(() => new Set()); const [bulkOpen, setBulkOpen] = React.useState(false); const areas = React.useMemo(() => { const all = DATA.AREAS || []; if (!activeTenant || activeTenant.id === '__all__') return all; return all.filter(a => a.tenant_id === activeTenant.id); }, [activeTenant.id, dataVersion]); const toggleSelect = (id) => setSelectedIds(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); // Beregn KPIer for FILTRERT sett (per tenant) const k = React.useMemo(() => { const empty = machines.reduce((s, m) => s + (m.emptyCount || 0), 0); const vacant = machines.reduce((s, m) => s + (m.vacantCount || 0), 0); const todayRev = machines.reduce((s, m) => s + (m.todayRev || 0), 0); const monthRev = machines.reduce((s, m) => s + (m.monthRev || 0), 0); const fullTotal = machines.reduce((s, m) => s + (m.fullTotal || 0), 0); const byMaterial = {}; machines.forEach(m => Object.entries(m.fullByMaterial || {}).forEach(([mat, n]) => { byMaterial[mat] = (byMaterial[mat] || 0) + n; })); // Ekte 14-dagers omsetningskurve: sum av automatenes spark (kr/dag). // (Før: hardkodede tall — falsk graf.) const spark = Array.from({ length: 14 }, (_, i) => machines.reduce((s, m) => s + (((m.spark || [])[i]) || 0), 0)); const hasSpark = spark.some(v => v > 0); return { empty, vacant, todayRev, monthRev, fullTotal, byMaterial, spark: hasSpark ? spark : null }; }, [machines]); const filtered = React.useMemo(() => { let list = machines; if (areaFilter === '__none__') list = list.filter(m => !m.areaId); else if (areaFilter) list = list.filter(m => m.areaId === areaFilter); if (filter === 'all') return list; if (filter === 'legacy') return list.filter(m => m.kind === 'legacy'); if (filter === 'v2') return list.filter(m => m.kind === 'v2'); return list.filter(m => m.status === filter); }, [filter, areaFilter, machines]); // Sorter materialer etter antall (synker), dropp nuller const matLabels = window.bottleHelpers.materialLabels; const materialKpis = Object.entries(k.byMaterial) .filter(([_, n]) => n > 0) .sort((a, b) => b[1] - a[1]); const kpis = [ { label: t.kpi_today_rev, value: fmtKr(k.todayRev, lang), dir: 'neutral' }, { label: t.kpi_month_rev, value: fmtKr(k.monthRev, lang), dir: 'neutral', spark: k.spark || undefined }, { label: lang === 'no' ? 'Fulle totalt' : 'Full total', value: fmtNum(k.fullTotal, lang), unit: lang === 'no' ? 'stk' : 'pcs', dir: 'neutral' }, ...materialKpis.slice(0, 2).map(([mat, n]) => ({ label: (lang === 'no' ? 'Fulle ' : 'Full ') + (matLabels[mat]?.plural || mat).toLowerCase(), value: fmtNum(n, lang), unit: lang === 'no' ? 'stk' : 'pcs', dir: 'neutral', })), { label: t.kpi_empty, value: fmtNum(k.empty, lang), unit: lang === 'no' ? 'stk' : 'pcs', dir: 'neutral' }, { label: t.kpi_vacant, value: fmtNum(k.vacant, lang), unit: lang === 'no' ? 'hyller' : 'shelves', dir: 'neutral' }, ]; // (Onboarding-progress brukes ikke her — vises på Plattform-siden i stedet) return (

{t.overview_title}

{t.overview_sub} · {t.live}
{/* Export laster ned ordre-CSV for valgt tenant. «Legg til automat» er fjernet her — tenant_admin kan ikke opprette automater, og superadmin gjør det via Innstillinger (unngår en død CTA). */}
{/* Empty state hvis ingen automater */} {machines.length === 0 && (

{lang === 'no' ? 'Ingen automater ennå' : 'No automats yet'}

{lang === 'no' ? 'Denne tenanten har ingen automater tildelt. Be superadmin om å tildele eller opprette en.' : 'This tenant has no automats. Ask superadmin to assign or create one.'}

)} {machines.length > 0 && (
{kpis.map((k, i) => (
{k.label}
{k.value}{k.unit && {k.unit}}
{/* «vs forrige» vises kun når en delta faktisk er beregnet (2026-09-05) — før sto teksten alltid der med tomt tall. */} {k.delta !== undefined && (
{k.dir === 'up' && } {k.dir === 'down' && } {k.delta} {lang === 'no' ? 'vs forrige' : 'vs prev'}
)} {k.spark &&
}
))}
)} {machines.length > 0 && (
{/* Machines panel */}
{t.all_machines} {filtered.length} / {machines.length}
{/* Område-filter (sortér automatene i områder for enklere kontroll) */} {areas.length > 0 && ( )}
{/* Bulk-verktøylinje: vises når automater er valgt (liste-visning) */} {selectedIds.size > 0 && (
{selectedIds.size} {lang === 'no' ? 'valgt' : 'selected'}
)} {view === 'grid' ? (
{filtered.map(m => ( onOpenMachine(m.id)} /> ))}
) : ( )}
{bulkOpen && ( selectedIds.has(m.id))} areas={areas} onClose={() => setBulkOpen(false)} onDone={() => { // Bulk endrer priser/områder som er cachet ved innlasting — // full reload er enklest og trygt for en sjelden operasjon // (samme mønster som flaskekatalog-endringer). location.reload(); }} /> )} {/* Alarms */}

{t.alarms}

{ALARMS.filter(a => a.sev === 'danger').length} {t.sev_danger.toLowerCase()} {ALARMS.filter(a => a.sev === 'warn').length}
{ALARMS.map(a => (
onOpenMachine(a.machineId)} style={{ padding: '11px 16px', borderBottom: '1px solid var(--border)', display: 'flex', gap: 10, alignItems: 'flex-start', cursor: 'pointer' }}>
{a.sev === 'danger' && } {a.sev === 'warn' && } {a.sev === 'info' && }
{lang === 'no' ? a.msg_no : a.msg_en}
{a.machineName} · {a.id}
{a.mins} {lang === 'no' ? 'min' : 'min'}
))}
)}
); }; const MachineCard = ({ m, lang, onOpen }) => { const t = I18N[lang]; return (
{m.name} {m.kind === 'legacy' && ( v1 GAMMEL )}
{m.location} · {m.id}
{m.lastSeen}
{Object.entries(m.fullByMaterial || {}) .filter(([_, n]) => n > 0) .sort((a, b) => b[1] - a[1]) .slice(0, 2) .map(([mat, n]) => (
{window.bottleHelpers.materialLabels[mat]?.plural || mat} {n}
))} {Object.keys(m.fullByMaterial || {}).length === 0 && (
{t.full} 0
)}
{t.empty}/{t.vacant} {m.emptyCount}/{m.vacantCount}
{t.today} {fmtKr(m.todayRev, lang)}
{t.month} {fmtKr(m.monthRev, lang)}
); }; /* Tiny shelf preview: 6×4 dots (compressed view of 24 shelves) */ const ShelfMini = ({ shelves }) => { const list = Array.isArray(shelves) ? shelves : []; if (list.length === 0) return null; return (
{list.map((s, i) => { let bg = 'var(--surface-3)'; if (s.state === 'full') bg = 'var(--success)'; else if (s.state === 'empty') bg = 'var(--warn)'; else if (s.state === 'error') bg = 'var(--danger)'; else if (s.state === 'disabled') bg = 'var(--text-disabled)'; return (
); })}
); }; const MachineTable = ({ machines, lang, onOpen, selectedIds, onToggleSelect }) => { const t = I18N[lang]; // Bestem materialer å vise — union av alle materialer som finnes const materials = React.useMemo(() => { const set = new Set(); machines.forEach(m => Object.keys(m.fullByMaterial || {}).forEach(k => set.add(k))); // Sorter etter total mengde synkende const totals = {}; machines.forEach(m => Object.entries(m.fullByMaterial || {}).forEach(([k, n]) => { totals[k] = (totals[k] || 0) + n; })); return [...set].sort((a, b) => (totals[b] || 0) - (totals[a] || 0)); }, [machines]); const matLabels = window.bottleHelpers.materialLabels; const selectable = !!onToggleSelect; return (
{selectable && } {materials.map(mat => ( ))} {machines.map(m => ( onOpen(m.id)} style={{ cursor: 'pointer' }}> {selectable && ( )} {materials.map(mat => ( ))} ))}
{lang === 'no' ? 'Automat' : 'Machine'} {lang === 'no' ? 'Status' : 'Status'} {lang === 'no' ? 'Område' : 'Area'}{matLabels[mat]?.plural || mat}{t.empty} {t.vacant} {t.today} {t.month} {lang === 'no' ? '14 d' : '14d'}
e.stopPropagation()} style={{ cursor: 'default' }}> onToggleSelect(m.id)} />
{m.name}
{m.id} · {m.location}
{m.areaName ? {m.areaName} : —}{(m.fullByMaterial || {})[mat] || 0} 0 ? 'var(--danger)' : 'inherit' }}>{m.emptyCount} {m.vacantCount} {fmtKr(m.todayRev, lang)} {fmtKr(m.monthRev, lang)}
); }; /* Bulk-redigering av valgte automater: område, alarm-mobiler, vedlikehold, priser (per-automat prisoverstyring). Ett kall → backend bulk-edit. */ const BulkEditModal = ({ lang, machines, areas, onClose, onDone }) => { const no = lang === 'no'; const bottles = DATA.BOTTLE_TYPES || []; const [areaId, setAreaId] = React.useState('__keep__'); const [mobiles, setMobiles] = React.useState(''); const [setMob, setSetMob] = React.useState(false); const [maint, setMaint] = React.useState('__keep__'); const [prices, setPrices] = React.useState({}); // bottle_id -> {base, min} const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(null); const setPrice = (bid, key, val) => setPrices(p => ({ ...p, [bid]: { ...(p[bid] || {}), [key]: val }, })); const submit = async () => { setBusy(true); setErr(null); try { const body = { automat_ids: machines.map(m => m.id) }; if (areaId === '__clear__') body.clear_area = true; else if (areaId !== '__keep__') body.area_id = areaId; if (maint !== '__keep__') body.maintenance_mode = maint === 'on'; if (setMob) body.admin_mobiles = mobiles.split(',').map(s => s.trim()).filter(Boolean); const po = Object.entries(prices) .map(([bid, v]) => ({ bottle_id: Number(bid), base_price: v.base ? Math.round(parseFloat(v.base) * 100) : null, minimum_price: v.min ? Math.round(parseFloat(v.min) * 100) : null, })) .filter(x => x.base_price != null || x.minimum_price != null); if (po.length) body.price_overrides = po; const r = await window.api.bulkEditAutomats(body); if (!r || !r.ok) throw new Error('uventet svar'); onDone(); } catch (e) { setErr(e.message); setBusy(false); } }; return ( <>

{no ? 'Bulk-rediger' : 'Bulk edit'} {machines.length} {no ? 'automater' : 'machines'}

{machines.slice(0, 6).map(m => m.location).join(', ')}{machines.length > 6 ? ` +${machines.length - 6}` : ''}
{no ? 'Område' : 'Area'}
{no ? 'Alarm-mobiler (SMS)' : 'Alarm mobiles (SMS)'}
{setMob && ( setMobiles(e.target.value)} style={{ marginBottom: 14 }} /> )}
{no ? 'Vedlikeholdsmodus' : 'Maintenance'}
{no ? 'Priser (overstyring per automat, kr)' : 'Prices (per-machine override, kr)'}
{no ? 'Fyll kun inn for typene som skal endres — tomme felt røres ikke.' : 'Fill only the types to change — blanks are untouched.'}
{no ? 'Type' : 'Type'}
{no ? 'Pris' : 'Price'}
{no ? 'Min.pris' : 'Min price'}
{bottles.map(b => { const bid = b._raw ? b._raw.id : b.id; // numerisk bottle_id return (
{b.name}
setPrice(bid, 'base', e.target.value)} /> setPrice(bid, 'min', e.target.value)} />
); })}
{err &&
❌ {err}
}
); }; Object.assign(window, { Overview, BulkEditModal });