Files
texas-resellers/frontend/src/pages/Scanner.jsx
T

364 lines
14 KiB
React

import React, { useState, useRef, useEffect, useCallback } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import {
ScanLine, Camera, Keyboard, CheckCircle2, XCircle,
Eye, Pencil, ShoppingCart, RotateCcw, Zap
} from 'lucide-react'
import { useData } from '../context/DataContext'
import StatusBadge from '../components/StatusBadge'
import CategoryBadge from '../components/CategoryBadge'
// ─── Bluetooth / text-input scanner mode ─────────────────────────────────────
function BluetoothScanner({ onScan }) {
const [value, setValue] = useState('')
const inputRef = useRef(null)
useEffect(() => { inputRef.current?.focus() }, [])
function handleKeyDown(e) {
if (e.key === 'Enter') {
const trimmed = value.trim()
if (trimmed) { onScan(trimmed); setValue('') }
}
}
return (
<div className="space-y-5">
<div className="flex items-center gap-3 p-4 bg-blue-900/20 border border-blue-700/40 rounded-xl">
<Zap className="w-5 h-5 text-blue-400 shrink-0" />
<div>
<p className="text-sm font-medium text-blue-300">Bluetooth Scanner Mode</p>
<p className="text-xs text-blue-400/70 mt-0.5">
Connect your Bluetooth barcode scanner, click the field, and scan any item label.
The scanner will auto-submit when it sends Enter.
</p>
</div>
</div>
<div className="relative">
<ScanLine className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-amber-400" />
<input
ref={inputRef}
className="input text-xl font-mono tracking-widest pl-12 py-4 text-center"
placeholder="Scan barcode or type asset tag…"
value={value}
onChange={e => setValue(e.target.value)}
onKeyDown={handleKeyDown}
autoFocus
autoComplete="off"
spellCheck={false}
/>
</div>
<p className="text-center text-sm text-gray-500">
Or type an asset tag (e.g. <span className="font-mono text-amber-400">TR-A1B2C3</span>) and press Enter
</p>
</div>
)
}
// ─── Camera scanner mode ──────────────────────────────────────────────────────
function CameraScanner({ onScan }) {
const videoRef = useRef(null)
const canvasRef = useRef(null)
const frameRef = useRef(null)
const [status, setStatus] = useState('idle') // idle | starting | running | error
const [errorMsg, setErrorMsg] = useState('')
const [lastScan, setLastScan] = useState(null)
async function startCamera() {
setStatus('starting')
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } })
videoRef.current.srcObject = stream
await videoRef.current.play()
setStatus('running')
scanLoop()
} catch (err) {
setStatus('error')
setErrorMsg(err.message || 'Camera access denied')
}
}
function stopCamera() {
cancelAnimationFrame(frameRef.current)
const stream = videoRef.current?.srcObject
stream?.getTracks().forEach(t => t.stop())
if (videoRef.current) videoRef.current.srcObject = null
setStatus('idle')
}
useEffect(() => () => stopCamera(), [])
async function scanLoop() {
if (!videoRef.current || !canvasRef.current) return
const video = videoRef.current
const canvas = canvasRef.current
const ctx = canvas.getContext('2d')
async function tick() {
if (video.readyState === video.HAVE_ENOUGH_DATA) {
canvas.width = video.videoWidth
canvas.height = video.videoHeight
ctx.drawImage(video, 0, 0)
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height)
try {
// Dynamic import jsQR
const jsQR = (await import('jsqr')).default
const code = jsQR(imgData.data, imgData.width, imgData.height)
if (code && code.data !== lastScan) {
setLastScan(code.data)
onScan(code.data)
}
} catch (_) {}
}
frameRef.current = requestAnimationFrame(tick)
}
frameRef.current = requestAnimationFrame(tick)
}
return (
<div className="space-y-5">
<div className="flex items-center gap-3 p-4 bg-purple-900/20 border border-purple-700/40 rounded-xl">
<Camera className="w-5 h-5 text-purple-400 shrink-0" />
<div>
<p className="text-sm font-medium text-purple-300">Camera Scanner Mode</p>
<p className="text-xs text-purple-400/70 mt-0.5">
Uses your device camera to scan QR codes from printed labels.
Requires camera permission.
</p>
</div>
</div>
{status === 'idle' && (
<div className="text-center py-10">
<Camera className="w-12 h-12 text-gray-600 mx-auto mb-4" />
<p className="text-gray-400 mb-4">Camera is not active.</p>
<button onClick={startCamera} className="btn-primary mx-auto">
<Camera className="w-4 h-4" />
Start Camera
</button>
</div>
)}
{status === 'starting' && (
<div className="text-center py-10">
<div className="w-8 h-8 border-2 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-3" />
<p className="text-gray-400">Starting camera</p>
</div>
)}
{status === 'running' && (
<div className="space-y-3">
<div className="relative rounded-xl overflow-hidden bg-black">
<video ref={videoRef} className="w-full rounded-xl" playsInline muted />
{/* Scan overlay */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="w-48 h-48 border-2 border-amber-400 rounded-xl opacity-70">
<div className="absolute top-0 left-0 w-6 h-6 border-t-2 border-l-2 border-amber-400 rounded-tl-xl" />
<div className="absolute top-0 right-0 w-6 h-6 border-t-2 border-r-2 border-amber-400 rounded-tr-xl" />
<div className="absolute bottom-0 left-0 w-6 h-6 border-b-2 border-l-2 border-amber-400 rounded-bl-xl" />
<div className="absolute bottom-0 right-0 w-6 h-6 border-b-2 border-r-2 border-amber-400 rounded-br-xl" />
</div>
</div>
</div>
<canvas ref={canvasRef} className="hidden" />
<button onClick={stopCamera} className="btn-secondary w-full justify-center">
Stop Camera
</button>
</div>
)}
{status === 'error' && (
<div className="text-center py-10">
<XCircle className="w-10 h-10 text-red-400 mx-auto mb-3" />
<p className="text-red-400 mb-1">Camera Error</p>
<p className="text-sm text-gray-500 mb-4">{errorMsg}</p>
<button onClick={() => setStatus('idle')} className="btn-secondary mx-auto">
<RotateCcw className="w-4 h-4" /> Retry
</button>
</div>
)}
</div>
)
}
// ─── Scan result card ─────────────────────────────────────────────────────────
function ScanResult({ item, onMarkSold, onClear, settings }) {
const sym = settings.currencySymbol
const fmt = n => n != null ? `${sym}${Number(n).toFixed(2)}` : '—'
if (!item) return null
return (
<div className="card border-amber-500/30 bg-amber-500/5">
<div className="flex items-start justify-between gap-3 mb-4">
<div>
<div className="flex items-center gap-2 flex-wrap">
<CheckCircle2 className="w-5 h-5 text-green-400" />
<h3 className="text-base font-semibold text-gray-100">Item Found</h3>
<StatusBadge status={item.status} />
</div>
<p className="font-mono text-sm text-amber-400 mt-1">{item.assetTag}</p>
</div>
<button onClick={onClear} className="p-1.5 rounded hover:bg-gray-700 text-gray-400 hover:text-gray-100" title="Clear result">
<XCircle className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-2 gap-3 mb-5">
<div>
<p className="text-lg font-bold text-gray-100 leading-tight">{item.name}</p>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<CategoryBadge category={item.category} />
<span className="text-xs text-gray-400">{item.condition}</span>
</div>
</div>
<div className="text-right space-y-1">
<p className="text-xs text-gray-500">List Price</p>
<p className="text-xl font-bold text-amber-400">{fmt(item.listPrice)}</p>
<p className="text-xs text-gray-500">Cost: {fmt(item.purchasePrice)}</p>
</div>
</div>
{item.description && (
<p className="text-sm text-gray-400 mb-4 bg-gray-700/40 rounded-lg px-3 py-2">{item.description}</p>
)}
{/* Quick actions */}
<div className="flex flex-wrap gap-2">
<Link to={`/inventory/${item.id}`} className="btn-primary flex-1 justify-center">
<Eye className="w-4 h-4" />
View Full Item
</Link>
<Link to={`/inventory/${item.id}/edit`} className="btn-secondary flex-1 justify-center">
<Pencil className="w-4 h-4" />
Edit
</Link>
{item.status !== 'Sold' && (
<button onClick={onMarkSold} className="btn-secondary flex-1 justify-center text-green-400 border-green-800 hover:bg-green-900/30">
<ShoppingCart className="w-4 h-4" />
Mark as Sold
</button>
)}
</div>
</div>
)
}
// ─── Scan not found ───────────────────────────────────────────────────────────
function ScanNotFound({ query, onClear }) {
return (
<div className="card border-red-500/30 bg-red-500/5 text-center py-8">
<XCircle className="w-10 h-10 text-red-400 mx-auto mb-3" />
<p className="text-red-300 font-medium">No item found for:</p>
<p className="font-mono text-amber-400 text-lg mt-1">{query}</p>
<p className="text-sm text-gray-500 mt-2 mb-5">Check the asset tag and try again.</p>
<button onClick={onClear} className="btn-secondary mx-auto">
<RotateCcw className="w-4 h-4" /> Try Again
</button>
</div>
)
}
// ─── Main page ────────────────────────────────────────────────────────────────
export default function Scanner() {
const { getItemByTag, updateItem, settings } = useData()
const [mode, setMode] = useState('bluetooth') // bluetooth | camera
const [result, setResult] = useState(null) // { item, query } | null
const [soldMsg, setSoldMsg] = useState(false)
const handleScan = useCallback((raw) => {
// Raw might be full QR value like "Texas Resellers|TR-A1B2C3|Name"
// or just the asset tag like "TR-A1B2C3"
let query = raw.trim()
if (query.includes('|')) {
const parts = query.split('|')
query = parts[1] || query
}
const item = getItemByTag(query)
setResult({ item: item || null, query })
setSoldMsg(false)
}, [getItemByTag])
function handleMarkSold() {
if (!result?.item) return
updateItem(result.item.id, {
status: 'Sold',
dateSold: new Date().toISOString().split('T')[0],
})
setSoldMsg(true)
// Refresh result with updated item
setTimeout(() => {
setResult(prev => ({
...prev,
item: { ...prev.item, status: 'Sold', dateSold: new Date().toISOString().split('T')[0] }
}))
}, 100)
}
function handleClear() {
setResult(null)
setSoldMsg(false)
}
return (
<div className="p-6 max-w-2xl mx-auto space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-100">Scanner</h1>
<p className="text-sm text-gray-400 mt-1">Scan a barcode or QR code from a printed label.</p>
</div>
{/* Mode tabs */}
<div className="flex gap-1 p-1 bg-gray-800 rounded-xl">
<button
onClick={() => { setMode('bluetooth'); handleClear() }}
className={`flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg text-sm font-medium transition-colors ${
mode === 'bluetooth' ? 'bg-amber-500 text-gray-900' : 'text-gray-400 hover:text-gray-100'
}`}
>
<Keyboard className="w-4 h-4" />
Bluetooth Scanner
</button>
<button
onClick={() => { setMode('camera'); handleClear() }}
className={`flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg text-sm font-medium transition-colors ${
mode === 'camera' ? 'bg-amber-500 text-gray-900' : 'text-gray-400 hover:text-gray-100'
}`}
>
<Camera className="w-4 h-4" />
Camera
</button>
</div>
{/* Scanner UI */}
{mode === 'bluetooth' && <BluetoothScanner onScan={handleScan} />}
{mode === 'camera' && <CameraScanner onScan={handleScan} />}
{/* Result */}
{result && (
<div className="space-y-3">
{soldMsg && (
<div className="flex items-center gap-2 p-3 bg-green-900/30 border border-green-700/40 rounded-xl text-green-300 text-sm">
<CheckCircle2 className="w-4 h-4 shrink-0" />
Item marked as sold!
</div>
)}
{result.item
? <ScanResult item={result.item} onMarkSold={handleMarkSold} onClear={handleClear} settings={settings} />
: <ScanNotFound query={result.query} onClear={handleClear} />
}
</div>
)}
{/* Recent scans hint */}
{!result && (
<div className="text-center py-6 text-gray-600 text-sm">
<ScanLine className="w-8 h-8 mx-auto mb-2 text-gray-700" />
Scan result will appear here
</div>
)}
</div>
)
}