pharmacy-erp 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,6 +25,7 @@
25
25
  "clsx": "^2.1.1",
26
26
  "date-fns": "^4.1.0",
27
27
  "framer-motion": "^13.1.0",
28
+ "html5-qrcode": "^2.3.8",
28
29
  "jspdf": "^4.2.1",
29
30
  "jspdf-autotable": "^5.0.8",
30
31
  "lucide-react": "^0.468.0",
@@ -53,6 +53,9 @@ importers:
53
53
  framer-motion:
54
54
  specifier: ^13.1.0
55
55
  version: 13.1.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
56
+ html5-qrcode:
57
+ specifier: ^2.3.8
58
+ version: 2.3.8
56
59
  jspdf:
57
60
  specifier: ^4.2.1
58
61
  version: 4.2.1
@@ -1633,6 +1636,7 @@ packages:
1633
1636
  eslint@9.39.5:
1634
1637
  resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==}
1635
1638
  engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1639
+ deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
1636
1640
  hasBin: true
1637
1641
  peerDependencies:
1638
1642
  jiti: '*'
@@ -1837,6 +1841,9 @@ packages:
1837
1841
  resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
1838
1842
  engines: {node: '>=8.0.0'}
1839
1843
 
1844
+ html5-qrcode@2.3.8:
1845
+ resolution: {integrity: sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==}
1846
+
1840
1847
  ignore@5.3.2:
1841
1848
  resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
1842
1849
  engines: {node: '>= 4'}
@@ -4587,6 +4594,8 @@ snapshots:
4587
4594
  text-segmentation: 1.0.3
4588
4595
  optional: true
4589
4596
 
4597
+ html5-qrcode@2.3.8: {}
4598
+
4590
4599
  ignore@5.3.2: {}
4591
4600
 
4592
4601
  ignore@7.0.6: {}
@@ -8,8 +8,10 @@ import { Select } from '@/components/ui/select';
8
8
  import { Switch } from '@/components/ui/switch';
9
9
  import { Badge } from '@/components/ui/badge';
10
10
  import { ConfirmDialog } from '@/components/ui/confirm-dialog';
11
+ import { BarcodeScanner } from '@/components/ui/barcode-scanner';
12
+ import { useScanFeedback } from '@/components/ui/scan-feedback';
11
13
  import { toast } from 'react-hot-toast';
12
- import { Search, Plus, Edit, Trash2, Package, Pill, Beaker, Tag, DollarSign, Archive, AlertCircle, ChevronDown, Upload } from 'lucide-react';
14
+ import { Search, Plus, Edit, Trash2, Package, Pill, Beaker, Tag, DollarSign, Archive, AlertCircle, ChevronDown, Upload, Camera } from 'lucide-react';
13
15
  import { BulkImportModal } from '@/components/ui/bulk-import-modal';
14
16
  import { cn } from '@/lib/utils';
15
17
  import { useRouter, useParams, useSearchParams } from 'next/navigation';
