Files
Apothecary/web/src/components/ScanField.tsx
T
josh 11f4c0537d
Build and push image / build (push) Successful in 1m25s
Mobile view overhaul: bottom nav, card inventory, camera scanner, PWA
Replace the cramped horizontal sidebar with a 5-tab bottom nav (Home,
Inventory, Scan, Custody, More) with an elevated scan button. Convert
the 10-column inventory table to card-based list on mobile with
scrollable filter pills and long-press selection. Add full-screen
camera barcode scanner using html5-qrcode with post-scan action sheet.
Set up PWA with vite-plugin-pwa for add-to-home-screen. Convert modals
to bottom sheets, add pull-to-refresh, safe-area padding, and iOS
input zoom fix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-06 10:23:43 -04:00

148 lines
4.6 KiB
TypeScript

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 (
<Field label={mode === "assetId" ? "Scan asset ID" : mode === "sku" ? "Scan SKU" : "Scan asset id or SKU"}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
...inputStyle,
padding: "0 12px",
}}
>
<Icon name="search" size={14} color="var(--ink-3)" />
<input
autoFocus
value={scan}
onChange={(e) => 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 && (
<span
className="mono"
style={{
fontSize: 11,
color: "var(--sage)",
whiteSpace: "nowrap",
}}
>
{matchedLabel}
</span>
)}
</div>
{feedback && (
<span
style={{
fontSize: 11,
color: feedback.type === "matched" ? "var(--sage)" : "var(--terracotta)",
marginTop: 2,
}}
>
{feedback.text}
</span>
)}
</Field>
);
}
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;
}