/* Påfylling direkte fra automat-siden (2026-09-04).
*
* 1. Start: velg omfang (tomme/tomflaske-hyller, seksjon, eller enkeltdører)
* → dørene åpnes; kameraet tar bilde og detekterer når hver dør lukkes.
* 2. Under fylling: dør-for-dør-status (før / nå / bilde / konfidens), og
* operatøren kan RETTE feil deteksjon (setter lager + lagrer treningsbilde)
* eller ta nytt bilde.
* 3. Avslutt → rapport: hva ble tatt ut (tomflasker) og satt inn (fulle),
* per type og per dør. Historikk over tidligere fyllinger.
* 4. Kontroller lageret: gå gjennom alle hyller, bekreft eller rett.
*/
const FILL_KIND = {
satt_inn: { no: 'Satt inn', tone: 'ok' },
byttet: { no: 'Tomflaske ut, full inn', tone: 'ok' },
byttet_type: { no: 'Byttet type', tone: 'info' },
tatt_ut: { no: 'Tomflaske tatt ut', tone: 'warn' },
uendret: { no: 'Uendret', tone: 'empty' },
uendret_tomflaske: { no: 'Tomflaske står igjen', tone: 'warn' },
defekt: { no: 'Defekt / mismatch', tone: 'danger' },
ubekreftet: { no: 'Ubekreftet', tone: 'danger' },
};
const fillStatusLabel = (st) => ({
full: 'Full', empty: 'Ledig', empty_bottle: 'Tomflaske', faulty: 'Defekt',
unknown: 'Venter / usikker',
}[st] || st || '—');
/* Bilde-thumb med versjonert URL (ny bilde-id → ny henting) */
const FillThumb = ({ url, imageId, size = 64 }) => {
const [src, setSrc] = React.useState(null);
const full = url ? `${url}?v=${encodeURIComponent(imageId || '')}&size=${size * 2}` : null;
React.useEffect(() => {
if (!full) { setSrc(null); return; }
let dau = false;
window.authedImageUrl(full).then(u => { if (!dau) setSrc(u); }).catch(() => { if (!dau) setSrc(null); });
return () => { dau = true; };
}, [full]);
return (
{src ?
:
}
);
};
/* Dropdown for å rette flasketype — kun typer i automatens sortiment + Tom */
const useTenantBottles = (machine) => {
const [codes, setCodes] = React.useState(null);
React.useEffect(() => {
let dau = false;
const shelfCodes = new Set((machine.shelves || []).map(s => s.bottle).filter(Boolean));
if (!machine.tenantId) { setCodes(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)); setCodes(new Set(shelfCodes)); })
.catch(() => { if (!dau) setCodes(shelfCodes); });
return () => { dau = true; };
}, [machine.id]);
return (window.DATA?.BOTTLE_TYPES || []).filter(b => !codes || codes.size === 0 || codes.has(b.id));
};
const CorrectSelect = ({ bottles, value, onPick, disabled, lang }) => (
{ if (e.target.value) onPick(e.target.value); }}>
{lang === 'no' ? 'Rett til…' : 'Correct to…'}
{bottles.map(b => {b.name} )}
{lang === 'no' ? 'Tom / ledig hylle' : 'Empty shelf'}
);
const FillTab = ({ machine, lang }) => {
const no = lang === 'no';
const [active, setActive] = React.useState(undefined); // undefined=laster, null=ingen
const [history, setHistory] = React.useState([]);
const [report, setReport] = React.useState(null);
const [scope, setScope] = React.useState('empty'); // empty | A | B | doors
const [pick, setPick] = React.useState(() => new Set());
const [includeFaulty, setIncludeFaulty] = React.useState(false);
const [busy, setBusy] = React.useState(null);
const [err, setErr] = React.useState(null);
const bottles = useTenantBottles(machine);
const last = React.useCallback(async () => {
try {
const r = await window.api.fillActive(machine.id);
setActive(r && r.active ? r : null);
} catch (e) { setErr(e.message); }
}, [machine.id]);
const lastHist = React.useCallback(() => {
window.api.fillHistory(machine.id).then(setHistory).catch(() => {});
}, [machine.id]);
React.useEffect(() => { last(); lastHist(); }, [last, lastHist]);
React.useEffect(() => {
if (!active) return;
const iv = setInterval(last, 3000);
return () => clearInterval(iv);
}, [active && active.fill_session_id, last]);
const start = async () => {
setBusy('start'); setErr(null);
try {
const opts = { include_faulty: includeFaulty };
if (scope === 'A') opts.section = 1;
else if (scope === 'B') opts.section = 2;
else if (scope === 'doors') {
if (pick.size === 0) { setErr(no ? 'Velg minst én dør' : 'Pick at least one door'); setBusy(null); return; }
opts.doors = [...pick];
}
await window.api.fillStart(machine.id, opts);
setReport(null);
await last();
} catch (e) { setErr(e.message); }
setBusy(null);
};
const finish = async () => {
if (!active) return;
if (!confirm(no ? 'Avslutte påfyllingen og lage rapport?' : 'Finish and create report?')) return;
setBusy('finish'); setErr(null);
try {
const r = await window.api.fillFinish(machine.id, active.fill_session_id);
setReport(r.report || null);
setActive(null);
lastHist();
if (machine._onReload) await machine._onReload();
} catch (e) { setErr(e.message); }
setBusy(null);
};
const cancel = async () => {
if (!active) return;
if (!confirm(no ? 'Avbryte påfyllingen? Hyllene settes tilbake slik de var.' : 'Cancel refill?')) return;
setBusy('cancel'); setErr(null);
try {
await window.api.fillCancel(machine.id, active.fill_session_id);
setActive(null);
if (machine._onReload) await machine._onReload();
} catch (e) { setErr(e.message); }
setBusy(null);
};
const correct = async (shelf, label) => {
setBusy('c' + shelf.id); setErr(null);
try {
await window.api.confirmLabel(machine.id, shelf.id, {
label, detected: shelf.bottle?.code || null, detected_score: shelf.last_score ?? null,
});
await last();
} catch (e) { setErr(e.message); }
setBusy(null);
};
const recheck = async (shelf) => {
setBusy('r' + shelf.id); setErr(null);
try { await window.api.recheckShelf(machine.id, shelf.id); }
catch (e) { setErr(e.message); }
setBusy(null);
};
// Åpne dører (på nytt): null = alle som ikke er bekreftet åpne
const reopen = async (doors) => {
if (!active) return;
setBusy('open'); setErr(null);
try {
await window.api.fillOpenDoors(machine.id, active.fill_session_id, doors);
await last();
} catch (e) { setErr(e.message); }
setBusy(null);
};
const allShelves = machine.shelves || [];
const candidates = allShelves.filter(s => ['vacant', 'empty'].includes(s.state) || (includeFaulty && s.state === 'error'));
return (
{err &&
❌ {err}
}
{/* ===== AKTIV PÅFYLLING ===== */}
{active && (
{no ? 'Påfylling pågår' : 'Refill in progress'}
{no ? 'startet' : 'started'} {formatTs(active.started_at)} · {active.doors.length} {no ? 'dører' : 'doors'}
{no ? 'Avbryt' : 'Cancel'}
{busy === 'finish' ? '⏳' : (no ? 'Avslutt og lag rapport' : 'Finish & report')}
{no ? 'Dørene åpnes én etter én (ca. 2,5 s per dør per modul). Ta ut ev. tomflaske, sett inn full flaske og lukk døra — bildet tas og typen detekteres automatisk. Er deteksjonen feil, rett den her før du avslutter.'
: 'Doors open one by one. Swap bottles and close each door — detection runs automatically. Correct any wrong detection before finishing.'}
{(() => {
const dop = active.door_open || {};
const res = dop.doors || {};
const opened = active.doors.filter(d => (res[String(d)] || {}).opened === true);
const notOpen = active.doors.filter(d => (res[String(d)] || {}).opened !== true);
if (dop.opening) {
return (
⏳ {no ? 'Åpner dører…' : 'Opening doors…'} {opened.length}/{active.doors.length} {no ? 'bekreftet åpne' : 'confirmed open'}
);
}
if (dop.done && notOpen.length > 0) {
return (
⚠️ {notOpen.length} {no ? 'dør(er) er ikke bekreftet åpne' : 'door(s) not confirmed open'}: {notOpen.join(', ')}
reopen(null)}>
{no ? 'Åpne disse på nytt' : 'Reopen these'}
);
}
return null;
})()}
{no ? 'Dør' : 'Door'} {no ? 'Luke' : 'Hatch'} {no ? 'Før' : 'Before'} {no ? 'Nå' : 'Now'}
{no ? 'Bilde' : 'Image'} {no ? 'Rett' : 'Correct'}
{active.shelves.map(s => {
const p = (active.prior || {})[String(s.door)] || {};
const pb = (window.DATA?.BOTTLE_TYPES || []).find(b => b._raw?.id === p.bottle_id);
const isSuper = !!(window.isSuperView && window.isSuperView());
const waiting = s.status === 'unknown';
const ds = (active.door_states || {})[String(s.door)];
const dres = ((active.door_open || {}).doors || {})[String(s.door)] || {};
const hatch = ds && ds.closed === false ? 'open' : ds && ds.closed === true ? 'closed' : 'unknown';
return (
{s.door}
{hatch === 'open' ?
{no ? 'Åpen' : 'Open'}
: hatch === 'closed' ?
{no ? 'Lukket' : 'Closed'}
:
{no ? 'Ukjent' : 'Unknown'} }
{(active.door_open || {}).opening && dres.opened !== true &&
⏳ }
{!(active.door_open || {}).opening && (
reopen([s.door])}>
)}
{fillStatusLabel(p.status)}{pb ? ` · ${pb.name}` : ''}
{waiting ? {no ? 'Venter på lukking / deteksjon' : 'Waiting'}
: s.status === 'full' ? {no ? 'Full' : 'Full'} {s.bottle?.display_name || '—'}
{isSuper && s.last_score != null && {Math.round(s.last_score * 100)} % }
: s.status === 'faulty' ? {no ? 'Defekt / ventil-mismatch' : 'Faulty'}
: {fillStatusLabel(s.status)}{s.bottle ? ` · ${s.bottle.display_name}` : ''} }
correct(s, v)} />
recheck(s)} title={no ? 'Ta nytt bilde' : 'Recapture'}>
);
})}
{(() => {
const done = active.shelves.filter(s => s.status !== 'unknown').length;
return `${done}/${active.shelves.length} ${no ? 'dører ferdig' : 'doors done'}`;
})()}
)}
{/* ===== START ===== */}
{active === null && (
{no ? 'Start påfylling' : 'Start refill'}
)}
{/* ===== RAPPORT ===== */}
{report &&
setReport(null)} />}
{/* ===== HISTORIKK ===== */}
{no ? 'Tidligere påfyllinger' : 'Previous refills'}
{history.length === 0 ? (
{no ? 'Ingen påfyllinger registrert ennå' : 'No refills yet'}
) : (
{no ? 'Startet' : 'Started'} Status {no ? 'Dører' : 'Doors'}
{no ? 'Satt inn' : 'Inserted'} {no ? 'Tatt ut' : 'Removed'}
{no ? 'Ubekreftet' : 'Unconfirmed'}
{history.map(h => (
{formatTs(h.started_at)}
{h.status === 'done' ? {no ? 'fullført' : 'done'}
: h.status === 'active' ? {no ? 'pågår' : 'active'}
: {no ? 'avbrutt' : 'cancelled'} }
{h.doors}
{h.summary ? h.summary.inserted_total : '—'}
{h.summary ? h.summary.removed_total : '—'}
0 ? 'var(--danger)' : 'inherit' }}>{h.summary ? h.summary.unconfirmed : '—'}
window.api.fillReport(machine.id, h.fill_session_id).then(setReport).catch(e => setErr(e.message))}>
{no ? 'Vis rapport' : 'Report'}
))}
)}
{/* ===== KONTROLLER LAGERET ===== */}
);
};
const FillReport = ({ report, lang, onClose }) => {
const no = lang === 'no';
const s = report.summary || {};
const kv = (o) => Object.entries(o || {}).map(([k, v]) => `${v} × ${k}`).join(', ') || '—';
return (
{no ? 'Påfyllingsrapport' : 'Refill report'} · {formatTs(report.started_at)}{report.finished_at ? ` → ${formatTs(report.finished_at)}` : ''}
window.print()}> {no ? 'Skriv ut' : 'Print'}
{no ? 'Satt inn (fulle)' : 'Inserted'}
{s.inserted_total ?? 0}
{no ? 'Tatt ut (tomme)' : 'Removed'}
{s.removed_total ?? 0}
{no ? 'Dører' : 'Doors'}
{s.doors_total ?? 0}
{no ? 'Ubekreftet' : 'Unconfirmed'}
{s.unconfirmed ?? 0}
{report.by &&
{no ? 'Utført av' : 'By'}: {report.by}
}
{no ? 'Dør' : 'Door'} {no ? 'Før' : 'Before'} {no ? 'Etter' : 'After'} {no ? 'Hendelse' : 'Event'} {no ? 'Bilde' : 'Image'}
{(report.doors || []).map(d => {
const k = FILL_KIND[d.kind] || { no: d.kind, tone: 'empty' };
return (
{d.door}
{fillStatusLabel(d.prior_status)}{d.prior_bottle ? ` · ${d.prior_bottle.display_name}` : ''}
{fillStatusLabel(d.now_status)}{d.now_bottle ? · {d.now_bottle.display_name} : ''}
{k.no}
);
})}
);
};
/* Gå gjennom alle hyller: bekreft eller rett hva som faktisk står der */
const ShelfReview = ({ machine, lang, bottles }) => {
const no = lang === 'no';
const [rows, setRows] = React.useState(machine.shelves || []);
const [busy, setBusy] = React.useState(null);
const [err, setErr] = React.useState(null);
const [confirmed, setConfirmed] = React.useState(() => new Set());
const refresh = React.useCallback(async () => {
try { setRows(await window.api.refreshMachineShelves(machine.id)); } catch (e) { setErr(e.message); }
}, [machine.id]);
React.useEffect(() => { refresh(); }, [refresh]);
const bekreft = async (s) => {
setBusy(s.rawShelfId); setErr(null);
try {
await window.api.confirmLabel(machine.id, s.rawShelfId, {
label: s.state === 'full' && s.bottle ? s.bottle : 'tom',
detected: s.bottle || null, detected_score: s.lastScore ?? null,
});
setConfirmed(prev => new Set(prev).add(s.rawShelfId));
await refresh();
} catch (e) { setErr(e.message); }
setBusy(null);
};
const rett = async (s, label) => {
setBusy(s.rawShelfId); setErr(null);
try {
await window.api.confirmLabel(machine.id, s.rawShelfId, {
label, detected: s.bottle || null, detected_score: s.lastScore ?? null,
});
setConfirmed(prev => new Set(prev).add(s.rawShelfId));
await refresh();
if (machine._onReload) await machine._onReload();
} catch (e) { setErr(e.message); }
setBusy(null);
};
const setStatus = async (s, status) => {
setBusy(s.rawShelfId); setErr(null);
try {
await window.api.overrideShelf(machine.id, s.rawShelfId,
status === 'empty_bottle' ? s.bottle : null, status);
await refresh();
if (machine._onReload) await machine._onReload();
} catch (e) { setErr(e.message); }
setBusy(null);
};
return (
{no ? 'Kontroller lageret' : 'Review inventory'}
{confirmed.size}/{rows.length} {no ? 'bekreftet' : 'confirmed'}
{no ? 'Oppdater' : 'Refresh'}
{no ? 'Gå gjennom hylle for hylle: stemmer bildet med typen? Bekreft, eller rett. Bekreftelser lagres som treningsdata og gjør gjenkjenningen bedre.'
: 'Go through each shelf: does the image match the type? Confirm or correct. Confirmations are saved as training data.'}
{err &&
❌ {err}
}
{no ? 'Dør' : 'Door'} {no ? 'Bilde' : 'Image'} Status {no ? 'Innhold' : 'Contents'}
{rows.map(s => (
{s.rawDoor}
{fillStatusLabel(s.rawStatus)}
{s.bottleName || — }
bekreft(s)}>
{confirmed.has(s.rawShelfId) ? '✓ ' : ''}{no ? 'Bekreft' : 'Confirm'}
rett(s, v)} />
{s.state === 'full' && s.bottle && (
setStatus(s, 'empty_bottle')}>{no ? 'Tomflaske' : 'Empty bottle'}
)}
{s.state !== 'error'
? setStatus(s, 'faulty')}>{no ? 'Deaktiver' : 'Disable'}
: setStatus(s, 'empty')}>{no ? 'Aktiver' : 'Enable'} }
))}
);
};
Object.assign(window, { FillTab });