Karte 10: Kamera-Scan im Frontend (Objekt-Code, Scan-First-Einstieg)
CI / backend-tests (push) Successful in 1m0s
CI / frontend-build (push) Successful in 18s

BarcodeScanner-Komponente auf @zxing/browser (Code128, iOS/Safari-kompatibel
im Gegensatz zur nativen BarcodeDetector-API). Treffer springt direkt ins
Kontroll-Screen, sonst Fallback ins bestehende Such-Textfeld.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-04 17:39:24 +02:00
co-authored by Claude Sonnet 5
parent f06d704168
commit 6cf28cc8ee
6 changed files with 151 additions and 12 deletions
+13
View File
@@ -2578,3 +2578,16 @@ Keine Commits in dieser Session.
- backend/tests/test_objekte.py | 83 ++++++++++++ - backend/tests/test_objekte.py | 83 ++++++++++++
--- ---
## 2026-09-04 17:37 17:37 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
- f06d704 Frontend: Code-Vorschlag beim Objekt-Anlegen/Duplizieren einbinden
### Geänderte Dateien
- DEVLOG.md | 36 +++++++++++++++++++
- frontend/DEVLOG.md | 20 +++++++++++
- frontend/src/pages/admin/ObjektSection.tsx | 58 +++++++++++++++++++++++++++++-
---
+13
View File
@@ -18,3 +18,16 @@ Keine Commits in dieser Session.
- backend/tests/test_objekte.py | 83 ++++++++++++ - backend/tests/test_objekte.py | 83 ++++++++++++
--- ---
## 2026-09-04 17:37 17:38 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 36 +++++++++++++++++++
- frontend/DEVLOG.md | 20 +++++++++++
- frontend/src/pages/admin/ObjektSection.tsx | 58 +++++++++++++++++++++++++++++-
---
+1
View File
@@ -9,6 +9,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@zxing/browser": "^0.1.5",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
@@ -0,0 +1,64 @@
import { useEffect, useRef, useState } from "react";
interface Props {
onErkannt: (code: string) => void;
onSchliessen: () => void;
}
/**
* Kamera-Scan (Karte 10): Code128-Etiketten sind kameraseitig empfindlicher als
* QR (Abstand/Winkel/Druckqualität) - deshalb bewusst kein Auto-Close bei jedem
* Treffer, sondern Fehler bleiben sichtbar, bis der Nutzer erneut versucht oder
* abbricht. @zxing/browser statt der nativen BarcodeDetector-API, weil Letztere
* auf iOS/Safari fehlt - Einsatzgeräte sind gemischt (Karte 06).
*/
export function BarcodeScanner({ onErkannt, onSchliessen }: Props) {
const videoRef = useRef<HTMLVideoElement>(null);
const [fehler, setFehler] = useState<string | null>(null);
useEffect(() => {
let abgebrochen = false;
let controls: { stop: () => void } | null = null;
async function starten() {
try {
const { BrowserMultiFormatReader } = await import("@zxing/browser");
if (abgebrochen || !videoRef.current) return;
const reader = new BrowserMultiFormatReader();
controls = await reader.decodeFromVideoDevice(
undefined,
videoRef.current,
(ergebnis, fehlerErgebnis) => {
if (ergebnis) {
onErkannt(ergebnis.getText());
} else if (fehlerErgebnis && fehlerErgebnis.name !== "NotFoundException") {
setFehler("Scan-Fehler - bitte Kamera-Berechtigung prüfen.");
}
}
);
} catch {
if (!abgebrochen) {
setFehler("Kamera konnte nicht gestartet werden - Berechtigung erteilt?");
}
}
}
starten();
return () => {
abgebrochen = true;
controls?.stop();
};
}, [onErkannt]);
return (
<div className="scanner-overlay">
<div className="scanner-box">
<video ref={videoRef} className="scanner-video" muted playsInline />
{fehler && <div className="alert alert-danger">{fehler}</div>}
<button className="btn btn-secondary" onClick={onSchliessen} style={{ marginTop: "0.75rem" }}>
Abbrechen
</button>
</div>
</div>
);
}
+36 -12
View File
@@ -1,17 +1,20 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { apiRequest } from "../api/client"; import { apiRequest, ApiError } from "../api/client";
import type { Objekt } from "../api/types"; import type { Objekt } from "../api/types";
import { BarcodeScanner } from "../components/BarcodeScanner";
// Prompt 11 Screen 1: Standort/Objekt wählen. QR-/Barcode-Scan (Karte 10) ist // Prompt 11 Screen 1: Standort/Objekt wählen. Objekt-Code (Karte 10)
// Roadmap - dieser Screen bietet bewusst schon die Code-Suche als Textfeld an, // Direktsprung ins Kontroll-Screen, sonst füllt der Scan dasselbe Textfeld wie
// ein späterer Kamera-Scan kann denselben Code einfach in dasselbe Feld schreiben. // die manuelle Suche (Kamera fällt bei Etikettschaden auf Tippen zurück).
export function ObjektListPage() { export function ObjektListPage() {
const navigate = useNavigate();
const [objekte, setObjekte] = useState<Objekt[]>([]); const [objekte, setObjekte] = useState<Objekt[]>([]);
const [suche, setSuche] = useState(""); const [suche, setSuche] = useState("");
const [ladefehler, setLadefehler] = useState<string | null>(null); const [ladefehler, setLadefehler] = useState<string | null>(null);
const [laedt, setLaedt] = useState(true); const [laedt, setLaedt] = useState(true);
const [scannerOffen, setScannerOffen] = useState(false);
useEffect(() => { useEffect(() => {
apiRequest<Objekt[]>("/objekte") apiRequest<Objekt[]>("/objekte")
@@ -20,6 +23,20 @@ export function ObjektListPage() {
.finally(() => setLaedt(false)); .finally(() => setLaedt(false));
}, []); }, []);
async function scanVerarbeiten(code: string) {
setScannerOffen(false);
try {
const objekt = await apiRequest<Objekt>(`/objekte/code/${encodeURIComponent(code)}`);
navigate(`/objekte/${objekt.id}/kontrolle`);
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
setSuche(code);
} else {
setLadefehler("Scan konnte nicht verarbeitet werden.");
}
}
}
const gefiltert = objekte.filter( const gefiltert = objekte.filter(
(o) => (o) =>
o.name.toLowerCase().includes(suche.toLowerCase()) || o.name.toLowerCase().includes(suche.toLowerCase()) ||
@@ -29,13 +46,20 @@ export function ObjektListPage() {
return ( return (
<main className="page"> <main className="page">
<h1>Objekte</h1> <h1>Objekte</h1>
<input <div className="row" style={{ marginBottom: "1.25rem" }}>
className="input input-search" <input
placeholder="Suche oder Code scannen…" className="input input-search"
value={suche} placeholder="Suche oder Code…"
onChange={(e) => setSuche(e.target.value)} value={suche}
style={{ marginBottom: "1.25rem" }} onChange={(e) => setSuche(e.target.value)}
/> />
<button className="btn btn-secondary" onClick={() => setScannerOffen(true)}>
Scannen
</button>
</div>
{scannerOffen && (
<BarcodeScanner onErkannt={scanVerarbeiten} onSchliessen={() => setScannerOffen(false)} />
)}
{ladefehler && <div className="alert alert-danger">{ladefehler}</div>} {ladefehler && <div className="alert alert-danger">{ladefehler}</div>}
{laedt && <p className="text-muted">Lade</p>} {laedt && <p className="text-muted">Lade</p>}
{!laedt && !ladefehler && gefiltert.length === 0 && ( {!laedt && !ladefehler && gefiltert.length === 0 && (
+24
View File
@@ -321,3 +321,27 @@ select.input:focus {
.section { .section {
margin-bottom: 2rem; margin-bottom: 2rem;
} }
.scanner-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.scanner-box {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1rem;
}
.scanner-video {
width: min(90vw, 480px);
border-radius: 0.5rem;
background: #000;
}