3import React, { useEffect, useMemo, useRef, useState } from "react";
4import { apiClient } from "@/lib/client/apiClient";
6interface HistoryEntry {
11 state: "Pending" | "Saving" | "Recorded" | "Error";
15interface StoreOption {
20const LS_FLAGS_KEY = "PageStock2Count.Flags";
22// Bit flags (to mirror legacy toggles)
23// 1: Speak Product Descriptions
24// 2: Buzzer for Errors
28function flagsFromStorage(): number {
29 if (typeof window === "undefined") return 0;
30 const raw = window.localStorage.getItem(LS_FLAGS_KEY);
31 return raw ? Number(raw) || 0 : 0;
34function saveFlagsToStorage(n: number) {
36 window.localStorage.setItem(LS_FLAGS_KEY, String(n));
40export default function SimpleCountPage() {
41 const [barcode, setBarcode] = useState("");
42 const [qty, setQty] = useState<number>(1);
43 const [flags, setFlags] = useState<number>(0);
44 const [currentDesc, setCurrentDesc] = useState<string>("");
45 const [imageUrl, setImageUrl] = useState<string>("");
46 const [history, setHistory] = useState<HistoryEntry[]>([]);
47 const [saving, setSaving] = useState(false);
48 const [stores, setStores] = useState<StoreOption[]>([]);
49 const [storeId, setStoreId] = useState<string>("0");
50 const [reference, setReference] = useState<string>("");
51 const [linking, setLinking] = useState(false);
52 const [linkedStocktakeId, setLinkedStocktakeId] = useState<number | null>(null);
53 const inputRef = useRef<HTMLInputElement>(null);
54 const audioRef = useRef<HTMLAudioElement>(null);
57 setFlags(flagsFromStorage());
61 inputRef.current?.focus();
65 // load stores for optional linkage to overview
66 const loadStores = async () => {
68 const resp = await apiClient.getLocations({ source: 'elink' });
69 if (resp.success && (resp.data as any)?.DATS) {
70 setStores((resp.data as any).DATS.map((r: any) => ({ id: Number(r.f100) || 0, name: r.f101 || "" })));
79 const speakDesc = useMemo(() => Boolean(flags & 1), [flags]);
80 const buzzerOnError = useMemo(() => Boolean(flags & 2), [flags]);
81 const speakErrors = useMemo(() => Boolean(flags & 4), [flags]);
82 const speakQty = useMemo(() => Boolean(flags & 8), [flags]);
83 const showImage = useMemo(() => Boolean(flags & 16), [flags]);
85 const toggleFlag = (bit: number) => {
86 const next = flags ^ bit;
88 saveFlagsToStorage(next);
91 const playVoice = (text: string) => {
92 const el = audioRef.current;
94 el.src = "/geni/audio_|" + encodeURIComponent(text) + "|";
96 el.play().catch(() => {});
99 const playBuzzer = () => {
100 const el = audioRef.current;
102 el.src = "/geni/audio_buzzer";
104 el.play().catch(() => {});
107 const lookupProductByBarcode = async (bc: string): Promise<{ pid?: number; desc?: string; img?: string } | null> => {
109 const resp = await apiClient.lookupProductByBarcode(bc);
110 if (resp.success && resp.data?.DATS) {
111 const d = resp.data.DATS;
112 if (Array.isArray(d) && d.length === 1) {
115 const f199 = r?.f199;
116 if (Array.isArray(f199) && f199.length > 0) img = f199[0];
117 else if (typeof f199 === "string") img = f199;
118 return { pid: Number(r?.f100) || undefined, desc: r?.f101 || "", img };
127 const pushHistory = (h: Omit<HistoryEntry, "id" | "time">) => {
128 const entry: HistoryEntry = { id: crypto.randomUUID(), time: Date.now(), ...h };
129 setHistory((prev) => [entry, ...prev].slice(0, 50));
132 const handleSave = async () => {
133 const bc = barcode.trim();
136 const thisQty = Number(qty) || 1;
138 // If last pending and same barcode, increment qty locally
139 const last = history[0];
140 if (last && last.state === "Pending" && last.barcode === bc) {
141 const upd = { ...last, qty: last.qty + thisQty } as HistoryEntry;
142 setHistory((prev) => [upd, ...prev.slice(1)]);
143 if (speakQty) playVoice(String(upd.qty));
145 inputRef.current?.focus();
150 const found = await lookupProductByBarcode(bc);
152 if (buzzerOnError) playBuzzer();
153 if (speakErrors) playVoice("Unknown barcode");
154 setCurrentDesc(`Unrecognised: ${bc}`);
156 pushHistory({ barcode: bc, qty: thisQty, desc: "Unknown barcode", state: "Error" });
158 inputRef.current?.focus();
162 const desc = found.desc || "";
163 setCurrentDesc(desc);
164 if (showImage) setImageUrl(found.img || ""); else setImageUrl("");
165 if (speakDesc && desc) playVoice(desc);
166 else if (speakQty) playVoice(String(thisQty));
168 const pending: HistoryEntry = {
169 id: crypto.randomUUID(),
176 setHistory((prev) => [pending, ...prev].slice(0, 50));
180 const resp = await apiClient.recordStocktakeCount({
181 productId: found.pid,
184 storeId: storeId !== "0" ? storeId : undefined
187 if (!resp.success) throw new Error(resp.error || 'Failed to record stocktake');
190 setHistory((prev) => prev.map((e) => (e.id === pending.id ? { ...e, state: "Recorded" } : e)));
192 if (buzzerOnError) playBuzzer();
193 setHistory((prev) => prev.map((e) => (e.id === pending.id ? { ...e, state: "Error" } : e)));
197 inputRef.current?.focus();
201 const escapeXml = (v: string) => v.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
203 const createOverviewEntry = async () => {
204 if (!reference.trim()) return;
205 if (!storeId || storeId === "0") return;
210 "<f8_s>retailmax.elink.stocktake.control</f8_s>",
212 "<f180_E>1</f180_E>",
213 `<f202_s>${escapeXml(reference)}</f202_s>`,
214 `<f207_E>${storeId}</f207_E>`,
217 const resp = await fetch("/dati", {
219 headers: { "Content-Type": "application/xml", Accept: "application/json" },
220 credentials: "include",
223 if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
224 // Best effort: try parse JSON to get new id if available
225 let id: number | null = null;
227 const data = await resp.json();
228 const nid = Number(data?.f100 || data?.DATS?.[0]?.f100 || 0);
229 if (nid > 0) id = nid;
231 setLinkedStocktakeId(id);
233 // swallow; user can retry
240 <div className="p-6 max-w-5xl mx-auto space-y-6">
241 <div className="flex items-center justify-between flex-wrap gap-3">
243 <h1 className="text-2xl font-bold text-text">Simple Counting</h1>
244 <p className="text-sm text-muted">Quick, ad-hoc item counting with barcode or keyboard entry.</p>
248 <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
249 <div className="lg:col-span-2 bg-surface rounded-lg shadow p-4">
250 <div className="flex items-end gap-3 flex-wrap">
251 <div className="grow">
252 <label className="block text-sm font-medium text-text mb-1">Barcode</label>
256 onChange={(e) => setBarcode(e.target.value)}
258 if (e.key === "Enter") handleSave();
260 className="w-full px-3 py-2 border border-border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
261 placeholder="Scan or type barcode"
266 <label className="block text-sm font-medium text-text mb-1">Qty</label>
271 onChange={(e) => setQty(Math.max(1, Number(e.target.value || 1)))}
272 className="w-24 px-3 py-2 border border-border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
275 <div className="self-start">
278 disabled={saving || !barcode.trim()}
279 className="mt-6 px-4 py-2 bg-[#00543b] text-white rounded-md text-sm font-semibold hover:bg-[#003d2b] disabled:bg-surface-2 disabled:cursor-not-allowed"
281 {saving ? "Saving..." : "Save Barcode"}
286 <div className="mt-4 text-sm text-text">
287 <div className="flex items-center gap-4 flex-wrap">
288 <label className="flex items-center gap-2">
289 <input type="checkbox" checked={speakDesc} onChange={() => toggleFlag(1)} className="rounded" />
290 Speak Product Descriptions
292 <label className="flex items-center gap-2">
293 <input type="checkbox" checked={buzzerOnError} onChange={() => toggleFlag(2)} className="rounded" />
296 <label className="flex items-center gap-2">
297 <input type="checkbox" checked={speakErrors} onChange={() => toggleFlag(4)} className="rounded" />
300 <label className="flex items-center gap-2">
301 <input type="checkbox" checked={speakQty} onChange={() => toggleFlag(8)} className="rounded" />
304 <label className="flex items-center gap-2">
305 <input type="checkbox" checked={showImage} onChange={() => toggleFlag(16)} className="rounded" />
311 <div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-3">
313 <label className="block text-sm font-medium text-text mb-1">Reference (shows on overview)</label>
316 onChange={(e) => setReference(e.target.value)}
317 className="w-full px-3 py-2 border border-border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
318 placeholder="e.g. Cycle Count Aisle 3"
322 <label className="block text-sm font-medium text-text mb-1">Store</label>
325 onChange={(e) => setStoreId(e.target.value)}
326 className="w-full px-3 py-2 border border-border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
328 <option value="0">-- Select store --</option>
330 <option key={s.id} value={s.id}>{s.name}</option>
334 <div className="flex items-end">
335 {linkedStocktakeId ? (
336 <div className="text-sm text-text">
337 Linked to stocktake {linkedStocktakeId > 0 ? `#${linkedStocktakeId}` : "(created)"}.
338 <a href="/pages/products/stocktake" className="text-[#00543b] hover:underline ml-1">Open overview</a>
342 onClick={createOverviewEntry}
343 disabled={linking || !reference.trim() || storeId === "0"}
344 className="px-4 py-2 bg-brand text-white rounded-md text-sm font-semibold hover:bg-brand/90 disabled:bg-surface-2 disabled:cursor-not-allowed"
346 {linking ? "Creating…" : "Create Overview Entry"}
352 <div className="mt-6">
353 <h3 className="text-base font-semibold text-text mb-2">History</h3>
354 {history.length === 0 ? (
355 <div className="text-sm text-muted">No scans yet.</div>
357 <div className="space-y-2">
358 {history.map((h) => (
359 <div key={h.id} className="border rounded p-2 text-sm flex items-start justify-between gap-3">
361 <div className="font-medium text-text">{h.desc || "(no description)"}</div>
362 <div className="text-muted">Barcode: {h.barcode}</div>
363 <div className="text-muted">Qty: {h.qty}</div>
365 <div className="text-right text-muted">
366 <div>{new Date(h.time).toLocaleTimeString()}</div>
367 <div className="mt-1">
368 {h.state === "Recorded" ? (
369 <span className="inline-block px-2 py-0.5 text-xs rounded bg-green-100 text-green-800">Recorded</span>
370 ) : h.state === "Saving" ? (
371 <span className="inline-block px-2 py-0.5 text-xs rounded bg-info/10 text-info">Saving…</span>
372 ) : h.state === "Error" ? (
373 <span className="inline-block px-2 py-0.5 text-xs rounded bg-red-100 text-red-800">Error</span>
375 <span className="inline-block px-2 py-0.5 text-xs rounded bg-surface-2 text-text">Pending</span>
386 <div className="bg-surface rounded-lg shadow p-4">
387 <div className="text-sm text-text">
388 <h3 className="text-base font-semibold text-text">Product</h3>
389 <div className="mt-2">
390 <div className="text-sm text-text min-h-[1.5rem]" id="proddetail">{currentDesc}</div>
391 {showImage && imageUrl ? (
392 <img src={imageUrl} alt="Product" className="mt-3 max-h-48 object-contain" />
399 <audio ref={audioRef} />