import { useEffect, useState } from "react"; import type { Item, Product } from "../types.js"; import { Icon, Field, inputStyle } from "./primitives/index.js"; export type ScanResult = | { kind: "item"; item: Item } | { kind: "product"; product: Product }; // Scan-friendly picker. Auto-focuses on mount so a barcode scanner // can fire immediately. Exact (case-insensitive) match against assetId // (per-instance) or sku (per-product) → calls onMatch and clears itself. // Asset id wins ties since it's the more specific identifier. export function ScanField({ items, products, onMatch, onScanNoMatch, matchedLabel, mode = "both", }: { items: Item[]; products?: Product[]; onMatch: (result: ScanResult) => void; // Fired once after a debounce when the scanned text doesn't resolve to // any known asset id or SKU. The parent can use the raw value (e.g. to // open a "create new product" form prefilled with the scanned SKU). onScanNoMatch?: (raw: string) => void; matchedLabel: string | null; mode?: "both" | "assetId" | "sku"; }) { const [scan, setScan] = useState(""); const [feedback, setFeedback] = useState<{ type: "matched" | "miss"; text: string } | null>(null); useEffect(() => { const trimmed = scan.trim().toLowerCase(); if (!trimmed) { setFeedback(null); return; } const hit = lookup(trimmed, items, products, mode); if (hit) { const label = hit.kind === "item" ? hit.item.name : hit.product.sku; onMatch(hit); setScan(""); setFeedback({ type: "matched", text: `Matched ${label}` }); } }, [scan]); // eslint-disable-line react-hooks/exhaustive-deps // Show "no match" only after the user has stopped typing for a beat — // avoids flashing "no match" mid-type while a multi-char SKU is entered. useEffect(() => { if (!scan.trim() || feedback?.type === "matched") return; const timer = setTimeout(() => { const raw = scan.trim(); if (!lookup(raw.toLowerCase(), items, products, mode)) { if (onScanNoMatch) { onScanNoMatch(raw); setScan(""); setFeedback(null); } else { const missText = mode === "assetId" ? "No item matches that asset ID." : mode === "sku" ? "No product matches that SKU." : "No asset id or SKU matches that."; setFeedback({ type: "miss", text: missText }); } } }, 400); return () => clearTimeout(timer); }, [scan, items, products]); // eslint-disable-line react-hooks/exhaustive-deps return (
setScan(e.target.value)} onFocus={(e) => e.currentTarget.select()} placeholder={mode === "assetId" ? "123456" : mode === "sku" ? "SKU-XXXXXX" : "123456 or SKU-XXXXXX"} style={{ border: "none", outline: "none", background: "transparent", padding: "10px 0", fontSize: 13, flex: 1, color: "var(--ink)", fontFamily: "var(--mono)", }} /> {matchedLabel && !scan && ( ✓ {matchedLabel} )}
{feedback && ( {feedback.text} )}
); } export function lookup( trimmed: string, items: Item[], products?: Product[], mode: "both" | "assetId" | "sku" = "both", ): ScanResult | null { if (mode !== "sku") { const itemHit = items.find((i) => i.assetId.toLowerCase() === trimmed); if (itemHit) return { kind: "item", item: itemHit }; } if (mode !== "assetId") { const skuHitItem = items.find((i) => i.sku.toLowerCase() === trimmed); if (skuHitItem) return { kind: "item", item: skuHitItem }; if (products) { const productHit = products.find((p) => p.sku.toLowerCase() === trimmed); if (productHit) return { kind: "product", product: productHit }; } } return null; }