364 lines
14 KiB
React
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>
|
|
)
|
|
}
|