feat(geraete): Barcode/QR-Scanner per Smartphone-Kamera

- Neue Komponente BarcodeScanner (Modal mit Video-Stream + Such-Rahmen)
- @zxing/browser als Dependency (dynamisch importiert -> eigener Chunk,
  nicht im Initial-Bundle)
- Im Geraete-Formular Scan-Button neben dem Seriennummer-Input
- Auto-Erkennung: erster Treffer fuellt Feld + schliesst Modal
- Bevorzugt Rueckkamera (facingMode environment), Kamera-Switch-Button
  wenn mehrere Cams verfuegbar
- Fehlerbehandlung: NotAllowedError (Permission denied) mit Hinweis-Text
- Mobile-friendly: aspect-square Video, Touch-Targets

Unterstuetzte Formate: QR, Code128, Code39, EAN-8/13, UPC-A/E, Data Matrix u.a.
This commit is contained in:
christian
2026-05-26 14:29:06 +00:00
parent 0f9fdf8fab
commit 6d00e7e703
3 changed files with 152 additions and 4 deletions

View File

@@ -11,7 +11,8 @@
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^5.5.0"
"react-icons": "^5.5.0",
"@zxing/browser": "^0.1.5"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.5.1",
@@ -21,4 +22,4 @@
"vite": "^5.2.0",
"vite-plugin-pwa": "^1.0.0"
}
}
}

View File

@@ -0,0 +1,132 @@
import { useEffect, useRef, useState } from 'react';
import { FaTimes, FaCamera, FaSyncAlt } from 'react-icons/fa';
// Scanner-Modal: oeffnet Kamera (bevorzugt Rueckkamera), liest Codes,
// liefert beim ersten Treffer den Text via onScan zurueck und schliesst sich.
export default function BarcodeScanner({ open, onClose, onScan }) {
const videoRef = useRef(null);
const controlsRef = useRef(null);
const [error, setError] = useState('');
const [devices, setDevices] = useState([]);
const [deviceIdx, setDeviceIdx] = useState(0);
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!open) return;
let cancelled = false;
setError('');
setBusy(true);
(async () => {
try {
// Lade ZXing dynamisch -> reduziert initial-bundle
const { BrowserMultiFormatReader } = await import('@zxing/browser');
if (cancelled) return;
const reader = new BrowserMultiFormatReader();
// Verfuegbare Kameras (fuer Switch-Button)
const cams = await BrowserMultiFormatReader.listVideoInputDevices();
if (cancelled) return;
setDevices(cams);
// Bevorzuge Rueckkamera ('back', 'environment')
let startDeviceId;
if (cams.length > 0) {
const backCam = cams.find(c => /back|rear|environment|umgebung/i.test(c.label));
startDeviceId = (backCam || cams[cams.length - 1]).deviceId;
setDeviceIdx(cams.findIndex(c => c.deviceId === startDeviceId));
}
const controls = await reader.decodeFromVideoDevice(
startDeviceId,
videoRef.current,
(result, err, ctrl) => {
if (result) {
const text = result.getText();
ctrl.stop();
onScan(text);
}
}
);
controlsRef.current = controls;
setBusy(false);
} catch (e) {
console.error('Scanner-Fehler:', e);
if (!cancelled) {
setError(
e?.name === 'NotAllowedError'
? 'Kamerazugriff verweigert. Bitte in den Browser-Einstellungen erlauben.'
: e?.message || 'Kamera konnte nicht gestartet werden.'
);
setBusy(false);
}
}
})();
return () => {
cancelled = true;
try { controlsRef.current?.stop(); } catch {}
};
}, [open, onScan]);
const switchCamera = async () => {
if (devices.length < 2) return;
try { controlsRef.current?.stop(); } catch {}
const next = (deviceIdx + 1) % devices.length;
setDeviceIdx(next);
setBusy(true);
const { BrowserMultiFormatReader } = await import('@zxing/browser');
const reader = new BrowserMultiFormatReader();
const controls = await reader.decodeFromVideoDevice(
devices[next].deviceId, videoRef.current,
(result, err, ctrl) => { if (result) { ctrl.stop(); onScan(result.getText()); } }
);
controlsRef.current = controls;
setBusy(false);
};
if (!open) return null;
return (
<div className="fixed inset-0 z-50 bg-slate-900/80 backdrop-blur-sm flex items-center justify-center p-4">
<div className="bg-white rounded-xl max-w-md w-full overflow-hidden shadow-soft">
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-200">
<div className="flex items-center gap-2">
<FaCamera className="text-brand-900" />
<h3 className="font-semibold text-slate-900">Barcode scannen</h3>
</div>
<div className="flex items-center gap-1">
{devices.length > 1 && (
<button onClick={switchCamera} title="Kamera wechseln"
className="btn-ghost p-2">
<FaSyncAlt />
</button>
)}
<button onClick={onClose} className="btn-ghost p-2"><FaTimes /></button>
</div>
</div>
<div className="relative bg-slate-900 aspect-square">
<video ref={videoRef}
className="absolute inset-0 h-full w-full object-cover"
playsInline muted autoPlay />
{/* Such-Rechteck */}
<div className="absolute inset-0 grid place-items-center pointer-events-none">
<div className="w-3/4 aspect-square border-2 border-white/70 rounded-lg shadow-[0_0_0_9999px_rgba(0,0,0,0.35)]" />
</div>
{busy && (
<div className="absolute inset-0 grid place-items-center text-white text-sm">
Kamera startet
</div>
)}
</div>
<div className="px-4 py-3 text-xs text-slate-500 text-center min-h-[2.5rem]">
{error
? <span className="text-rose-600">{error}</span>
: 'Barcode/QR im Rahmen positionieren. Erkannt wird automatisch.'}
</div>
</div>
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { FaPlus, FaEdit, FaTrash, FaTimes } from 'react-icons/fa';
import { FaPlus, FaEdit, FaTrash, FaTimes, FaQrcode } from 'react-icons/fa';
import BarcodeScanner from './BarcodeScanner';
import { api } from '../api/client';
const STATUS = {
@@ -20,6 +21,7 @@ export default function Geräte() {
const [loading, setLoading] = useState(true);
const [filterTyp, setFilterTyp] = useState('');
const [filterStatus, setFilterStatus] = useState('');
const [scannerOpen, setScannerOpen] = useState(false);
const load = async () => {
setLoading(true);
@@ -62,7 +64,14 @@ export default function Geräte() {
<h3 className="font-medium text-slate-800 mb-3">{editId ? 'Gerät bearbeiten' : 'Neues Gerät'}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<Field label="Seriennummer *">
<input className="input" value={form.seriennummer} onChange={e => setForm(f => ({ ...f, seriennummer: e.target.value }))} />
<div className="flex gap-2">
<input className="input flex-1" value={form.seriennummer}
onChange={e => setForm(f => ({ ...f, seriennummer: e.target.value }))} />
<button type="button" onClick={() => setScannerOpen(true)}
className="btn-secondary px-3" title="Barcode/QR scannen">
<FaQrcode />
</button>
</div>
</Field>
<Field label="Typ *">
<input className="input" placeholder="z. B. Ofen, Kühlschrank" value={form.typ}
@@ -142,6 +151,12 @@ export default function Geräte() {
</table>
)}
</div>
<BarcodeScanner open={scannerOpen}
onClose={() => setScannerOpen(false)}
onScan={(code) => {
setForm(f => ({ ...f, seriennummer: code }));
setScannerOpen(false);
}} />
</div>
);
}