11f4c0537d
Build and push image / build (push) Successful in 1m25s
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>
148 lines
4.6 KiB
TypeScript
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;
|
|
}
|