/* Realtime inventory (24 shelves) + Shelves tab + Shelf drawer */ const STATE_LABELS = (lang) => ({ full: I18N[lang].state_full, empty: I18N[lang].state_empty, vacant: I18N[lang].state_vacant, disabled: I18N[lang].state_disabled, error: I18N[lang].state_error, }); const Inventory = ({ machine, lang }) => { const t = I18N[lang]; const [selected, setSelected] = React.useState(null); const sl = STATE_LABELS(lang); // Mobil: detaljpanelet ligger UNDER hylle-flisene når kolonnene stables — // scroll det inn når en hylle velges, ellers ser det ut som ingenting skjer. const detailRef = React.useRef(null); const velgHylle = React.useCallback((sh) => { setSelected(sh); if (sh && window.matchMedia('(max-width: 900px)').matches) { setTimeout(() => detailRef.current?.scrollIntoView( { behavior: 'smooth', block: 'start' }), 60); } }, []); // EKTE sanntid (2026-09-03): poll hyllene hvert 10. sek. Før var visningen // et statisk øyeblikksbilde fra innlogging — innkomne returflasker // (empty_bottle) fra kundebytter dukket aldri opp. const [rev, setRev] = React.useState(0); const [lastSync, setLastSync] = React.useState(Date.now()); const syncShelves = React.useCallback(async () => { try { const fresh = await window.api.refreshMachineShelves(machine.id); machine.shelves.splice(0, machine.shelves.length, ...fresh); setSelected(sel => sel ? (fresh.find(f => f.rawShelfId === sel.rawShelfId) || null) : null); setLastSync(Date.now()); setRev(r => r + 1); } catch (e) { /* nettverksglipp — prøver igjen neste tick */ } }, [machine]); React.useEffect(() => { const iv = setInterval(syncShelves, 10000); return () => clearInterval(iv); }, [syncShelves]); // Dynamic counts: state-counters + per-bottle-code counters // (vi vet ikke navnene på flasketypene før vi har sett dataen, så vi // bygger en map per kode → {name, image, full, empty}) const { counts, byType } = React.useMemo(() => { const c = { full: 0, empty: 0, vacant: 0, disabled: 0, error: 0 }; const t = {}; // code → { name, image, full, empty } machine.shelves.forEach(s => { c[s.state] = (c[s.state] || 0) + 1; if (s.bottle) { if (!t[s.bottle]) t[s.bottle] = { name: s.bottleName || s.bottle, image: s.bottleImage, full: 0, empty: 0 }; if (s.state === 'full') t[s.bottle].full++; if (s.state === 'empty') t[s.bottle].empty++; } }); return { counts: c, byType: t }; }, [machine.shelves, rev]); const bottleEntries = Object.entries(byType).sort((a, b) => (b[1].full + b[1].empty) - (a[1].full + a[1].empty)); return ( <>

{t.tab_inventory}

{/* Totaler for HELE automaten (ikke per seksjon) — samme typografi for full/tom/ledig. */} {counts.full} full {counts.empty} tom {counts.vacant} ledig {t.live} {lang === 'no' ? 'Oppdatert' : 'Updated'} {Math.max(0, Math.round((Date.now() - lastSync) / 1000))}s {lang === 'no' ? 'siden' : 'ago'}
{sl.full} ({counts.full}) {sl.empty} ({counts.empty}) {sl.vacant} ({counts.vacant}) {sl.disabled} ({counts.disabled}) {sl.error} ({counts.error})

{lang === 'no' ? 'Beholdning' : 'Inventory'}

{bottleEntries.length === 0 && (
{lang === 'no' ? 'Ingen flasker registrert' : 'No bottles registered'}
)} {bottleEntries.map(([code, info]) => ( 0 ? `${info.empty} ${sl.empty.toLowerCase()}` : null} val={info.full} /> ))}
{selected ? (
setSelected(null)} onReload={machine._onReload} onRefresh={syncShelves} />
) : (

{lang === 'no' ? 'Velg en hylle' : 'Select a shelf'}

{lang === 'no' ? 'Klikk en hylle for detaljer, kamerabilde og styring' : 'Click a shelf for details, camera image and control'}
)}
); }; const InvRow = ({ color, label, val, bordered, image, sub }) => (
{image ? ( { e.target.style.display = 'none'; }} /> ) : ( )} {label} {sub && {sub}} {val}
); const Cabinets = ({ machine, selected, onSelect, lang }) => { const t = I18N[lang]; const A = machine.shelves.filter(s => s.cabinet === 'A'); const B = machine.shelves.filter(s => s.cabinet === 'B'); return (
); }; const Cabinet = ({ title, sub, shelves, selected, onSelect, lang }) => { const sl = STATE_LABELS(lang); // Totaler vises samlet i panel-hodet (2026-09-04) — ikke per seksjon. return (
{title}
{sub}
{shelves.map(sh => ( ))}
); }; /* En enkelt hylle — kompakt flis med tydelig flasketype i ramma */ const ShelfTile = ({ sh, selected, onSelect, sl }) => { const bottleImg = sh.bottleImage ? `/app/img/bottles/${sh.bottleImage}` : null; const isFull = sh.state === 'full'; const isEmpty = sh.state === 'empty'; const isVacant = sh.state === 'vacant'; const isError = sh.state === 'error'; const isDisabled = sh.state === 'disabled'; const stateClass = `state-${sh.state}`; // Kort, tydelig typeetikett: «11 kg» + materiale (Stål/Kompositt/…) const wt = sh.bottle ? window.bottleHelpers?.weightKg(sh.bottle) : null; const mat = sh.bottle ? window.bottleHelpers?.material(sh.bottle) : null; const matLabel = mat ? (window.bottleHelpers?.materialLabels?.[mat]?.single || '') : ''; return (
onSelect(sh)}> {/* topp-stripe: dør-id og status-badge */}
{sh.id} {sl[sh.state]}
{/* hoved-innhold: lite flaskebilde + STOR typeetikett */}
{bottleImg && (isFull || isEmpty) && ( { e.target.style.display = 'none'; }} /> )} {(isFull || isEmpty) && sh.bottle && (
{wt ? `${wt} kg` : (sh.bottleName || sh.bottle)} {matLabel && {matLabel}}
)} {isVacant && (
ledig
)} {isDisabled && (
deaktivert
)} {isError && (
feil
)}
{/* hjørne-indikator */} {isFull &&
●
} {isEmpty &&
○
}
); }; const ShelfDetail = ({ shelf, machine, lang, onClose, onReload, onRefresh }) => { const t = I18N[lang]; const sl = STATE_LABELS(lang); const isSuper = !!(window.isSuperView && window.isSuperView()); const [busy, setBusy] = React.useState(null); const safe = async (label, fn) => { setBusy(label); try { await fn(); // onRefresh (hylle-poll) oppdaterer den VALGTE hylla straks — bl.a. det // nye bildet etter «Ta nytt bilde» (URL-en er versjonert på bilde-id). if (onRefresh) await onRefresh(); if (onReload) await onReload(); } catch (e) { alert(`${label}: ${e.message}`); } finally { setBusy(null); } }; return (

{lang === 'no' ? 'Hylle' : 'Shelf'} {shelf.id} · {sl[shelf.state]}

{/* Flaska i kammeret — samme illustrasjon som salgsskjermen. For test-automater ER dette den simulerte flaska. */} {shelf.bottle && (
{ e.target.onerror = null; e.target.src = '/app/img/bottles/generic.svg'; }} />
{shelf.bottleName || shelf.bottle}
{sl[shelf.state]}{machine.isTest ? ' · simulert' : ''}
)}
{/* AI-konfidens er plattform-/driftsinfo — kun superadmin */} {isSuper && ( )}
{t.shelf_actions}
{machine.kind === 'legacy' ? ( ) : (
{shelf.bottle && shelf.state === 'full' && ( )} {shelf.state !== 'error' ? ( ) : ( )}
)} {busy &&
⏳ {busy}…
}
); }; /* === LEGACY HANDLINGER === */ const LegacyShelfActions = ({ shelf, machine, onReload, busy, safe }) => { const [shadowResult, setShadowResult] = React.useState(null); const [running, setRunning] = React.useState(null); // Skygge-sjekk (v1 vs v2-ML m/ konfidenser) er plattform-diagnostikk — // kun superadmin-visning const isSuper = !!(window.isSuperView && window.isSuperView()); const doShadow = async (useExisting = false) => { setShadowResult(null); setRunning(useExisting ? 'shadow-existing' : 'shadow'); try { const door = shelf.door || shelf.rawDoor; const r = await window.api.legacyShadowCheck(machine.id, door, 30, useExisting); setShadowResult(r); if (!useExisting && onReload) await onReload(); } catch (e) { alert('Skygge-sjekk: ' + (e?.message || JSON.stringify(e))); } finally { setRunning(null); } }; const doCheck = () => safe('Ta nytt bilde (v1)', () => window.api.legacyCheckDoor(machine.id, shelf.door)); const doOpen = () => { if (!confirm( `⚠ ÅPNER FAKTISK DØR #${shelf.door} på ${machine.location}!\n\n` + `Dette KAN forstyrre en pågående kunde-handel. Sikker?` )) return; if (!confirm('Bekreft enda en gang — døren åpnes umiddelbart.')) return; safe('Dør åpnet', () => window.api.legacyOpenDoor(machine.id, shelf.door)); }; return ( <>
ℹ v1 (gammel) automat — i drift. Kommandoer skrives til Firestore og kan ta 2-15 sekunder før v1 RPi plukker dem opp.
{isSuper && (<> )}
{running === 'shadow-existing' && (
⏳ Kjører v2-ML mot eksisterende bilde…
)} {running === 'shadow' && (
⏳ Venter på at v1 tar bilde og laster opp… (opptil 30 sek)
)} {shadowResult && isSuper && } ); }; const ShadowResult = ({ result }) => { if (!result.ok) { return (
❌ Skygge-sjekk feilet: {result.reason} {result.note &&
{result.note}
}
); } const { v1, v2, agreement } = result; return (
Skygge-sjekk resultat {result.used_existing && EKSISTERENDE BILDE} {agreement.valve_match && agreement.bottle_match ? v1 og v2 ENIG ✓ : UENIGHET ⚠}
{result.ml_stub && (
⚠ v2-ML kjører i stub-modus — ingen TensorFlow-modell lastet, så v2-resultatet er tilfeldig generert. Last opp modell til /app/models/bottle_v3 og /app/models/valve_v1 for ekte ML.
)}
v1 (live)
{v1.label || '—'}
Ventil: {v1.valve || '—'} · konfidens: {v1.score != null ? (v1.score * 100).toFixed(0) + '%' : '—'}
v2 (skygge)
{v2.bottle?.label || '—'}
Ventil: {v2.valve?.label || '—'} ({((v2.valve?.score || 0) * 100).toFixed(0)}%) · Flaske: {((v2.bottle?.score || 0) * 100).toFixed(0)}%
Ventil-match: {agreement.valve_match ? '✓' : '✗'} Flaske-match: {agreement.bottle_match ? '✓' : '✗'} v2 kombinert OK: {agreement.v2_combined_ok ? '✓' : '✗'} {v2.mismatch && ⚠ v2 valve/bottle mismatch}
{/* === Detaljer: top-3 alternativer + anti-spoof + presence === */} {v2.bottle?.top3 && v2.bottle.top3.length > 0 && (
v2 top-3 alternativer (embedding cosine-sim)
{v2.bottle.top3.map((t, i) => (
{t.code}
{(t.score * 100).toFixed(1)}%
))}
{v2.bottle?.margin != null && (
Margin til 2.-plass: {(v2.bottle.margin * 100).toFixed(1)} pp {v2.bottle.margin < 0.05 && ⚠ liten margin, usikker}
)}
)} {v2.anti_spoof && (
Anti-spoof analyse {v2.anti_spoof.is_spoof ? '⚠ MISTENKELIG' : '✓ EKTE'} ({(v2.anti_spoof.spoof_score * 100).toFixed(0)}%)
{Object.entries(v2.anti_spoof.signals || {}).map(([k, v]) => (
{k}
0.55 ? 'var(--warn)' : 'inherit' }}> {(v * 100).toFixed(0)}%
))}
{v2.anti_spoof.reasons?.length > 0 && (
⚠ {v2.anti_spoof.reasons.join(' · ')}
)}
)} {v2.presence && (
Objekt-presence: {v2.presence.has_bottle ? '✓' : '—'} ({(v2.presence.confidence * 100).toFixed(0)}% bottle-lignende)
)} {v2.pipeline_version && (
Pipeline: {v2.pipeline_version} {v2.using_embedding_classifier && ' · embedding cosine-sim'}
)}
); }; /* Force-set bottle type dropdown. 2026-09-04: (a) lista rendres position:fixed ut fra knappen så den ALDRI klippes av panel-ramma (før ble den skjult bak hylle-info); (b) viser kun flasketyper som er i bruk på automaten (sortimentet + det som står i hyllene) — ikke hele plattform-katalogen. */ const BottleOverride = ({ shelf, machine, lang, onDone }) => { const [open, setOpen] = React.useState(false); const [pos, setPos] = React.useState(null); const [inUse, setInUse] = React.useState(null); // Set av bottle-koder const btnRef = React.useRef(null); React.useEffect(() => { let dau = false; // Koder som står i automaten akkurat nå (alltid relevante) const shelfCodes = new Set((machine.shelves || []) .map(s => s.bottle).filter(Boolean)); if (!machine.tenantId) { setInUse(shelfCodes); return; } window.api.listPriceOverrides(machine.tenantId, machine.id) .then(rows => { if (dau) return; rows.filter(r => r.offered).forEach(r => shelfCodes.add(r.code)); setInUse(new Set(shelfCodes)); }) .catch(() => { if (!dau) setInUse(shelfCodes); }); return () => { dau = true; }; }, [machine.id]); const bottles = (window.DATA?.BOTTLE_TYPES || []) .filter(b => !inUse || inUse.size === 0 || inUse.has(b.id)); const toggle = () => { if (!open && btnRef.current) { const r = btnRef.current.getBoundingClientRect(); // Åpne opp/ned avhengig av plass; fixed = klippes aldri av paneler. // maxHeight klemmes mot FAKTISK plass (lav viewport / telefon i landskap). const plassNed = window.innerHeight - r.bottom - 12; const plassOpp = r.top - 12; const nedover = plassNed >= Math.min(300, plassOpp) || plassNed >= 200; setPos({ left: Math.max(8, Math.min(r.left, window.innerWidth - 260)), top: nedover ? r.bottom + 4 : null, bottom: nedover ? null : (window.innerHeight - r.top + 4), maxH: Math.max(120, Math.min(300, nedover ? plassNed : plassOpp)), }); } setOpen(o => !o); }; return ( <> {open && pos && ( <>
setOpen(false)} />
{bottles.length === 0 && (
{lang === 'no' ? 'Ingen flasketyper i sortimentet' : 'No bottle types in assortment'}
)} {bottles.map(b => (
{ setOpen(false); try { await window.api.overrideShelf(machine.id, shelf.rawShelfId, b.id, 'full'); if (onDone) await onDone(); } catch (e) { alert(e.message); } }}> {b.name}
))}
)} ); }; const KVP = ({ label, value, colspan = 1 }) => (
{label}
{value}
); /* Camera shot — EKTE bilde fra backend. Ingen tegnet SVG-mock lenger (mock-revisjon 2026-09-04): finnes ikke bilde, sier vi det ærlig. */ const CamShot = ({ machineId, shelfId, cameraId, bottle, state, lang, realImageUrl }) => { const [imgSrc, setImgSrc] = React.useState(null); const [feil, setFeil] = React.useState(null); React.useEffect(() => { if (!realImageUrl) { setImgSrc(null); setFeil(null); return; } let cancelled = false; setFeil(null); window.authedImageUrl(realImageUrl).then(url => { if (!cancelled) setImgSrc(url); }) .catch(e => { if (!cancelled) { setImgSrc(null); setFeil(e.message); } }); return () => { cancelled = true; }; }, [realImageUrl]); if (imgSrc) { return (
CAM-{cameraId} · {shelfId}
{lang === 'no' ? 'Sist tatt' : 'Last taken'}
); } return (
{feil ? (lang === 'no' ? `Kunne ikke hente bildet (${feil})` : `Could not load image (${feil})`) : (lang === 'no' ? 'Ingen bilde tatt av denne hyllen ennå — bruk «Ta nytt bilde»' : 'No image captured for this shelf yet')}
CAM-{cameraId} · {shelfId}
); }; const BottleSvg = ({ type, x, y, scale = 1, faded = false }) => { // Farge + størrelse fra material + vekt (utledet fra bottle.code) const mat = window.bottleHelpers?.material(type) || 'unknown'; const weight = window.bottleHelpers?.weightKg(type); const color = ({ stal: '#94a3b8', lettstal: '#cbd5e1', kompositt: '#fb923c', aluminium: '#60a5fa', })[mat] || '#f59e0b'; // Liten flaske = lite høyde, stor = mye const w = weight && weight <= 5 ? 38 : (weight && weight >= 11 ? 48 : 44); const h = weight && weight <= 5 ? 68 : (weight && weight >= 11 ? 95 : 82); const shortLabel = weight ? `${weight}kg` : '?'; return ( {shortLabel} ); }; /* Liten EKTE hyllebilde-thumbnail (lazy, nedskalert server-side) */ const ShelfThumb = ({ shelf }) => { const [src, setSrc] = React.useState(null); const [visible, setVisible] = React.useState(false); const ref = React.useRef(null); const url = shelf.lastImageUrl ? shelf.lastImageUrl + (shelf.lastImageUrl.includes('?') ? '&' : '?') + 'size=96' : null; React.useEffect(() => { if (!ref.current || !url) return; const obs = new IntersectionObserver(entries => { entries.forEach(e => { if (e.isIntersecting) { setVisible(true); obs.disconnect(); } }); }, { rootMargin: '200px' }); obs.observe(ref.current); return () => obs.disconnect(); }, [url]); React.useEffect(() => { if (!visible || !url) return; let cancelled = false; window.authedImageUrl(url) .then(u => { if (!cancelled) setSrc(u); }) .catch(() => { if (!cancelled) setSrc(null); }); return () => { cancelled = true; }; }, [url, visible]); return (
{src ? ( ) : url ? null : ( )}
); }; /* ===== Shelves tab — table with last image thumb and per-shelf actions ===== */ const ShelvesTab = ({ machine, lang }) => { const t = I18N[lang]; const sl = STATE_LABELS(lang); const isSuper = !!(window.isSuperView && window.isSuperView()); const [filter, setFilter] = React.useState('all'); const [open, setOpen] = React.useState(null); const filtered = machine.shelves.filter(s => filter === 'all' || s.state === filter); // CSV-eksport av hyllelista, klientside (2026-09-05 — knappen hadde ingen // handler før). Kolonner: hylle, dør, kamera, status, flaske, sist bilde. const exportCsv = () => { const cell = v => `"${String(v ?? '').replace(/"/g, '""')}"`; const lines = [ ['Hylle', 'Dør', 'Kamera', 'Status', 'Flaske', 'Sist bilde'].join(';'), ...filtered.map(sh => [ sh.id, sh.rawDoor, `CAM-${sh.cameraId}`, sl[sh.state] || sh.state, sh.bottleName || sh.bottle || '', sh.lastEventAt !== '—' ? sh.lastEventAt : '', ].map(cell).join(';')), ].join('\n'); // BOM så Excel leser UTF-8 (æøå) riktig const blob = new Blob(['\ufeff' + lines], { type: 'text/csv;charset=utf-8' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `hyller-${machine.id}.csv`; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000); }; return ( <>
{lang === 'no' ? 'Alle 24 hyller' : 'All 24 shelves'}
{filtered.length}/{machine.shelves.length}
{/* «Lås»-kolonnen er fjernet (2026-09-05): lockState var en konstant 'locked' uten kilde i backend. */} {filtered.map(sh => ( setOpen(sh)} style={{ cursor: 'pointer' }}> ))}
{lang === 'no' ? 'Hylle' : 'Shelf'} {lang === 'no' ? 'Status' : 'Status'} {lang === 'no' ? 'Innhold' : 'Contents'} {lang === 'no' ? 'Kamera' : 'Camera'}{t.last_event}
{/* EKTE hyllebilde-thumbnail (2026-09-04) — før: svart boks med tegnet SVG-flaske. */}
{sh.id}
{sh.cabinet} · R{sh.row + 1}/K{sh.col + 1}
{sl[sh.state]} {sh.bottleName ? {sh.bottleName} : —} CAM-{sh.cameraId} {sh.lastEvent}· {sh.lastEventAt} e.stopPropagation()}> {/* Ekte handlinger (mock-revisjon 2026-09-04) — samme endepunkter som Lagerstatus-fanen bruker. */}
{open && ( setOpen(null)} title={`${lang === 'no' ? 'Hylle' : 'Shelf'} ${open.id}`}> {/* Ekte hyllebilde (mock-revisjon 2026-09-04): siste ROI-utsnitt fra backend i stedet for tegnet SVG med falsk REC-HUD. */}
{/* AI-konfidens er plattforminfo — kun superadmin */} {isSuper && ( )}
{t.shelf_actions}
)} ); }; const Drawer = ({ title, onClose, children }) => ( <>

{title}

{children}
); Object.assign(window, { Inventory, ShelvesTab, BottleSvg, CamShot, Drawer });