EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
page.tsx
Go to the documentation of this file.
1"use client";
2
3import React, { useEffect, useMemo, useRef, useState } from "react";
4import { apiClient } from "@/lib/client/apiClient";
5
6interface HistoryEntry {
7 id: string;
8 barcode: string;
9 qty: number;
10 desc: string;
11 state: "Pending" | "Saving" | "Recorded" | "Error";
12 time: number;
13}
14
15interface StoreOption {
16 id: number;
17 name: string;
18}
19
20const LS_FLAGS_KEY = "PageStock2Count.Flags";
21
22// Bit flags (to mirror legacy toggles)
23// 1: Speak Product Descriptions
24// 2: Buzzer for Errors
25// 4: Speak Errors
26// 8: Speak Quantities
27// 16: Display Image
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;
32}
33
34function saveFlagsToStorage(n: number) {
35 try {
36 window.localStorage.setItem(LS_FLAGS_KEY, String(n));
37 } catch {}
38}
39
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);
55
56 useEffect(() => {
57 setFlags(flagsFromStorage());
58 }, []);
59
60 useEffect(() => {
61 inputRef.current?.focus();
62 }, []);
63
64 useEffect(() => {
65 // load stores for optional linkage to overview
66 const loadStores = async () => {
67 try {
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 || "" })));
71 }
72 } catch (e) {
73 // ignore
74 }
75 };
76 loadStores();
77 }, []);
78
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]);
84
85 const toggleFlag = (bit: number) => {
86 const next = flags ^ bit;
87 setFlags(next);
88 saveFlagsToStorage(next);
89 };
90
91 const playVoice = (text: string) => {
92 const el = audioRef.current;
93 if (!el) return;
94 el.src = "/geni/audio_|" + encodeURIComponent(text) + "|";
95 el.load();
96 el.play().catch(() => {});
97 };
98
99 const playBuzzer = () => {
100 const el = audioRef.current;
101 if (!el) return;
102 el.src = "/geni/audio_buzzer";
103 el.load();
104 el.play().catch(() => {});
105 };
106
107 const lookupProductByBarcode = async (bc: string): Promise<{ pid?: number; desc?: string; img?: string } | null> => {
108 try {
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) {
113 const r = d[0];
114 let img = "";
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 };
119 }
120 }
121 return null;
122 } catch (e) {
123 return null;
124 }
125 };
126
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));
130 };
131
132 const handleSave = async () => {
133 const bc = barcode.trim();
134 if (!bc) return;
135
136 const thisQty = Number(qty) || 1;
137
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));
144 setBarcode("");
145 inputRef.current?.focus();
146 return;
147 }
148
149 // Lookup product
150 const found = await lookupProductByBarcode(bc);
151 if (!found?.pid) {
152 if (buzzerOnError) playBuzzer();
153 if (speakErrors) playVoice("Unknown barcode");
154 setCurrentDesc(`Unrecognised: ${bc}`);
155 setImageUrl("");
156 pushHistory({ barcode: bc, qty: thisQty, desc: "Unknown barcode", state: "Error" });
157 setBarcode("");
158 inputRef.current?.focus();
159 return;
160 }
161
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));
167
168 const pending: HistoryEntry = {
169 id: crypto.randomUUID(),
170 barcode: bc,
171 qty: thisQty,
172 desc: desc,
173 state: "Saving",
174 time: Date.now(),
175 };
176 setHistory((prev) => [pending, ...prev].slice(0, 50));
177
178 setSaving(true);
179 try {
180 const resp = await apiClient.recordStocktakeCount({
181 productId: found.pid,
182 quantity: thisQty,
183 barcode: bc,
184 storeId: storeId !== "0" ? storeId : undefined
185 });
186
187 if (!resp.success) throw new Error(resp.error || 'Failed to record stocktake');
188
189 // Mark recorded
190 setHistory((prev) => prev.map((e) => (e.id === pending.id ? { ...e, state: "Recorded" } : e)));
191 } catch (e) {
192 if (buzzerOnError) playBuzzer();
193 setHistory((prev) => prev.map((e) => (e.id === pending.id ? { ...e, state: "Error" } : e)));
194 } finally {
195 setSaving(false);
196 setBarcode("");
197 inputRef.current?.focus();
198 }
199 };
200
201 const escapeXml = (v: string) => v.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
202
203 const createOverviewEntry = async () => {
204 if (!reference.trim()) return;
205 if (!storeId || storeId === "0") return;
206 try {
207 setLinking(true);
208 const xml = [
209 "<DATI>",
210 "<f8_s>retailmax.elink.stocktake.control</f8_s>",
211 "<f11_B>I</f11_B>",
212 "<f180_E>1</f180_E>",
213 `<f202_s>${escapeXml(reference)}</f202_s>`,
214 `<f207_E>${storeId}</f207_E>`,
215 "</DATI>",
216 ].join("");
217 const resp = await fetch("/dati", {
218 method: "POST",
219 headers: { "Content-Type": "application/xml", Accept: "application/json" },
220 credentials: "include",
221 body: xml,
222 });
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;
226 try {
227 const data = await resp.json();
228 const nid = Number(data?.f100 || data?.DATS?.[0]?.f100 || 0);
229 if (nid > 0) id = nid;
230 } catch {}
231 setLinkedStocktakeId(id);
232 } catch (e) {
233 // swallow; user can retry
234 } finally {
235 setLinking(false);
236 }
237 };
238
239 return (
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">
242 <div>
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>
245 </div>
246 </div>
247
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>
253 <input
254 ref={inputRef}
255 value={barcode}
256 onChange={(e) => setBarcode(e.target.value)}
257 onKeyDown={(e) => {
258 if (e.key === "Enter") handleSave();
259 }}
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"
262 autoFocus
263 />
264 </div>
265 <div>
266 <label className="block text-sm font-medium text-text mb-1">Qty</label>
267 <input
268 type="number"
269 value={qty}
270 min={1}
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"
273 />
274 </div>
275 <div className="self-start">
276 <button
277 onClick={handleSave}
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"
280 >
281 {saving ? "Saving..." : "Save Barcode"}
282 </button>
283 </div>
284 </div>
285
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
291 </label>
292 <label className="flex items-center gap-2">
293 <input type="checkbox" checked={buzzerOnError} onChange={() => toggleFlag(2)} className="rounded" />
294 Buzzer for Errors
295 </label>
296 <label className="flex items-center gap-2">
297 <input type="checkbox" checked={speakErrors} onChange={() => toggleFlag(4)} className="rounded" />
298 Speak Errors
299 </label>
300 <label className="flex items-center gap-2">
301 <input type="checkbox" checked={speakQty} onChange={() => toggleFlag(8)} className="rounded" />
302 Speak Quantities
303 </label>
304 <label className="flex items-center gap-2">
305 <input type="checkbox" checked={showImage} onChange={() => toggleFlag(16)} className="rounded" />
306 Display Image
307 </label>
308 </div>
309 </div>
310
311 <div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-3">
312 <div>
313 <label className="block text-sm font-medium text-text mb-1">Reference (shows on overview)</label>
314 <input
315 value={reference}
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"
319 />
320 </div>
321 <div>
322 <label className="block text-sm font-medium text-text mb-1">Store</label>
323 <select
324 value={storeId}
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"
327 >
328 <option value="0">-- Select store --</option>
329 {stores.map((s) => (
330 <option key={s.id} value={s.id}>{s.name}</option>
331 ))}
332 </select>
333 </div>
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>
339 </div>
340 ) : (
341 <button
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"
345 >
346 {linking ? "Creating…" : "Create Overview Entry"}
347 </button>
348 )}
349 </div>
350 </div>
351
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>
356 ) : (
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">
360 <div>
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>
364 </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>
374 ) : (
375 <span className="inline-block px-2 py-0.5 text-xs rounded bg-surface-2 text-text">Pending</span>
376 )}
377 </div>
378 </div>
379 </div>
380 ))}
381 </div>
382 )}
383 </div>
384 </div>
385
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" />
393 ) : null}
394 </div>
395 </div>
396 </div>
397 </div>
398
399 <audio ref={audioRef} />
400 </div>
401 );
402}