From 75c91a59416bcf4bab0964efca620d18134e28f5 Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Fri, 10 Apr 2026 06:22:14 +0000 Subject: [PATCH] feat(03-05): QR scanner page with @zxing/browser and router wiring - web/src/pages/ScanPage.tsx: camera QR scanner with volt reticle overlay - extractHWID() parses both URL format and bare HW-XXXXX patterns - rear camera preference (back/rear/environment label matching) - debounce via lastScanned state prevents duplicate navigation - graceful camera permission denied error state - web/src/router.tsx: lazy-loads ScanPage with Suspense fallback spinner - web/src/lib/api.ts: typed fetch wrappers (fetchInventory, fetchInventoryItem) - web/src/components/layout/AppShell.tsx: minimal page wrapper (stub for plan 03-03) --- web/src/components/layout/AppShell.tsx | 18 +++ web/src/lib/api.ts | 33 ++++ web/src/pages/ScanPage.tsx | 205 +++++++++++++++++++++++++ web/src/router.tsx | 17 +- 4 files changed, 270 insertions(+), 3 deletions(-) create mode 100644 web/src/components/layout/AppShell.tsx create mode 100644 web/src/lib/api.ts create mode 100644 web/src/pages/ScanPage.tsx diff --git a/web/src/components/layout/AppShell.tsx b/web/src/components/layout/AppShell.tsx new file mode 100644 index 0000000..510e530 --- /dev/null +++ b/web/src/components/layout/AppShell.tsx @@ -0,0 +1,18 @@ +import { type ReactNode } from 'react' + +interface AppShellProps { + children: ReactNode +} + +// AppShell — full-page layout wrapper with consistent padding and background. +// This is a minimal version created by Plan 03-05 for ScanPage use. +// Plan 03-03 will extend this with TopBar navigation and grid layout. +export function AppShell({ children }: AppShellProps) { + return ( +
+
+ {children} +
+
+ ) +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 0000000..5b8abe1 --- /dev/null +++ b/web/src/lib/api.ts @@ -0,0 +1,33 @@ +// API client — typed fetch wrappers for the HWLab Go backend +// This file is created by Plan 03-05 as a minimal stub for ScanPage. +// Plan 03-03 will extend this with full dashboard/item-detail support. + +export interface InventoryItem { + id: number + name: string + asset_tag: string | null + hw_id: string | null + catalog_status: string | null + product_url: string | null + firmware_version: string | null + test_date: string | null + test_data: string | null + ai_notes: string | null + photo_urls: string[] +} + +export async function fetchInventory(): Promise { + const res = await fetch('/api/inventory') + if (!res.ok) { + throw new Error(`GET /api/inventory failed: ${res.status}`) + } + return res.json() as Promise +} + +export async function fetchInventoryItem(id: number): Promise { + const res = await fetch(`/api/inventory/${id}`) + if (!res.ok) { + throw new Error(`GET /api/inventory/${id} failed: ${res.status}`) + } + return res.json() as Promise +} diff --git a/web/src/pages/ScanPage.tsx b/web/src/pages/ScanPage.tsx new file mode 100644 index 0000000..1af9ee2 --- /dev/null +++ b/web/src/pages/ScanPage.tsx @@ -0,0 +1,205 @@ +import { useEffect, useRef, useState, useCallback } from 'react' +import { BrowserQRCodeReader, type IScannerControls } from '@zxing/browser' +import { useNavigate } from '@tanstack/react-router' +import { AppShell } from '@/components/layout/AppShell' +import { Button } from '@/components/ui/button' +import { Camera, Loader2, QrCode, AlertCircle } from 'lucide-react' +import { fetchInventory } from '@/lib/api' +import { useUIStore } from '@/store/ui' +import toast from 'react-hot-toast' + +function extractHWID(text: string): string | null { + // Match full URL: http://mac-mini.mg:8080/hw/HW-00001 + const urlMatch = text.match(/\/hw\/(HW-\d{5,})/i) + if (urlMatch) return urlMatch[1].toUpperCase() + // Match bare HW ID: HW-00001 + const bareMatch = text.match(/^(HW-\d{5,})$/i) + if (bareMatch) return bareMatch[1].toUpperCase() + return null +} + +type ScanState = 'idle' | 'requesting' | 'scanning' | 'resolving' | 'error' + +export function ScanPage() { + const navigate = useNavigate() + const videoRef = useRef(null) + const controlsRef = useRef(null) + const readerRef = useRef(null) + const { setScannerActive } = useUIStore() + + const [scanState, setScanState] = useState('idle') + const [errorMsg, setErrorMsg] = useState(null) + const [lastScanned, setLastScanned] = useState(null) + + const stopScanner = useCallback(() => { + controlsRef.current?.stop() + controlsRef.current = null + readerRef.current = null + setScannerActive(false) + setScanState('idle') + }, [setScannerActive]) + + // Cleanup on unmount + useEffect(() => () => { + controlsRef.current?.stop() + controlsRef.current = null + readerRef.current = null + }, []) + + const startScanner = useCallback(async () => { + setScanState('requesting') + setErrorMsg(null) + setLastScanned(null) + + try { + const reader = new BrowserQRCodeReader() + readerRef.current = reader + + const devices = await BrowserQRCodeReader.listVideoInputDevices() + if (devices.length === 0) { + throw new Error('No camera found on this device') + } + + // Prefer rear camera (contains "back", "rear", or "environment" in label) + const rear = devices.find((d) => + /back|rear|environment/i.test(d.label) + ) + const deviceId = rear?.deviceId ?? devices[devices.length - 1].deviceId + + setScanState('scanning') + setScannerActive(true) + + const controls = await reader.decodeFromVideoDevice( + deviceId, + videoRef.current!, + async (result, _err) => { + if (!result) return + const text = result.getText() + const hwid = extractHWID(text) + + if (!hwid) { + // Not an HWLab QR code — ignore silently + return + } + + // Debounce: skip if the same code was scanned recently + if (hwid === lastScanned) return + setLastScanned(hwid) + + controls.stop() + setScanState('resolving') + + try { + const items = await fetchInventory() + const item = items.find( + (i) => + i.hw_id?.toUpperCase() === hwid || + i.asset_tag?.toUpperCase() === hwid + ) + + if (!item) { + toast.error(`${hwid} not found in inventory`) + setScanState('scanning') + // Restart scanner for next scan attempt + void startScanner() + return + } + + controlsRef.current?.stop() + controlsRef.current = null + setScannerActive(false) + void navigate({ to: '/item/$id', params: { id: String(item.id) } }) + } catch (_e) { + toast.error('Failed to look up item') + setScanState('scanning') + void startScanner() + } + } + ) + + controlsRef.current = controls + } catch (e) { + setErrorMsg((e as Error).message) + setScanState('error') + setScannerActive(false) + } + }, [lastScanned, navigate, setScannerActive]) + + return ( + +
+

Scan QR Code

+

Point camera at an HWLab label to open the item

+
+ +
+ {/* Camera viewfinder */} +
+