Import TexasResellers into Gitea
This commit is contained in:
@@ -0,0 +1,363 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user