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:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
132
src/components/BarcodeScanner.jsx
Normal file
132
src/components/BarcodeScanner.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user