@@ -32,10 +34,12 @@ export default function ProductsPage() {
32
34
 
33
35
  const [slideOpen, setSlideOpen] = useState(false);
34
36
  const [importModalOpen, setImportModalOpen] = useState(false);
37
+ const [scannerOpen, setScannerOpen] = useState(false);
35
38
  const [editingId, setEditingId] = useState<string | null>(null);
36
39
 
37
40
  const [deleteDialog, setDeleteDialog] = useState({ open: false, id: '', name: '' });
38
41
  const [isSaving, setIsSaving] = useState(false);
42
+ const { playSuccess, playError } = useScanFeedback();
39
43
 
40
44
  // Form State
41
45
  const defaultForm = {
@@ -521,13 +525,23 @@ export default function ProductsPage() {
521
525
  className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground font-mono focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
522
526
  />
523
527
  </div>
524
- <div className="space-y-1.5">
528
+ <div className="space-y-1.5 relative">
525
529
  <label className="text-sm text-muted-foreground">Barcode</label>
526
- <input
527
- type="text"
528
- value={formData.barcode} onChange={e => setFormData({...formData, barcode: e.target.value})}
529
- className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground font-mono focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
530
- />
530
+ <div className="flex gap-2">
531
+ <input
532
+ type="text"
533
+ value={formData.barcode} onChange={e => setFormData({...formData, barcode: e.target.value})}
534
+ className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground font-mono focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
535
+ />
536
+ <button
537
+ type="button"
538
+ onClick={() => setScannerOpen(true)}
539
+ className="shrink-0 p-2 border border-border rounded-lg hover:bg-surface text-brand-500 transition-colors"
540
+ title="Scan Barcode"
541
+ >
542
+ <Camera size={20} />
543
+ </button>
544
+ </div>
531
545
  </div>
532
546
  </div>
533
547
  </div>
@@ -748,6 +762,26 @@ export default function ProductsPage() {
748
762
  onOpenChange={setImportModalOpen}
749
763
  onImportComplete={fetchData}
750
764
  />
765
+
766
+ {/* Barcode Scanner Modal */}
767
+ <BarcodeScanner
768
+ isOpen={scannerOpen}
769
+ onClose={() => setScannerOpen(false)}
770
+ title="Scan Barcode / QR Code"
771
+ onScan={(code) => {
772
+ setScannerOpen(false);
773
+ // Check if barcode already exists in loaded medicines
774
+ const exists = medicines.find(m => m.barcode === code);
775
+ if (exists) {
776
+ playError();
777
+ toast.error(`Barcode belongs to existing medicine: ${exists.name_en}`);
778
+ } else {
779
+ playSuccess();
780
+ setFormData(prev => ({ ...prev, barcode: code }));
781
+ toast.success("Barcode filled!");
782
+ }
783
+ }}
784
+ />
751
785
  </div>
752
786
  );
753
787
  }
@@ -12,11 +12,15 @@ import PaymentSettingsModal from '@/components/pos/PaymentSettingsModal';
12
12
  import { CashSessionGuard } from '@/components/pos/CashSessionGuard';
13
13
  import { CloseSessionModal } from '@/components/pos/CloseSessionModal';
14
14
  import { SaleReceipt } from '@/components/pos/SaleReceipt';
15
+ import { BarcodeScanner } from '@/components/ui/barcode-scanner';
16
+ import { useScanFeedback } from '@/components/ui/scan-feedback';
17
+ import { useBarcodeScanner } from '@/hooks/use-barcode-scanner';
15
18
  import type { Product } from '@/components/pos/ProductCatalog';
16
19
  import {
17
20
  Menu,
18
21
  Search,
19
22
  ScanBarcode,
23
+ Camera,
20
24
  ChevronDown,
21
25
  User,
22
26
  X,
@@ -50,6 +54,9 @@ function PosContent({ session }: { session: { id: string; opening_amount: number
50
54
  const [receiptOpen, setReceiptOpen] = useState(false);
51
55
  const [saleReceiptData, setSaleReceiptData] = useState<any>(null);
52
56
 
57
+ // Camera Scanner State
58
+ const [cameraScannerOpen, setCameraScannerOpen] = useState(false);
59
+
53
60
  const searchInputRef = useRef<HTMLInputElement>(null);
54
61
  const dropdownRef = useRef<HTMLDivElement>(null);
55
62
  const userMenuRef = useRef<HTMLDivElement>(null);
@@ -154,82 +161,99 @@ function PosContent({ session }: { session: { id: string; opening_amount: number
154
161
  [addItem]
155
162
  );
156
163
 
157
- // Barcode Scanner Listener (Detect hardware scanners fast-typing + enter)
158
- useEffect(() => {
159
- let barcodeBuffer = '';
160
- let lastKeyTime = Date.now();
164
+ // Scan feedback (beep sounds + visual flash)
165
+ const { playSuccess, playError, flashElement } = useScanFeedback();
166
+
167
+ // Shared barcode scan handler (used by both USB scanner and camera scanner)
168
+ const handleBarcodeScan = useCallback(
169
+ (scannedCode: string) => {
170
+ // First, try local lookup by product ID or barcode
171
+ const found = products.find(
172
+ (p) =>
173
+ p.id === scannedCode ||
174
+ p.name.toLowerCase() === scannedCode.toLowerCase()
175
+ );
176
+
177
+ if (found) {
178
+ handleProductClick(found);
179
+ playSuccess();
180
+ flashElement('green', searchInputRef.current);
181
+ toast.success(`✅ Scanned: ${found.name}`);
182
+ return;
183
+ }
184
+
185
+ // Fallback: search via API (matches barcode field in database)
186
+ api
187
+ .searchMedicines(scannedCode)
188
+ .then((res: any) => {
189
+ if (Array.isArray(res) && res.length > 0) {
190
+ const match = products.find((p) => p.medicine_id === res[0].id);
191
+ if (match) {
192
+ handleProductClick(match);
193
+ playSuccess();
194
+ flashElement('green', searchInputRef.current);
195
+ toast.success(`✅ Scanned: ${match.name}`);
196
+ } else {
197
+ playError();
198
+ flashElement('red', searchInputRef.current);
199
+ toast.error(`Scanned item not in stock catalog`);
200
+ }
201
+ } else {
202
+ playError();
203
+ flashElement('red', searchInputRef.current);
204
+ toast.error(`Barcode not found: ${scannedCode}`);
205
+ }
206
+ })
207
+ .catch(() => {
208
+ playError();
209
+ flashElement('red', searchInputRef.current);
210
+ toast.error(`Product not found for code: ${scannedCode}`);
211
+ });
212
+ },
213
+ [products, handleProductClick, playSuccess, playError, flashElement]
214
+ );
161
215
 
216
+ // USB/Bluetooth hardware barcode scanner hook
217
+ useBarcodeScanner({
218
+ onScan: handleBarcodeScan,
219
+ enabled: !paymentMode && !cameraScannerOpen,
220
+ });
221
+
222
+ // Camera scanner callback
223
+ const handleCameraScan = useCallback(
224
+ (code: string) => {
225
+ setCameraScannerOpen(false);
226
+ handleBarcodeScan(code);
227
+ },
228
+ [handleBarcodeScan]
229
+ );
230
+
231
+ // Keyboard shortcuts (F2 = search, F4 = camera, F9 = payment, Escape)
232
+ useEffect(() => {
162
233
  const handleKeyDown = (e: KeyboardEvent) => {
163
- // Global keyboard shortcuts
164
234
  if (e.key === 'F2') {
165
235
  e.preventDefault();
166
236
  searchInputRef.current?.focus();
167
- return;
237
+ }
238
+ if (e.key === 'F4') {
239
+ e.preventDefault();
240
+ setCameraScannerOpen(true);
168
241
  }
169
242
  if (e.key === 'F9' && cart.length > 0) {
170
243
  e.preventDefault();
171
244
  setPaymentMode(true);
172
- return;
173
245
  }
174
246
  if (e.key === 'Escape') {
247
+ if (cameraScannerOpen) setCameraScannerOpen(false);
175
248
  if (paymentMode) setPaymentMode(false);
176
249
  if (showDropdown) setShowDropdown(false);
177
250
  if (userMenuOpen) setUserMenuOpen(false);
178
- return;
179
- }
180
-
181
- // If user is focused on an input/textarea (not scanning outside), skip global barcode hook
182
- const activeTag = document.activeElement?.tagName.toLowerCase();
183
- const isInputActive = activeTag === 'input' || activeTag === 'textarea';
184
-
185
- const currentTime = Date.now();
186
- const timeDiff = currentTime - lastKeyTime;
187
- lastKeyTime = currentTime;
188
-
189
- // Barcode scanners usually send characters within 30-50ms of each other
190
- if (timeDiff > 80) {
191
- barcodeBuffer = '';
192
- }
193
-
194
- if (e.key === 'Enter') {
195
- if (barcodeBuffer.length >= 3) {
196
- e.preventDefault();
197
- const scannedCode = barcodeBuffer.trim();
198
- barcodeBuffer = '';
199
-
200
- // Look up scanned barcode in products list or via API search
201
- const found = products.find(p => p.id === scannedCode || p.name.toLowerCase() === scannedCode.toLowerCase());
202
- if (found) {
203
- handleProductClick(found);
204
- toast.success(`Scanned: ${found.name}`);
205
- } else {
206
- api.searchMedicines(scannedCode)
207
- .then((res: any) => {
208
- if (Array.isArray(res) && res.length > 0) {
209
- const match = products.find(p => p.medicine_id === res[0].id);
210
- if (match) {
211
- handleProductClick(match);
212
- toast.success(`Scanned: ${match.name}`);
213
- } else {
214
- toast.error(`Scanned item not in stock catalog`);
215
- }
216
- } else {
217
- toast.error(`Barcode not found: ${scannedCode}`);
218
- }
219
- })
220
- .catch(() => {
221
- toast.error(`Product not found for code: ${scannedCode}`);
222
- });
223
- }
224
- }
225
- } else if (e.key.length === 1 && !isInputActive) {
226
- barcodeBuffer += e.key;
227
251
  }
228
252
  };
229
253
 
230
254
  window.addEventListener('keydown', handleKeyDown);
231
255
  return () => window.removeEventListener('keydown', handleKeyDown);
232
- }, [cart.length, paymentMode, showDropdown, userMenuOpen, products, handleProductClick]);
256
+ }, [cart.length, paymentMode, showDropdown, userMenuOpen, cameraScannerOpen]);
233
257
 
234
258
  // Click outside to close dropdowns
235
259
  useEffect(() => {
@@ -330,10 +354,18 @@ function PosContent({ session }: { session: { id: string; opening_amount: number
330
354
  {theme === 'dark' ? <Sun size={18} className="md:w-5 md:h-5" /> : <Moon size={18} className="md:w-5 md:h-5" />}
331
355
  </button>
332
356
 
357
+ <button
358
+ onClick={() => setCameraScannerOpen(true)}
359
+ className="p-1.5 md:p-2 bg-transparent border border-border rounded-lg text-foreground hover:bg-surface transition-colors shrink-0"
360
+ title="Scan with Camera (F4)"
361
+ >
362
+ <Camera size={18} className="md:w-5 md:h-5 text-brand-500" />
363
+ </button>
364
+
333
365
  <button
334
366
  onClick={() => searchInputRef.current?.focus()}
335
367
  className="p-1.5 md:p-2 bg-transparent border border-border rounded-lg text-foreground hover:bg-surface transition-colors shrink-0"
336
- title="Scan Barcode (Focus Search)"
368
+ title="Focus Barcode Search (F2)"
337
369
  >
338
370
  <ScanBarcode size={18} className="md:w-5 md:h-5" />
339
371
  </button>
@@ -440,13 +472,24 @@ function PosContent({ session }: { session: { id: string; opening_amount: number
440
472
  onClose={() => setSettingsOpen(false)}
441
473
  />
442
474
 
443
- {/* ===== SALE RECEIPT MODAL ===== */}
475
+ {/* Payment Success Receipt */}
444
476
  <SaleReceipt
445
477
  open={receiptOpen}
446
- onClose={() => setReceiptOpen(false)}
478
+ onClose={() => {
479
+ setReceiptOpen(false);
480
+ setSaleReceiptData(null);
481
+ }}
447
482
  saleData={saleReceiptData}
448
483
  />
449
484
 
485
+ {/* Camera Barcode Scanner */}
486
+ <BarcodeScanner
487
+ isOpen={cameraScannerOpen}
488
+ onClose={() => setCameraScannerOpen(false)}
489
+ onScan={handleCameraScan}
490
+ title="Scan Product Barcode"
491
+ />
492
+
450
493
  {/* ===== CLOSE REGISTER MODAL ===== */}
451
494
  <CloseSessionModal
452
495
  open={closeSessionOpen}
@@ -0,0 +1,222 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useRef, useCallback } from 'react';
4
+ import { X, Camera, SwitchCamera, Flashlight, Loader2 } from 'lucide-react';
5
+
6
+ interface BarcodeScannerProps {
7
+ /** Whether the scanner modal is open */
8
+ isOpen: boolean;
9
+ /** Called when the modal is closed */
10
+ onClose: () => void;
11
+ /** Called when a barcode/QR code is successfully scanned */
12
+ onScan: (code: string) => void;
13
+ /** Title shown at the top of the modal */
14
+ title?: string;
15
+ }
16
+
17
+ /**
18
+ * Camera-based barcode and QR code scanner modal.
19
+ *
20
+ * Uses the `html5-qrcode` library to access the device camera and decode
21
+ * barcodes in real-time. Supports EAN-13, EAN-8, UPC-A, Code-128, QR Code,
22
+ * Data Matrix, and more.
23
+ *
24
+ * Works on mobile (front/back camera), tablets, and desktop webcams.
25
+ *
26
+ * @example
27
+ * ```tsx
28
+ * const [scannerOpen, setScannerOpen] = useState(false);
29
+ *
30
+ * <BarcodeScanner
31
+ * isOpen={scannerOpen}
32
+ * onClose={() => setScannerOpen(false)}
33
+ * onScan={(code) => {
34
+ * console.log('Scanned:', code);
35
+ * setScannerOpen(false);
36
+ * }}
37
+ * />
38
+ * ```
39
+ */
40
+ export function BarcodeScanner({ isOpen, onClose, onScan, title = 'Scan Barcode' }: BarcodeScannerProps) {
41
+ const [error, setError] = useState<string | null>(null);
42
+ const [loading, setLoading] = useState(true);
43
+ const [facingMode, setFacingMode] = useState<'environment' | 'user'>('environment');
44
+ const scannerRef = useRef<any>(null);
45
+ const containerRef = useRef<HTMLDivElement>(null);
46
+ const hasScannedRef = useRef(false);
47
+
48
+ const stopScanner = useCallback(async () => {
49
+ if (scannerRef.current) {
50
+ try {
51
+ const state = scannerRef.current.getState();
52
+ // State 2 = SCANNING
53
+ if (state === 2) {
54
+ await scannerRef.current.stop();
55
+ }
56
+ scannerRef.current.clear();
57
+ } catch {
58
+ // Ignore cleanup errors
59
+ }
60
+ scannerRef.current = null;
61
+ }
62
+ }, []);
63
+
64
+ const startScanner = useCallback(async () => {
65
+ if (!isOpen) return;
66
+
67
+ setLoading(true);
68
+ setError(null);
69
+ hasScannedRef.current = false;
70
+
71
+ // Dynamic import to avoid SSR issues
72
+ const { Html5Qrcode } = await import('html5-qrcode');
73
+
74
+ // Clean up any previous instance
75
+ await stopScanner();
76
+
77
+ // Wait for DOM to be ready
78
+ await new Promise(resolve => setTimeout(resolve, 100));
79
+
80
+ const readerId = 'barcode-reader';
81
+ const readerElement = document.getElementById(readerId);
82
+ if (!readerElement) {
83
+ setError('Scanner container not found');
84
+ setLoading(false);
85
+ return;
86
+ }
87
+
88
+ try {
89
+ const scanner = new Html5Qrcode(readerId);
90
+ scannerRef.current = scanner;
91
+
92
+ await scanner.start(
93
+ { facingMode },
94
+ {
95
+ fps: 10,
96
+ qrbox: { width: 250, height: 250 },
97
+ aspectRatio: 1.0,
98
+ },
99
+ (decodedText: string) => {
100
+ // Prevent double scans
101
+ if (hasScannedRef.current) return;
102
+ hasScannedRef.current = true;
103
+
104
+ onScan(decodedText);
105
+ },
106
+ () => {
107
+ // QR decode failure — this fires continuously, ignore
108
+ }
109
+ );
110
+
111
+ setLoading(false);
112
+ } catch (err: any) {
113
+ setLoading(false);
114
+ if (err?.message?.includes('NotAllowedError') || err?.name === 'NotAllowedError') {
115
+ setError('Camera permission denied. Please allow camera access in your browser settings.');
116
+ } else if (err?.message?.includes('NotFoundError') || err?.name === 'NotFoundError') {
117
+ setError('No camera found on this device.');
118
+ } else {
119
+ setError(`Camera error: ${err?.message || 'Unknown error'}`);
120
+ }
121
+ }
122
+ }, [isOpen, facingMode, onScan, stopScanner]);
123
+
124
+ // Start scanner when modal opens
125
+ useEffect(() => {
126
+ if (isOpen) {
127
+ startScanner();
128
+ }
129
+ return () => {
130
+ stopScanner();
131
+ };
132
+ }, [isOpen, startScanner, stopScanner]);
133
+
134
+ // Toggle front/back camera
135
+ const toggleCamera = useCallback(async () => {
136
+ setFacingMode(prev => prev === 'environment' ? 'user' : 'environment');
137
+ }, []);
138
+
139
+ if (!isOpen) return null;
140
+
141
+ return (
142
+ <div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm">
143
+ <div className="bg-background border border-border rounded-2xl shadow-2xl w-full max-w-md mx-4 overflow-hidden">
144
+ {/* Header */}
145
+ <div className="flex items-center justify-between px-4 py-3 border-b border-border">
146
+ <div className="flex items-center gap-2">
147
+ <Camera size={20} className="text-brand-500" />
148
+ <h3 className="font-semibold text-foreground">{title}</h3>
149
+ </div>
150
+ <button
151
+ onClick={() => { stopScanner(); onClose(); }}
152
+ className="p-1.5 rounded-lg hover:bg-surface transition-colors text-foreground/60 hover:text-foreground"
153
+ >
154
+ <X size={20} />
155
+ </button>
156
+ </div>
157
+
158
+ {/* Camera View */}
159
+ <div className="relative bg-black">
160
+ <div
161
+ id="barcode-reader"
162
+ ref={containerRef}
163
+ className="w-full aspect-square"
164
+ />
165
+
166
+ {/* Loading overlay */}
167
+ {loading && (
168
+ <div className="absolute inset-0 flex flex-col items-center justify-center bg-black/80 text-white gap-3">
169
+ <Loader2 size={32} className="animate-spin text-brand-500" />
170
+ <p className="text-sm text-white/80">Starting camera...</p>
171
+ </div>
172
+ )}
173
+
174
+ {/* Error overlay */}
175
+ {error && (
176
+ <div className="absolute inset-0 flex flex-col items-center justify-center bg-black/80 text-white gap-3 p-6">
177
+ <Camera size={48} className="text-red-400" />
178
+ <p className="text-sm text-center text-red-300">{error}</p>
179
+ <button
180
+ onClick={startScanner}
181
+ className="px-4 py-2 bg-brand-500 text-white rounded-lg text-sm hover:bg-brand-600 transition-colors"
182
+ >
183
+ Try Again
184
+ </button>
185
+ </div>
186
+ )}
187
+
188
+ {/* Scan overlay frame */}
189
+ {!loading && !error && (
190
+ <div className="absolute inset-0 pointer-events-none">
191
+ <div className="absolute inset-0 flex items-center justify-center">
192
+ <div className="w-[250px] h-[250px] border-2 border-brand-500/50 rounded-lg">
193
+ {/* Corner accents */}
194
+ <div className="absolute top-0 left-0 w-6 h-6 border-t-3 border-l-3 border-brand-400 rounded-tl-lg" />
195
+ <div className="absolute top-0 right-0 w-6 h-6 border-t-3 border-r-3 border-brand-400 rounded-tr-lg" />
196
+ <div className="absolute bottom-0 left-0 w-6 h-6 border-b-3 border-l-3 border-brand-400 rounded-bl-lg" />
197
+ <div className="absolute bottom-0 right-0 w-6 h-6 border-b-3 border-r-3 border-brand-400 rounded-br-lg" />
198
+ </div>
199
+ </div>
200
+ </div>
201
+ )}
202
+ </div>
203
+
204
+ {/* Controls */}
205
+ <div className="flex items-center justify-between px-4 py-3 border-t border-border">
206
+ <p className="text-xs text-foreground/50">
207
+ Point camera at barcode or QR code
208
+ </p>
209
+ <div className="flex items-center gap-2">
210
+ <button
211
+ onClick={toggleCamera}
212
+ className="p-2 rounded-lg bg-surface hover:bg-border transition-colors text-foreground/70"
213
+ title="Switch Camera"
214
+ >
215
+ <SwitchCamera size={18} />
216
+ </button>
217
+ </div>
218
+ </div>
219
+ </div>
220
+ </div>
221
+ );
222
+ }
@@ -0,0 +1,100 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useRef } from 'react';
4
+
5
+ const BEEP_DURATION = 150;
6
+ const BEEP_VOLUME = 0.3;
7
+
8
+ /**
9
+ * Creates a beep sound using the Web Audio API.
10
+ * No external audio files needed — works everywhere.
11
+ */
12
+ function createBeep(frequency: number, duration: number, volume: number) {
13
+ try {
14
+ const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)();
15
+ const oscillator = audioCtx.createOscillator();
16
+ const gainNode = audioCtx.createGain();
17
+
18
+ oscillator.connect(gainNode);
19
+ gainNode.connect(audioCtx.destination);
20
+
21
+ oscillator.frequency.value = frequency;
22
+ oscillator.type = 'sine';
23
+ gainNode.gain.value = volume;
24
+
25
+ oscillator.start();
26
+ oscillator.stop(audioCtx.currentTime + duration / 1000);
27
+
28
+ // Clean up
29
+ oscillator.onended = () => audioCtx.close();
30
+ } catch {
31
+ // Audio not supported — fail silently
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Hook that provides audio and visual feedback for barcode scanning.
37
+ *
38
+ * @example
39
+ * ```tsx
40
+ * const { playSuccess, playError, flashElement } = useScanFeedback();
41
+ *
42
+ * // On successful scan
43
+ * playSuccess();
44
+ * flashElement('green');
45
+ *
46
+ * // On failed scan
47
+ * playError();
48
+ * flashElement('red');
49
+ * ```
50
+ */
51
+ export function useScanFeedback() {
52
+ const flashTimeoutRef = useRef<NodeJS.Timeout | null>(null);
53
+ const flashElementRef = useRef<HTMLElement | null>(null);
54
+
55
+ /** Play a success beep (high pitch) */
56
+ const playSuccess = useCallback(() => {
57
+ createBeep(1200, BEEP_DURATION, BEEP_VOLUME);
58
+ // Double beep for success
59
+ setTimeout(() => createBeep(1500, BEEP_DURATION, BEEP_VOLUME), 100);
60
+ }, []);
61
+
62
+ /** Play an error beep (low pitch) */
63
+ const playError = useCallback(() => {
64
+ createBeep(300, 300, BEEP_VOLUME);
65
+ }, []);
66
+
67
+ /**
68
+ * Flash the border of a target element green or red.
69
+ * Pass the element directly or use the ref returned by `getFlashRef`.
70
+ */
71
+ const flashElement = useCallback((color: 'green' | 'red', element?: HTMLElement | null) => {
72
+ const target = element || flashElementRef.current;
73
+ if (!target) return;
74
+
75
+ // Clear any existing flash
76
+ if (flashTimeoutRef.current) {
77
+ clearTimeout(flashTimeoutRef.current);
78
+ }
79
+
80
+ const borderColor = color === 'green' ? 'rgb(34, 197, 94)' : 'rgb(239, 68, 68)';
81
+ const shadowColor = color === 'green' ? 'rgba(34, 197, 94, 0.4)' : 'rgba(239, 68, 68, 0.4)';
82
+
83
+ target.style.transition = 'box-shadow 0.15s ease-in, border-color 0.15s ease-in';
84
+ target.style.borderColor = borderColor;
85
+ target.style.boxShadow = `0 0 0 3px ${shadowColor}`;
86
+
87
+ flashTimeoutRef.current = setTimeout(() => {
88
+ target.style.borderColor = '';
89
+ target.style.boxShadow = '';
90
+ target.style.transition = '';
91
+ }, 600);
92
+ }, []);
93
+
94
+ return {
95
+ playSuccess,
96
+ playError,
97
+ flashElement,
98
+ flashElementRef,
99
+ };
100
+ }
@@ -0,0 +1,84 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useCallback } from 'react';
4
+
5
+ interface UseBarcodeOptions {
6
+ /** Callback when a barcode is scanned */
7
+ onScan: (code: string) => void;
8
+ /** Minimum barcode length (default: 3) */
9
+ minLength?: number;
10
+ /** Maximum time between keystrokes in ms (default: 80) */
11
+ maxDelay?: number;
12
+ /** Whether the scanner is enabled (default: true) */
13
+ enabled?: boolean;
14
+ }
15
+
16
+ /**
17
+ * Custom hook that detects USB/Bluetooth barcode scanner input.
18
+ *
19
+ * Hardware barcode scanners work like fast keyboards — they type characters
20
+ * very quickly (under 50ms between keystrokes) and end with Enter.
21
+ * This hook detects that pattern and fires `onScan` with the scanned code.
22
+ *
23
+ * @example
24
+ * ```tsx
25
+ * useBarcodeScanner({
26
+ * onScan: (code) => {
27
+ * console.log('Scanned:', code);
28
+ * // Look up product by barcode and add to cart
29
+ * },
30
+ * });
31
+ * ```
32
+ */
33
+ export function useBarcodeScanner({
34
+ onScan,
35
+ minLength = 3,
36
+ maxDelay = 80,
37
+ enabled = true,
38
+ }: UseBarcodeOptions) {
39
+ const bufferRef = useRef('');
40
+ const lastKeyTimeRef = useRef(Date.now());
41
+ const onScanRef = useRef(onScan);
42
+
43
+ // Keep callback ref updated without re-registering the listener
44
+ useEffect(() => {
45
+ onScanRef.current = onScan;
46
+ }, [onScan]);
47
+
48
+ const handleKeyDown = useCallback(
49
+ (e: KeyboardEvent) => {
50
+ // Skip if focused on an input/textarea (let the input handle it)
51
+ const activeTag = document.activeElement?.tagName.toLowerCase();
52
+ const isInputActive = activeTag === 'input' || activeTag === 'textarea';
53
+
54
+ const now = Date.now();
55
+ const timeDiff = now - lastKeyTimeRef.current;
56
+ lastKeyTimeRef.current = now;
57
+
58
+ // Reset buffer if too much time has passed between keystrokes
59
+ if (timeDiff > maxDelay) {
60
+ bufferRef.current = '';
61
+ }
62
+
63
+ if (e.key === 'Enter') {
64
+ const code = bufferRef.current.trim();
65
+ if (code.length >= minLength) {
66
+ e.preventDefault();
67
+ e.stopPropagation();
68
+ bufferRef.current = '';
69
+ onScanRef.current(code);
70
+ }
71
+ } else if (e.key.length === 1 && !isInputActive) {
72
+ bufferRef.current += e.key;
73
+ }
74
+ },
75
+ [minLength, maxDelay]
76
+ );
77
+
78
+ useEffect(() => {
79
+ if (!enabled) return;
80
+
81
+ window.addEventListener('keydown', handleKeyDown, { capture: true });
82
+ return () => window.removeEventListener('keydown', handleKeyDown, { capture: true });
83
+ }, [enabled, handleKeyDown]);
84
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pharmacy-erp",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Pharmacy ERP & POS System — Enterprise-grade pharmacy management with full Arabic (RTL) and English support",
5
5
  "bin": {
6
6
  "pharmacy-erp": "./bin/cli.js"