feat: Sitzungsliste und Verdrahtung zur vollstaendigen App

Ersetzt das Sichtpruefungs-Geruest in main.js durch die vollstaendige
Verdrahtung aus Kamera, Barcode/OCR-Erkennung, Sitzungsverwaltung,
Speicherung und Oberflaeche. Neu: session-list.js fuer die
Sitzungsliste zum Umsortieren/Entfernen/Beenden.

Gegenueber dem urspruenglichen Plan an den seither geaenderten
Schnittstellen ausgerichtet: commitAssignment erhaelt die tatsaechliche
Erkennungsquelle (source) statt der Konfidenz als drittes Argument, und
die OCR-Verfuegbarkeitsanzeige ueberschreibt keine noch zu lesende
Fehlermeldung mehr. Bezeichner in beiden Dateien auf Englisch
umgestellt, damit sie der bereits etablierten Projektkonvention
entsprechen.
This commit is contained in:
vchuser
2026-07-28 17:28:15 +02:00
parent 0c96743211
commit c4ad0416ab
2 changed files with 269 additions and 12 deletions
+167 -12
View File
@@ -1,19 +1,174 @@
// Vollstaendige Verdrahtung der App: Kamera, Barcode/OCR-Erkennung, Sitzung
// und Oberflaeche. Ersetzt das fruehere Sichtpruefungs-Geruest vollstaendig.
import { startCamera, grabFrame, imageDataFromFile } from './camera.js';
import { decodeBarcodes } from './barcode.js';
import { runOcr, isOcrAvailable } from './ocr.js';
import { recognize } from './pipeline.js';
import {
createSession, proposeAssignment, commitAssignment,
undoLast, moveEntry, removeEntry,
} from './session.js';
import { saveSession, loadSession, clearSession } from './storage.js';
import { renderScanView } from './ui/scan-view.js'; import { renderScanView } from './ui/scan-view.js';
import { showResult } from './ui/result-overlay.js';
import { askForStack } from './ui/ambiguous-dialog.js'; import { askForStack } from './ui/ambiguous-dialog.js';
import { startCamera } from './camera.js'; import { renderSessionList } from './ui/session-list.js';
const app = document.querySelector('#app'); const app = document.querySelector('#app');
const store = window.localStorage;
// loadSession liefert entweder eine in sich stimmige Sitzung oder null - nie
// einen halb brauchbaren Zustand (siehe storage.js). Eine gefundene Sitzung
// nach einem Neuladen hat Vorrang vor einer neuen.
let session = loadSession(store) ?? createSession();
// Sperrt die Erfassung, waehrend eine Erkennung laeuft. Wird in processCapture()
// im finally-Block in jedem Ausgang wieder aufgehoben - auch wenn der Nutzer
// im Rot-Dialog "nochmal scannen" waehlt und die Funktion damit vorzeitig
// verlassen wird (der fruehe return liegt innerhalb des try-Blocks).
let busy = false;
const view = renderScanView(app, { const view = renderScanView(app, {
onCapture: async () => { onCapture: () => processCapture(() => grabFrame(view.video)),
const answer = await askForStack(app, { onUndo: () => {
spec: { capacityGb: 64, rank: null, speed: null, formFactor: null, partNumber: null }, const entry = undoLast(session);
candidates: ['A', 'B'], view.setLast(entry ? `zurueckgenommen: Stapel ${entry.stackId}` : 'nichts zurueckzunehmen');
}); syncView();
view.setLast(`Antwort: ${JSON.stringify(answer)}`);
}, },
onUndo: () => view.setLast('zurueckgenommen'), onOpenList: openSessionList,
onOpenList: () => {}, onPickFile: (file) => processCapture(() => imageDataFromFile(file)),
onPickFile: () => {},
}); });
view.setStacks([{ id: 'A', count: 12 }, { id: 'B', count: 4 }]);
startCamera(view.video).catch(() => view.setStatus('Kamera nicht verfuegbar', true)); /** Spiegelt den Sitzungsstand in die Oberflaeche und sichert ihn gegen Neuladen ab. */
function syncView() {
view.setStacks(session.stacks);
saveSession(session, store);
}
/** Kurzbeschreibung fuer die "Zuletzt"-Zeile. */
function describeEntry(spec, stackId) {
const parts = [
spec.capacityGb ? `${spec.capacityGb}GB` : '?',
spec.speed ?? '?',
spec.formFactor ?? '',
].filter(Boolean);
return `${parts.join(' ')} → Stapel ${stackId}`;
}
/** Naechster noch nicht vergebener Stapel-Buchstabe - fuer den Fall, dass der Nutzer im Rot-Dialog ausdruecklich einen neuen Stapel waehlt. */
function nextFreeStackId() {
const used = new Set(session.stacks.map((stack) => stack.id));
for (const letter of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') {
if (!used.has(letter)) return letter;
}
// Nach Z weiter mit A2, B2, ... - in der Praxis nie erreicht (siehe session.js).
return `A${session.stacks.length}`;
}
/**
* Ein Scan-Durchlauf: Bild beschaffen, erkennen, bei unsicherer Erkennung
* nachfragen, buchen, kurz rueckmelden. Laeuft ohne Bestaetigung durch,
* solange die Erkennung eindeutig ist - haelt nur bei roter Konfidenz oder
* mehrdeutiger Stapelzuordnung an.
*/
async function processCapture(getFrame) {
if (busy) return;
busy = true;
view.setStatus('erkenne …');
let failed = false;
try {
const frame = await getFrame();
const { spec, source, confidence } = await recognize(frame, { decodeBarcodes, runOcr });
const plan = proposeAssignment(session, spec);
let stackId = plan.stackId;
if (confidence === 'red' || plan.kind === 'ambiguous') {
const answer = await askForStack(app, {
spec,
candidates: plan.kind === 'ambiguous'
? plan.candidates
: session.stacks.map((stack) => stack.id),
});
// "nochmal scannen": abbrechen, ohne zu buchen. Der fruehe return liegt
// innerhalb des try - der finally-Block hebt die Sperre trotzdem auf.
if (answer.action === 'retry') return;
if (answer.action === 'stack') {
// Nutzer hat einen bestehenden Stapel gewaehlt: genau dieser wird genommen.
stackId = answer.stackId;
} else {
// "neuer Stapel": unabhaengig von jeder Vermutung der naechste freie Buchstabe.
stackId = nextFreeStackId();
}
}
const entry = commitAssignment(session, spec, source, stackId);
view.setLast(describeEntry(spec, entry.stackId));
syncView();
// Nach einer manuellen Entscheidung im Rot-Dialog gibt es nichts mehr
// rueckzumelden - der Dialog selbst war die Rueckmeldung.
if (confidence !== 'red') {
await showResult(app, { stackId: entry.stackId, spec, confidence });
}
} catch (error) {
failed = true;
view.setStatus(`Fehler: ${error.message}`, true);
} finally {
busy = false;
// Die OCR-Verfuegbarkeitsanzeige darf eine Fehlermeldung, die der Nutzer
// noch lesen muss, nicht ueberschreiben.
if (!failed) {
const available = isOcrAvailable();
view.setStatus(
available ? '' : 'Texterkennung nicht verfuegbar — nur Barcodes werden gelesen',
!available,
);
}
}
}
/** Entfernt alle offenen Vollbild-Overlays innerhalb der App. */
function closeOverlays() {
app.querySelectorAll('.overlay').forEach((element) => element.remove());
}
/**
* Oeffnet die Sitzungsliste. Nach einer Aenderung (Umsortieren, Entfernen)
* wird die aktuelle Liste zuerst geschlossen und dann frisch neu aufgebaut,
* damit nach dem Entfernen eines Eintrags nie zwei Listen uebereinanderliegen
* und Stapel-Kopfzeilen sowie Zaehler stets aktuell bleiben.
*/
function openSessionList() {
renderSessionList(app, session, {
onMove: (entryId, stackId) => {
moveEntry(session, entryId, stackId);
syncView();
closeOverlays();
openSessionList();
},
onRemove: (entryId) => {
removeEntry(session, entryId);
syncView();
closeOverlays();
openSessionList();
},
onEndSession: () => {
session = createSession();
clearSession(store);
view.setLast('Sitzung beendet');
syncView();
},
onClose: () => {},
});
}
startCamera(view.video).catch((error) => {
view.setStatus(`Kamera nicht verfuegbar (${error.message}) — Bild auswaehlen`, true);
view.openFilePicker();
});
syncView();
+102
View File
@@ -0,0 +1,102 @@
// Rein darstellend: Vollbild-Liste aller Stapel der laufenden Sitzung, zum
// Umsortieren und Entfernen einzelner Eintraege sowie zum Beenden der
// Sitzung. Kennt weder Kamera noch Erkennung - alles kommt ueber die
// Rueckrufe, genau wie bei scan-view.js und ambiguous-dialog.js.
/** Kurzbeschreibung eines Specs fuer Stapel-Kopfzeilen und Eintrags-Zeilen ohne Teilenummer. */
function describeSpec(spec) {
const parts = [
spec.capacityGb ? `${spec.capacityGb}GB` : '?',
spec.rank ?? '?',
spec.speed ?? '?',
spec.formFactor ?? '?',
];
return parts.join(' ');
}
/**
* Vollbild-Liste aller Stapel der laufenden Sitzung.
* @param {HTMLElement} root
* @param {{stacks: Array<{id: string, count: number, spec: object}>, entries: Array<{entryId: number, spec: object, stackId: string}>}} session
* @param {{onMove: (entryId: number, stackId: string) => void, onRemove: (entryId: number) => void, onEndSession: () => void, onClose: () => void}} callbacks
*/
export function renderSessionList(root, session, { onMove, onRemove, onEndSession, onClose }) {
const overlay = document.createElement('div');
overlay.className = 'overlay';
overlay.style.background = 'var(--bg)';
overlay.style.justifyContent = 'flex-start';
overlay.style.overflowY = 'auto';
overlay.innerHTML = `
<div class="stack-id" style="font-size:26px">Sitzung</div>
<div class="choices" id="body" style="max-width:520px"></div>
<div class="choices" style="max-width:520px">
<button class="action" id="close">zurueck zum Scannen</button>
<button class="action secondary" id="end">Sitzung beenden</button>
</div>
`;
const body = overlay.querySelector('#body');
for (const stack of session.stacks) {
const header = document.createElement('div');
header.className = 'detail';
header.style.textAlign = 'left';
header.style.marginTop = '12px';
header.textContent = `Stapel ${stack.id}${stack.count}x ${describeSpec(stack.spec)}`;
body.appendChild(header);
for (const entry of session.entries.filter((candidate) => candidate.stackId === stack.id)) {
const row = document.createElement('div');
row.style.display = 'flex';
row.style.gap = '8px';
row.style.alignItems = 'center';
const label = document.createElement('span');
label.style.flex = '1';
label.style.fontSize = '14px';
label.style.textAlign = 'left';
label.textContent = entry.spec.partNumber ?? describeSpec(entry.spec);
row.appendChild(label);
// Bedienflaechen muessen mindestens 56px hoch sein - auch die Auswahl,
// nicht nur die Schaltflaechen.
const select = document.createElement('select');
select.style.minHeight = '56px';
select.setAttribute('aria-label', `Stapel fuer ${label.textContent}`);
for (const target of session.stacks) {
const option = document.createElement('option');
option.value = target.id;
option.textContent = target.id;
option.selected = target.id === entry.stackId;
select.appendChild(option);
}
select.addEventListener('change', () => onMove(entry.entryId, select.value));
row.appendChild(select);
const removeButton = document.createElement('button');
removeButton.className = 'action secondary';
removeButton.style.minWidth = '56px';
removeButton.textContent = '✕';
removeButton.setAttribute('aria-label', `${label.textContent} entfernen`);
removeButton.addEventListener('click', () => onRemove(entry.entryId));
row.appendChild(removeButton);
body.appendChild(row);
}
}
if (session.stacks.length === 0) {
body.textContent = 'Noch nichts erfasst.';
}
overlay.querySelector('#close').addEventListener('click', () => {
overlay.remove();
onClose();
});
overlay.querySelector('#end').addEventListener('click', () => {
overlay.remove();
onEndSession();
});
root.appendChild(overlay);
}