Karte 10: Kamera-Scan im Frontend (Objekt-Code, Scan-First-Einstieg)
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:
@@ -18,3 +18,16 @@ Keine Commits in dieser Session.
|
||||
- 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 +++++++++++++++++++++++++++++-
|
||||
|
||||
---
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@zxing/browser": "^0.1.5",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"react": "^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>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,20 @@
|
||||
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 { BarcodeScanner } from "../components/BarcodeScanner";
|
||||
|
||||
// Prompt 11 Screen 1: Standort/Objekt wählen. QR-/Barcode-Scan (Karte 10) ist
|
||||
// Roadmap - dieser Screen bietet bewusst schon die Code-Suche als Textfeld an,
|
||||
// ein späterer Kamera-Scan kann denselben Code einfach in dasselbe Feld schreiben.
|
||||
// Prompt 11 Screen 1: Standort/Objekt wählen. Objekt-Code (Karte 10) →
|
||||
// Direktsprung ins Kontroll-Screen, sonst füllt der Scan dasselbe Textfeld wie
|
||||
// die manuelle Suche (Kamera fällt bei Etikettschaden auf Tippen zurück).
|
||||
export function ObjektListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [objekte, setObjekte] = useState<Objekt[]>([]);
|
||||
const [suche, setSuche] = useState("");
|
||||
const [ladefehler, setLadefehler] = useState<string | null>(null);
|
||||
const [laedt, setLaedt] = useState(true);
|
||||
const [scannerOffen, setScannerOffen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
apiRequest<Objekt[]>("/objekte")
|
||||
@@ -20,6 +23,20 @@ export function ObjektListPage() {
|
||||
.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(
|
||||
(o) =>
|
||||
o.name.toLowerCase().includes(suche.toLowerCase()) ||
|
||||
@@ -29,13 +46,20 @@ export function ObjektListPage() {
|
||||
return (
|
||||
<main className="page">
|
||||
<h1>Objekte</h1>
|
||||
<input
|
||||
className="input input-search"
|
||||
placeholder="Suche oder Code scannen…"
|
||||
value={suche}
|
||||
onChange={(e) => setSuche(e.target.value)}
|
||||
style={{ marginBottom: "1.25rem" }}
|
||||
/>
|
||||
<div className="row" style={{ marginBottom: "1.25rem" }}>
|
||||
<input
|
||||
className="input input-search"
|
||||
placeholder="Suche oder Code…"
|
||||
value={suche}
|
||||
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>}
|
||||
{laedt && <p className="text-muted">Lade…</p>}
|
||||
{!laedt && !ladefehler && gefiltert.length === 0 && (
|
||||
|
||||
@@ -321,3 +321,27 @@ select.input:focus {
|
||||
.section {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user