pharmacy-erp 1.0.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.
Files changed (137) hide show
  1. package/.env.example +34 -0
  2. package/LICENSE +21 -0
  3. package/README.md +479 -0
  4. package/backend/Dockerfile +44 -0
  5. package/backend/app/__init__.py +1 -0
  6. package/backend/app/core/__init__.py +1 -0
  7. package/backend/app/core/audit.py +33 -0
  8. package/backend/app/core/config.py +16 -0
  9. package/backend/app/core/database.py +26 -0
  10. package/backend/app/core/deps.py +49 -0
  11. package/backend/app/core/exceptions.py +43 -0
  12. package/backend/app/core/security.py +32 -0
  13. package/backend/app/main.py +54 -0
  14. package/backend/app/modules/__init__.py +0 -0
  15. package/backend/app/modules/admin/__init__.py +1 -0
  16. package/backend/app/modules/admin/models.py +15 -0
  17. package/backend/app/modules/admin/router.py +353 -0
  18. package/backend/app/modules/auth/__init__.py +0 -0
  19. package/backend/app/modules/auth/models.py +16 -0
  20. package/backend/app/modules/auth/router.py +73 -0
  21. package/backend/app/modules/auth/schemas.py +29 -0
  22. package/backend/app/modules/cash_sessions/__init__.py +0 -0
  23. package/backend/app/modules/cash_sessions/models.py +19 -0
  24. package/backend/app/modules/cash_sessions/router.py +85 -0
  25. package/backend/app/modules/cash_sessions/schemas.py +31 -0
  26. package/backend/app/modules/customers/__init__.py +0 -0
  27. package/backend/app/modules/customers/models.py +37 -0
  28. package/backend/app/modules/customers/router.py +296 -0
  29. package/backend/app/modules/customers/schemas.py +72 -0
  30. package/backend/app/modules/inventory/__init__.py +0 -0
  31. package/backend/app/modules/inventory/models.py +16 -0
  32. package/backend/app/modules/inventory/router.py +150 -0
  33. package/backend/app/modules/inventory/schemas.py +24 -0
  34. package/backend/app/modules/medicines/__init__.py +0 -0
  35. package/backend/app/modules/medicines/models.py +54 -0
  36. package/backend/app/modules/medicines/router.py +631 -0
  37. package/backend/app/modules/medicines/schemas.py +153 -0
  38. package/backend/app/modules/medicines/template_generator.py +118 -0
  39. package/backend/app/modules/notifications/__init__.py +1 -0
  40. package/backend/app/modules/notifications/models.py +15 -0
  41. package/backend/app/modules/notifications/router.py +89 -0
  42. package/backend/app/modules/notifications/schemas.py +22 -0
  43. package/backend/app/modules/organizations/__init__.py +0 -0
  44. package/backend/app/modules/organizations/models.py +16 -0
  45. package/backend/app/modules/organizations/router.py +49 -0
  46. package/backend/app/modules/organizations/schemas.py +31 -0
  47. package/backend/app/modules/reports/__init__.py +0 -0
  48. package/backend/app/modules/reports/router.py +409 -0
  49. package/backend/app/modules/roles/__init__.py +1 -0
  50. package/backend/app/modules/roles/models.py +43 -0
  51. package/backend/app/modules/roles/router.py +89 -0
  52. package/backend/app/modules/roles/schemas.py +55 -0
  53. package/backend/app/modules/sales/__init__.py +0 -0
  54. package/backend/app/modules/sales/models.py +44 -0
  55. package/backend/app/modules/sales/router.py +63 -0
  56. package/backend/app/modules/sales/schemas.py +42 -0
  57. package/backend/app/modules/sales/service.py +128 -0
  58. package/backend/app/modules/suppliers/__init__.py +0 -0
  59. package/backend/app/modules/suppliers/models.py +14 -0
  60. package/backend/app/modules/suppliers/router.py +56 -0
  61. package/backend/app/modules/suppliers/schemas.py +27 -0
  62. package/backend/app/modules/users/__init__.py +0 -0
  63. package/backend/app/modules/users/router.py +74 -0
  64. package/backend/app/seed.py +275 -0
  65. package/backend/data/.gitkeep +1 -0
  66. package/backend/requirements.txt +17 -0
  67. package/bin/cli.js +288 -0
  68. package/docker-compose.dev.yml +42 -0
  69. package/docker-compose.yml +59 -0
  70. package/frontend/Dockerfile +56 -0
  71. package/frontend/next.config.ts +19 -0
  72. package/frontend/package.json +60 -0
  73. package/frontend/pnpm-lock.yaml +5678 -0
  74. package/frontend/pnpm-workspace.yaml +6 -0
  75. package/frontend/postcss.config.mjs +6 -0
  76. package/frontend/public/logo.png +0 -0
  77. package/frontend/src/app/[locale]/(app)/admin/page.tsx +1426 -0
  78. package/frontend/src/app/[locale]/(app)/catalog/products/[id]/page.tsx +505 -0
  79. package/frontend/src/app/[locale]/(app)/catalog/products/page.tsx +753 -0
  80. package/frontend/src/app/[locale]/(app)/customers/[id]/page.tsx +500 -0
  81. package/frontend/src/app/[locale]/(app)/customers/page.tsx +538 -0
  82. package/frontend/src/app/[locale]/(app)/dashboard/page.tsx +175 -0
  83. package/frontend/src/app/[locale]/(app)/inventory/page.tsx +765 -0
  84. package/frontend/src/app/[locale]/(app)/layout.tsx +60 -0
  85. package/frontend/src/app/[locale]/(app)/purchasing/page.tsx +1 -0
  86. package/frontend/src/app/[locale]/(app)/reports/page.tsx +794 -0
  87. package/frontend/src/app/[locale]/(app)/sales/page.tsx +296 -0
  88. package/frontend/src/app/[locale]/(auth)/login/page.tsx +100 -0
  89. package/frontend/src/app/[locale]/(pos)/layout.tsx +33 -0
  90. package/frontend/src/app/[locale]/(pos)/pos/page.tsx +463 -0
  91. package/frontend/src/app/[locale]/(pos)/pos/pos-data.ts +89 -0
  92. package/frontend/src/app/[locale]/layout.tsx +30 -0
  93. package/frontend/src/app/[locale]/page.tsx +6 -0
  94. package/frontend/src/app/globals.css +77 -0
  95. package/frontend/src/app/layout.tsx +23 -0
  96. package/frontend/src/app/print.css +18 -0
  97. package/frontend/src/components/pos/CartPanel.tsx +525 -0
  98. package/frontend/src/components/pos/CashSessionGuard.tsx +255 -0
  99. package/frontend/src/components/pos/CloseSessionModal.tsx +160 -0
  100. package/frontend/src/components/pos/PaymentModal.tsx +266 -0
  101. package/frontend/src/components/pos/PaymentSettingsModal.tsx +472 -0
  102. package/frontend/src/components/pos/ProductCatalog.tsx +140 -0
  103. package/frontend/src/components/pos/SaleReceipt.tsx +133 -0
  104. package/frontend/src/components/ui/badge.tsx +28 -0
  105. package/frontend/src/components/ui/bulk-import-modal.tsx +400 -0
  106. package/frontend/src/components/ui/button.tsx +60 -0
  107. package/frontend/src/components/ui/card.tsx +13 -0
  108. package/frontend/src/components/ui/confirm-dialog.tsx +50 -0
  109. package/frontend/src/components/ui/input.tsx +32 -0
  110. package/frontend/src/components/ui/notification-center.tsx +228 -0
  111. package/frontend/src/components/ui/select.tsx +59 -0
  112. package/frontend/src/components/ui/sidebar.tsx +91 -0
  113. package/frontend/src/components/ui/slide-over.tsx +43 -0
  114. package/frontend/src/components/ui/stat-card.tsx +40 -0
  115. package/frontend/src/components/ui/switch.tsx +38 -0
  116. package/frontend/src/components/ui/topbar.tsx +97 -0
  117. package/frontend/src/i18n/request.ts +13 -0
  118. package/frontend/src/i18n/routing.ts +9 -0
  119. package/frontend/src/lib/api.ts +240 -0
  120. package/frontend/src/lib/constants.ts +30 -0
  121. package/frontend/src/lib/utils.ts +32 -0
  122. package/frontend/src/messages/ar.json +106 -0
  123. package/frontend/src/messages/en.json +106 -0
  124. package/frontend/src/middleware.ts +8 -0
  125. package/frontend/src/store/app-store.ts +37 -0
  126. package/frontend/src/store/auth-store.ts +52 -0
  127. package/frontend/src/store/payment-methods-store.ts +144 -0
  128. package/frontend/src/store/pos-store.ts +344 -0
  129. package/frontend/tailwind.config.ts +85 -0
  130. package/frontend/tsconfig.json +27 -0
  131. package/img/backgraund-logo.png +0 -0
  132. package/img/full-logo.png +0 -0
  133. package/img/logo.png +0 -0
  134. package/nginx/nginx.conf +67 -0
  135. package/package.json +88 -0
  136. package/start.bat +90 -0
  137. package/start.sh +64 -0
@@ -0,0 +1,463 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useRef, useCallback } from 'react';
4
+ import { usePosStore } from '@/store/pos-store';
5
+ import { useAppStore } from '@/store/app-store';
6
+ import { formatMoney } from '@/lib/utils';
7
+ import { api } from '@/lib/api';
8
+ import CartPanel from '@/components/pos/CartPanel';
9
+ import ProductCatalog from '@/components/pos/ProductCatalog';
10
+ import PaymentModal from '@/components/pos/PaymentModal';
11
+ import PaymentSettingsModal from '@/components/pos/PaymentSettingsModal';
12
+ import { CashSessionGuard } from '@/components/pos/CashSessionGuard';
13
+ import { CloseSessionModal } from '@/components/pos/CloseSessionModal';
14
+ import { SaleReceipt } from '@/components/pos/SaleReceipt';
15
+ import type { Product } from '@/components/pos/ProductCatalog';
16
+ import {
17
+ Menu,
18
+ Search,
19
+ ScanBarcode,
20
+ ChevronDown,
21
+ User,
22
+ X,
23
+ Store,
24
+ Sun,
25
+ Moon,
26
+ LogOut,
27
+ Calculator,
28
+ } from 'lucide-react';
29
+ import { useRouter } from '@/i18n/routing';
30
+ import toast from 'react-hot-toast';
31
+
32
+ export default function PosPage() {
33
+ return (
34
+ <CashSessionGuard>
35
+ {(session) => <PosContent session={session} />}
36
+ </CashSessionGuard>
37
+ );
38
+ }
39
+
40
+ function PosContent({ session }: { session: { id: string; opening_amount: number; opened_at: string } }) {
41
+ const [search, setSearch] = useState('');
42
+ const [paymentMode, setPaymentMode] = useState(false);
43
+ const [settingsOpen, setSettingsOpen] = useState(false);
44
+ const [showDropdown, setShowDropdown] = useState(false);
45
+ const [closeSessionOpen, setCloseSessionOpen] = useState(false);
46
+ const [userMenuOpen, setUserMenuOpen] = useState(false);
47
+ const [mobileTab, setMobileTab] = useState<'cart' | 'products'>('products');
48
+
49
+ // Receipt State
50
+ const [receiptOpen, setReceiptOpen] = useState(false);
51
+ const [saleReceiptData, setSaleReceiptData] = useState<any>(null);
52
+
53
+ const searchInputRef = useRef<HTMLInputElement>(null);
54
+ const dropdownRef = useRef<HTMLDivElement>(null);
55
+ const userMenuRef = useRef<HTMLDivElement>(null);
56
+ const router = useRouter();
57
+ const { currency, theme, setTheme } = useAppStore();
58
+
59
+ const [products, setProducts] = useState<Product[]>([]);
60
+ const [categories, setCategories] = useState<string[]>(['All']);
61
+ const [isLoading, setIsLoading] = useState(true);
62
+
63
+ const fetchPosData = useCallback(async () => {
64
+ try {
65
+ setIsLoading(true);
66
+ const [medicines, stockItems, categoriesTree]: [any, any, any] = await Promise.all([
67
+ api.getMedicines().catch(() => []),
68
+ api.getStock().catch(() => []),
69
+ api.getCategoryTree().catch(() => [])
70
+ ]);
71
+
72
+ const catMap = new Map<string, string>();
73
+ const addCats = (nodes: any[]) => {
74
+ nodes.forEach((node: any) => {
75
+ catMap.set(node.id, node.name_en);
76
+ if (node.children) addCats(node.children);
77
+ });
78
+ };
79
+ if (Array.isArray(categoriesTree)) {
80
+ addCats(categoriesTree);
81
+ }
82
+
83
+ const stockMap = new Map<string, number>();
84
+ if (Array.isArray(stockItems)) {
85
+ stockItems.forEach((item: any) => {
86
+ if (item?.medicine?.id) {
87
+ stockMap.set(item.medicine.id, item.total_quantity);
88
+ }
89
+ });
90
+ }
91
+
92
+ const medList = Array.isArray(medicines) ? medicines : [];
93
+ const realProducts: Product[] = medList.map((med: any) => ({
94
+ id: med.id,
95
+ medicine_id: med.id,
96
+ name: med.name_en || med.name || 'Unknown',
97
+ category: med.category_id ? (catMap.get(med.category_id) || 'Other') : 'Other',
98
+ price: Number(med.selling_price) || 0,
99
+ stock: stockMap.get(med.id) || 0,
100
+ batch: '-',
101
+ lot_number: '-',
102
+ expiry: '-',
103
+ requires_prescription: med.requires_prescription || false,
104
+ favorite: false
105
+ }));
106
+
107
+ const catSet = new Set<string>(['All']);
108
+ realProducts.forEach(p => catSet.add(p.category));
109
+
110
+ setProducts(realProducts);
111
+ setCategories(Array.from(catSet));
112
+ } catch (error) {
113
+ console.error('Failed to fetch POS data', error);
114
+ setProducts([]);
115
+ setCategories(['All']);
116
+ } finally {
117
+ setIsLoading(false);
118
+ }
119
+ }, []);
120
+
121
+ useEffect(() => {
122
+ fetchPosData();
123
+ }, [fetchPosData]);
124
+
125
+ // Store
126
+ const addItem = usePosStore((s) => s.addItem);
127
+ const cart = usePosStore((s) => s.cart);
128
+ const total = usePosStore((s) => s.total);
129
+
130
+ // Search filtering
131
+ const searchResults =
132
+ search.length > 1
133
+ ? products.filter((p) => p.name.toLowerCase().includes(search.toLowerCase()))
134
+ : [];
135
+
136
+ // Product click handler
137
+ const handleProductClick = useCallback(
138
+ (product: Product) => {
139
+ if (product.stock <= 0) {
140
+ toast.error(`${product.name} is out of stock`);
141
+ return;
142
+ }
143
+ addItem({
144
+ medicine_id: product.medicine_id,
145
+ name: product.name,
146
+ batch: product.batch,
147
+ lot_number: product.lot_number,
148
+ quantity: 1,
149
+ unit_price: product.price,
150
+ });
151
+ setSearch('');
152
+ setShowDropdown(false);
153
+ },
154
+ [addItem]
155
+ );
156
+
157
+ // Barcode Scanner Listener (Detect hardware scanners fast-typing + enter)
158
+ useEffect(() => {
159
+ let barcodeBuffer = '';
160
+ let lastKeyTime = Date.now();
161
+
162
+ const handleKeyDown = (e: KeyboardEvent) => {
163
+ // Global keyboard shortcuts
164
+ if (e.key === 'F2') {
165
+ e.preventDefault();
166
+ searchInputRef.current?.focus();
167
+ return;
168
+ }
169
+ if (e.key === 'F9' && cart.length > 0) {
170
+ e.preventDefault();
171
+ setPaymentMode(true);
172
+ return;
173
+ }
174
+ if (e.key === 'Escape') {
175
+ if (paymentMode) setPaymentMode(false);
176
+ if (showDropdown) setShowDropdown(false);
177
+ 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
+ }
228
+ };
229
+
230
+ window.addEventListener('keydown', handleKeyDown);
231
+ return () => window.removeEventListener('keydown', handleKeyDown);
232
+ }, [cart.length, paymentMode, showDropdown, userMenuOpen, products, handleProductClick]);
233
+
234
+ // Click outside to close dropdowns
235
+ useEffect(() => {
236
+ const handleClickOutside = (e: MouseEvent) => {
237
+ if (
238
+ dropdownRef.current &&
239
+ !dropdownRef.current.contains(e.target as Node) &&
240
+ searchInputRef.current &&
241
+ !searchInputRef.current.contains(e.target as Node)
242
+ ) {
243
+ setShowDropdown(false);
244
+ }
245
+ if (
246
+ userMenuRef.current &&
247
+ !userMenuRef.current.contains(e.target as Node)
248
+ ) {
249
+ setUserMenuOpen(false);
250
+ }
251
+ };
252
+ document.addEventListener('mousedown', handleClickOutside);
253
+ return () => document.removeEventListener('mousedown', handleClickOutside);
254
+ }, []);
255
+
256
+ return (
257
+ <div className="flex flex-col h-full bg-background overflow-hidden">
258
+ {/* ===== HEADER ===== */}
259
+ <header className="h-[60px] bg-background border-b border-border flex items-center justify-between px-2 md:px-4 z-10 shrink-0 gap-2">
260
+ <div className="flex items-center gap-1 md:gap-3 shrink-0">
261
+ <button className="p-1.5 md:p-2 text-foreground hover:bg-surface rounded-lg transition-colors md:hidden">
262
+ <Menu size={20} />
263
+ </button>
264
+ <button
265
+ onClick={() => router.push('/dashboard')}
266
+ className="hidden sm:flex items-center gap-1.5 text-sm font-medium text-foreground/70 hover:text-foreground border border-border hover:bg-surface px-3 py-1.5 rounded-md transition-colors"
267
+ >
268
+ Exit POS <span className="text-foreground/40 font-serif">→</span>
269
+ </button>
270
+ {session?.id && (
271
+ <div className="hidden lg:flex items-center gap-2 px-2.5 py-1 bg-emerald-500/10 border border-emerald-500/20 text-emerald-600 dark:text-emerald-400 text-xs font-medium rounded-full">
272
+ <span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
273
+ <span>Register Open</span>
274
+ </div>
275
+ )}
276
+ </div>
277
+
278
+ <div className="hidden md:flex items-center gap-2 text-pos-500 dark:text-pos-300 font-bold text-lg lg:text-xl shrink-0">
279
+ <Store size={24} className="text-pos-500 dark:text-pos-300" />
280
+ <span>Pharmacy POS</span>
281
+ </div>
282
+
283
+ <div className="flex items-center gap-1.5 md:gap-3 flex-1 md:flex-none justify-end">
284
+ <div className="relative flex-1 max-w-[400px] md:w-[260px]">
285
+ <Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-foreground/40" />
286
+ <input
287
+ ref={searchInputRef}
288
+ type="text"
289
+ value={search}
290
+ onChange={(e) => {
291
+ setSearch(e.target.value);
292
+ setShowDropdown(true);
293
+ }}
294
+ onFocus={() => setShowDropdown(true)}
295
+ placeholder="Search / Scan barcode... (F2)"
296
+ className="w-full bg-surface border border-border text-foreground rounded-lg py-2 pl-9 pr-3 text-sm focus:outline-none focus:border-pos-400 focus:ring-1 focus:ring-pos-200 placeholder:text-foreground/40 transition-shadow"
297
+ />
298
+
299
+ {showDropdown && searchResults.length > 0 && (
300
+ <div
301
+ ref={dropdownRef}
302
+ className="absolute top-full left-0 right-0 mt-1 bg-background border border-border rounded-lg shadow-[0_10px_25px_rgba(0,0,0,0.15)] dark:shadow-[0_10px_25px_rgba(0,0,0,0.4)] max-h-[320px] overflow-y-auto z-50"
303
+ >
304
+ {searchResults.map((p) => (
305
+ <div
306
+ key={p.id}
307
+ onClick={() => handleProductClick(p)}
308
+ className="p-3 border-b border-border hover:bg-surface cursor-pointer flex justify-between items-center last:border-b-0"
309
+ >
310
+ <div className="flex flex-col">
311
+ <span className="font-medium text-sm text-foreground">{p.name}</span>
312
+ <span className="text-xs text-foreground/50">
313
+ Stock: {p.stock} | {p.stock === 0 ? 'Out of stock' : 'In stock'}
314
+ </span>
315
+ </div>
316
+ <span className="font-bold text-sm text-pos-600">
317
+ {formatMoney(p.price, currency)}
318
+ </span>
319
+ </div>
320
+ ))}
321
+ </div>
322
+ )}
323
+ </div>
324
+
325
+ <button
326
+ onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
327
+ className="p-1.5 md:p-2 bg-transparent border border-border rounded-lg text-foreground hover:bg-surface transition-colors shrink-0"
328
+ title={theme === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode'}
329
+ >
330
+ {theme === 'dark' ? <Sun size={18} className="md:w-5 md:h-5" /> : <Moon size={18} className="md:w-5 md:h-5" />}
331
+ </button>
332
+
333
+ <button
334
+ onClick={() => searchInputRef.current?.focus()}
335
+ 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)"
337
+ >
338
+ <ScanBarcode size={18} className="md:w-5 md:h-5" />
339
+ </button>
340
+
341
+ {/* User Menu & Close Register */}
342
+ <div className="relative shrink-0" ref={userMenuRef}>
343
+ <button
344
+ onClick={() => setUserMenuOpen(!userMenuOpen)}
345
+ className="flex items-center gap-1 md:gap-2 hover:bg-surface rounded-lg p-1 md:pr-2 transition-colors border border-transparent hover:border-border"
346
+ >
347
+ <div className="w-7 h-7 md:w-8 md:h-8 rounded-full bg-pos-500 text-white flex items-center justify-center font-bold text-xs md:text-sm">
348
+ A
349
+ </div>
350
+ <span className="text-sm font-medium text-foreground/80 hidden lg:block">Administrator</span>
351
+ <ChevronDown size={14} className="text-foreground/40 md:w-4 md:h-4" />
352
+ </button>
353
+
354
+ {userMenuOpen && (
355
+ <div className="absolute right-0 mt-2 w-48 bg-surface border border-border rounded-xl shadow-xl py-1.5 z-50">
356
+ {session?.id ? (
357
+ <>
358
+ <div className="px-3 py-2 border-b border-border text-xs text-foreground/50">
359
+ Opening Float: <span className="font-semibold text-foreground">{formatMoney(session.opening_amount, currency)}</span>
360
+ </div>
361
+ <button
362
+ onClick={() => {
363
+ setUserMenuOpen(false);
364
+ setCloseSessionOpen(true);
365
+ }}
366
+ className="w-full px-3 py-2 text-left text-sm text-foreground hover:bg-background flex items-center gap-2 text-orange-500 font-medium transition-colors"
367
+ >
368
+ <Calculator size={16} />
369
+ Close Register
370
+ </button>
371
+ </>
372
+ ) : null}
373
+ <button
374
+ onClick={() => {
375
+ setUserMenuOpen(false);
376
+ router.push('/dashboard');
377
+ }}
378
+ className="w-full px-3 py-2 text-left text-sm text-foreground hover:bg-background flex items-center gap-2 transition-colors"
379
+ >
380
+ <LogOut size={16} />
381
+ Exit to Dashboard
382
+ </button>
383
+ </div>
384
+ )}
385
+ </div>
386
+ </div>
387
+ </header>
388
+
389
+ {/* ===== MAIN CONTENT ===== */}
390
+ <div className="flex flex-col md:flex-row flex-1 overflow-hidden h-full">
391
+
392
+ {/* Mobile Tabs */}
393
+ <div className="md:hidden flex border-b border-border bg-background shrink-0">
394
+ <button
395
+ onClick={() => setMobileTab('products')}
396
+ className={`flex-1 py-3 text-sm font-semibold transition-colors ${mobileTab === 'products' ? 'text-pos-600 border-b-2 border-pos-600' : 'text-foreground/60'}`}
397
+ >
398
+ Products
399
+ </button>
400
+ <button
401
+ onClick={() => setMobileTab('cart')}
402
+ className={`flex-1 py-3 text-sm font-semibold transition-colors ${mobileTab === 'cart' ? 'text-pos-600 border-b-2 border-pos-600' : 'text-foreground/60'}`}
403
+ >
404
+ Cart ({cart.length})
405
+ </button>
406
+ </div>
407
+
408
+ {/* Left: Cart Panel */}
409
+ <div className={`w-full md:w-5/12 lg:w-[35%] h-full flex-col ${mobileTab === 'cart' ? 'flex' : 'hidden md:flex'}`}>
410
+ <CartPanel onPayment={() => setPaymentMode(true)} products={products} />
411
+ </div>
412
+
413
+ {/* Right: Product Catalog */}
414
+ <div className={`w-full md:w-7/12 lg:w-[65%] h-full flex-col ${mobileTab === 'products' ? 'flex' : 'hidden md:flex'}`}>
415
+ <ProductCatalog
416
+ products={products}
417
+ categories={categories}
418
+ onProductClick={handleProductClick}
419
+ isLoading={isLoading}
420
+ />
421
+ </div>
422
+ </div>
423
+
424
+ {/* ===== PAYMENT MODAL ===== */}
425
+ <PaymentModal
426
+ open={paymentMode}
427
+ onClose={() => setPaymentMode(false)}
428
+ onSettingsOpen={() => { setPaymentMode(false); setSettingsOpen(true); }}
429
+ onSaleComplete={fetchPosData}
430
+ cashSessionId={session.id}
431
+ onSaleSuccess={(receiptData) => {
432
+ setSaleReceiptData(receiptData);
433
+ setReceiptOpen(true);
434
+ }}
435
+ />
436
+
437
+ {/* ===== PAYMENT SETTINGS MODAL ===== */}
438
+ <PaymentSettingsModal
439
+ open={settingsOpen}
440
+ onClose={() => setSettingsOpen(false)}
441
+ />
442
+
443
+ {/* ===== SALE RECEIPT MODAL ===== */}
444
+ <SaleReceipt
445
+ open={receiptOpen}
446
+ onClose={() => setReceiptOpen(false)}
447
+ saleData={saleReceiptData}
448
+ />
449
+
450
+ {/* ===== CLOSE REGISTER MODAL ===== */}
451
+ <CloseSessionModal
452
+ open={closeSessionOpen}
453
+ onClose={() => setCloseSessionOpen(false)}
454
+ sessionId={session.id}
455
+ openingAmount={session.opening_amount}
456
+ onSessionClosed={() => {
457
+ setCloseSessionOpen(false);
458
+ window.location.reload();
459
+ }}
460
+ />
461
+ </div>
462
+ );
463
+ }
@@ -0,0 +1,89 @@
1
+ export interface Product {
2
+ id: string;
3
+ medicine_id: string;
4
+ name: string;
5
+ category: string;
6
+ price: number;
7
+ stock: number;
8
+ batch: string;
9
+ lot_number: string;
10
+ expiry: string;
11
+ requires_prescription: boolean;
12
+ favorite: boolean;
13
+ }
14
+
15
+ export const categories = [
16
+ 'All', 'Medicines', 'Antibiotics', 'Pain Relief', 'Vitamins',
17
+ 'Respiratory', 'Cardiovascular', 'Diabetes', 'Skin Care', 'Other'
18
+ ];
19
+
20
+ export const mockProducts: Product[] = [
21
+ // Medicines
22
+ { id: 'p1', medicine_id: 'm1', name: 'Metformin 500mg + Glimepiride 2mg', category: 'Medicines', price: 450, stock: 120, batch: 'B-001', lot_number: '000001', expiry: '12/2027', requires_prescription: true, favorite: true },
23
+ { id: 'p2', medicine_id: 'm2', name: 'Perindopril 5mg + Indapamide 1.25mg', category: 'Medicines', price: 550, stock: 85, batch: 'B-002', lot_number: '000002', expiry: '06/2028', requires_prescription: true, favorite: false },
24
+ { id: 'p3', medicine_id: 'm3', name: 'Atorvastatin 10mg', category: 'Medicines', price: 300, stock: 200, batch: 'B-003', lot_number: '000003', expiry: '01/2026', requires_prescription: true, favorite: true },
25
+ { id: 'p4', medicine_id: 'm4', name: 'Atorvastatin 20mg', category: 'Medicines', price: 400, stock: 150, batch: 'B-003', lot_number: '000004', expiry: '01/2026', requires_prescription: true, favorite: false },
26
+
27
+ // Antibiotics
28
+ { id: 'p5', medicine_id: 'm5', name: 'Amoxicillin 500mg', category: 'Antibiotics', price: 250, stock: 300, batch: 'B-004', lot_number: 'Lot-9854', expiry: '11/2025', requires_prescription: true, favorite: true },
29
+ { id: 'p6', medicine_id: 'm6', name: 'Azithromycin 250mg', category: 'Antibiotics', price: 350, stock: 50, batch: 'B-005', lot_number: 'Lot-9855', expiry: '03/2026', requires_prescription: true, favorite: false },
30
+ { id: 'p7', medicine_id: 'm7', name: 'Ciprofloxacin 500mg', category: 'Antibiotics', price: 400, stock: 90, batch: 'B-006', lot_number: 'Lot-9856', expiry: '08/2027', requires_prescription: true, favorite: false },
31
+
32
+ // Pain Relief
33
+ { id: 'p8', medicine_id: 'm8', name: 'Acetylsalicylic Acid 75mg', category: 'Pain Relief', price: 150, stock: 400, batch: 'B-007', lot_number: '000005', expiry: '05/2028', requires_prescription: false, favorite: true },
34
+ { id: 'p9', medicine_id: 'm9', name: 'Acetylsalicylic Acid 80mg Gastro-Resistant', category: 'Pain Relief', price: 180, stock: 250, batch: 'B-007', lot_number: '000006', expiry: '05/2028', requires_prescription: false, favorite: false },
35
+ { id: 'p10', medicine_id: 'm10', name: 'Acetylsalicylic Acid 160mg Gastro-Resistant', category: 'Pain Relief', price: 220, stock: 180, batch: 'B-008', lot_number: '000007', expiry: '09/2026', requires_prescription: false, favorite: false },
36
+ { id: 'p11', medicine_id: 'm11', name: 'Paracetamol 500mg', category: 'Pain Relief', price: 50, stock: 500, batch: 'B-009', lot_number: '000008', expiry: '12/2028', requires_prescription: false, favorite: true },
37
+ { id: 'p12', medicine_id: 'm12', name: 'Ibuprofen 400mg', category: 'Pain Relief', price: 120, stock: 350, batch: 'B-010', lot_number: '000009', expiry: '07/2027', requires_prescription: false, favorite: true },
38
+
39
+ // Vitamins
40
+ { id: 'p13', medicine_id: 'm13', name: 'Ascorbic Acid 500mg', category: 'Vitamins', price: 100, stock: 450, batch: 'B-011', lot_number: '000010', expiry: '10/2026', requires_prescription: false, favorite: true },
41
+ { id: 'p14', medicine_id: 'm14', name: 'B Vitamins Formula', category: 'Vitamins', price: 250, stock: 120, batch: 'B-012', lot_number: '000011', expiry: '04/2027', requires_prescription: false, favorite: false },
42
+ { id: 'p15', medicine_id: 'm15', name: 'Vitamin D3 1000 IU', category: 'Vitamins', price: 180, stock: 200, batch: 'B-013', lot_number: '000012', expiry: '02/2028', requires_prescription: false, favorite: true },
43
+ { id: 'p16', medicine_id: 'm16', name: 'Multivitamin Supplements', category: 'Vitamins', price: 300, stock: 80, batch: 'B-014', lot_number: '000013', expiry: '11/2025', requires_prescription: false, favorite: false },
44
+
45
+ // Respiratory
46
+ { id: 'p17', medicine_id: 'm17', name: 'Aminosidine 125mg/5ml', category: 'Respiratory', price: 280, stock: 60, batch: 'B-015', lot_number: '000014', expiry: '06/2026', requires_prescription: true, favorite: false },
47
+ { id: 'p18', medicine_id: 'm18', name: 'Aminosidine 250mg', category: 'Respiratory', price: 420, stock: 45, batch: 'B-016', lot_number: '000015', expiry: '06/2026', requires_prescription: true, favorite: false },
48
+ { id: 'p19', medicine_id: 'm19', name: 'Salbutamol Inhaler 100mcg', category: 'Respiratory', price: 150, stock: 150, batch: 'B-017', lot_number: '000016', expiry: '01/2027', requires_prescription: true, favorite: true },
49
+
50
+ // Cardiovascular
51
+ { id: 'p20', medicine_id: 'm20', name: 'Atenolol 25mg', category: 'Cardiovascular', price: 200, stock: 110, batch: 'B-018', lot_number: '000017', expiry: '03/2028', requires_prescription: true, favorite: false },
52
+ { id: 'p21', medicine_id: 'm21', name: 'Atenolol 50mg', category: 'Cardiovascular', price: 250, stock: 130, batch: 'B-018', lot_number: '000018', expiry: '03/2028', requires_prescription: true, favorite: true },
53
+ { id: 'p22', medicine_id: 'm22', name: 'Atenolol 100mg', category: 'Cardiovascular', price: 300, stock: 90, batch: 'B-019', lot_number: '000019', expiry: '05/2027', requires_prescription: true, favorite: false },
54
+ { id: 'p23', medicine_id: 'm23', name: 'Amlodipine 5mg', category: 'Cardiovascular', price: 180, stock: 220, batch: 'B-020', lot_number: '000020', expiry: '11/2026', requires_prescription: true, favorite: true },
55
+ { id: 'p24', medicine_id: 'm24', name: 'Amlodipine 10mg', category: 'Cardiovascular', price: 220, stock: 190, batch: 'B-020', lot_number: '000021', expiry: '11/2026', requires_prescription: true, favorite: false },
56
+ { id: 'p25', medicine_id: 'm25', name: 'Amlodipine 5mg+Losartan 50mg', category: 'Cardiovascular', price: 400, stock: 150, batch: 'B-021', lot_number: '000022', expiry: '08/2027', requires_prescription: true, favorite: true },
57
+ { id: 'p26', medicine_id: 'm26', name: 'Amlodipine 2.5mg+Hydrochlorothiazide 12.5mg', category: 'Cardiovascular', price: 350, stock: 75, batch: 'B-022', lot_number: '000023', expiry: '10/2025', requires_prescription: true, favorite: false },
58
+
59
+ // Diabetes
60
+ { id: 'p27', medicine_id: 'm27', name: 'Metformin 850mg', category: 'Diabetes', price: 200, stock: 300, batch: 'B-023', lot_number: '000024', expiry: '09/2028', requires_prescription: true, favorite: true },
61
+ { id: 'p28', medicine_id: 'm28', name: 'Glimepiride 4mg', category: 'Diabetes', price: 180, stock: 140, batch: 'B-024', lot_number: '000025', expiry: '02/2027', requires_prescription: true, favorite: false },
62
+
63
+ // Skin Care
64
+ { id: 'p29', medicine_id: 'm29', name: 'Benzoyl Peroxide 5%', category: 'Skin Care', price: 450, stock: 65, batch: 'B-025', lot_number: '000026', expiry: '07/2026', requires_prescription: false, favorite: true },
65
+ { id: 'p30', medicine_id: 'm30', name: 'Benzoyl Peroxide 10%', category: 'Skin Care', price: 550, stock: 40, batch: 'B-026', lot_number: '000027', expiry: '07/2026', requires_prescription: false, favorite: false },
66
+ { id: 'p31', medicine_id: 'm31', name: 'Azelaic Acid 20%', category: 'Skin Care', price: 600, stock: 80, batch: 'B-027', lot_number: '000028', expiry: '12/2025', requires_prescription: false, favorite: true },
67
+ { id: 'p32', medicine_id: 'm32', name: 'Acitretin 25mg', category: 'Skin Care', price: 900, stock: 25, batch: 'B-028', lot_number: '000029', expiry: '04/2028', requires_prescription: true, favorite: false },
68
+
69
+ // Other
70
+ { id: 'p33', medicine_id: 'm33', name: 'Activated Charcoal 125mg tablet', category: 'Other', price: 150, stock: 120, batch: 'B-029', lot_number: '000030', expiry: '01/2029', requires_prescription: false, favorite: false },
71
+ { id: 'p34', medicine_id: 'm34', name: 'Antihemorrhoids Preparations', category: 'Other', price: 320, stock: 85, batch: 'B-030', lot_number: '000031', expiry: '05/2026', requires_prescription: false, favorite: true },
72
+ { id: 'p35', medicine_id: 'm35', name: 'Adarone Chlorhydrate 200mg', category: 'Other', price: 480, stock: 30, batch: 'B-031', lot_number: '000032', expiry: '08/2027', requires_prescription: true, favorite: false },
73
+ { id: 'p36', medicine_id: 'm36', name: 'Adapalene 0.1%', category: 'Other', price: 750, stock: 55, batch: 'B-032', lot_number: '000033', expiry: '11/2026', requires_prescription: true, favorite: true },
74
+ ];
75
+
76
+ export interface Customer {
77
+ id: string;
78
+ name: string;
79
+ phone: string;
80
+ balance: number;
81
+ }
82
+
83
+ export const mockCustomers: Customer[] = [
84
+ { id: 'c1', name: 'Ahmed Ali', phone: '0912 345 678', balance: 0 },
85
+ { id: 'c2', name: 'Mohammed Hassan', phone: '0999 123 456', balance: 250 },
86
+ { id: 'c3', name: 'Fatima Omar', phone: '0911 222 333', balance: 1500 },
87
+ { id: 'c4', name: 'Sara Ibrahim', phone: '0922 444 555', balance: 0 },
88
+ { id: 'c5', name: 'Khalid Youssef', phone: '0933 666 777', balance: 800 },
89
+ ];
@@ -0,0 +1,30 @@
1
+ import { NextIntlClientProvider } from 'next-intl';
2
+ import { getMessages } from 'next-intl/server';
3
+ import { notFound } from 'next/navigation';
4
+ import { routing } from '@/i18n/routing';
5
+ import { Toaster } from 'react-hot-toast';
6
+
7
+ export default async function LocaleLayout({
8
+ children,
9
+ params
10
+ }: {
11
+ children: React.ReactNode;
12
+ params: Promise<{ locale: string }>;
13
+ }) {
14
+ const { locale } = await params;
15
+ if (!routing.locales.includes(locale as any)) {
16
+ notFound();
17
+ }
18
+
19
+ const messages = await getMessages();
20
+ const dir = locale === 'ar' ? 'rtl' : 'ltr';
21
+
22
+ return (
23
+ <div dir={dir} className="min-h-screen bg-background">
24
+ <NextIntlClientProvider messages={messages} locale={locale}>
25
+ {children}
26
+ <Toaster position="top-center" />
27
+ </NextIntlClientProvider>
28
+ </div>
29
+ );
30
+ }
@@ -0,0 +1,6 @@
1
+ import { redirect } from '@/i18n/routing';
2
+
3
+ export default function RootPage() {
4
+ // Simple redirect to dashboard, in real app check auth state
5
+ redirect({ href: '/dashboard', locale: 'en' });
6
+ }
@@ -0,0 +1,77 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ @layer base {
6
+ :root {
7
+ --background: #FFFFFF;
8
+ --foreground: #111827;
9
+ --surface: #F9FAFB;
10
+ --border: #E5E7EB;
11
+
12
+ --brand-primary: #2A50D8;
13
+ --brand-hover: #213EAF;
14
+ --brand-active: #172C85;
15
+
16
+ --success: #12B76A;
17
+ --warning: #F79009;
18
+ --danger: #F04438;
19
+ --info: #3B82F6;
20
+
21
+ --radius: 0.5rem;
22
+ }
23
+
24
+ .dark {
25
+ --background: #0B1220;
26
+ --foreground: #F9FAFB;
27
+ --surface: #121926;
28
+ --border: #202939;
29
+
30
+ --brand-primary: #587CFC;
31
+ --brand-hover: #829DFD;
32
+ --brand-active: #ABBDFE;
33
+
34
+ --success: #12B76A;
35
+ --warning: #F79009;
36
+ --danger: #F04438;
37
+ --info: #3B82F6;
38
+ }
39
+ }
40
+
41
+ @layer base {
42
+ * {
43
+ @apply border-[var(--border)];
44
+ }
45
+ body {
46
+ @apply bg-[var(--background)] text-[var(--foreground)] antialiased;
47
+ }
48
+ }
49
+
50
+ /* Scrollbar Styling */
51
+ ::-webkit-scrollbar {
52
+ width: 8px;
53
+ height: 8px;
54
+ }
55
+ ::-webkit-scrollbar-track {
56
+ @apply bg-transparent;
57
+ }
58
+ ::-webkit-scrollbar-thumb {
59
+ @apply bg-gray-300 dark:bg-gray-700 rounded-full;
60
+ }
61
+ ::-webkit-scrollbar-thumb:hover {
62
+ @apply bg-gray-400 dark:bg-gray-600;
63
+ }
64
+
65
+ /* RTL Utilities */
66
+ [dir="rtl"] {
67
+ font-family: 'Inter', system-ui, sans-serif; /* Fallback for Arabic if not using specific font */
68
+ }
69
+
70
+ /* Hide scrollbar utility for POS category bar */
71
+ .no-scrollbar::-webkit-scrollbar {
72
+ display: none;
73
+ }
74
+ .no-scrollbar {
75
+ -ms-overflow-style: none;
76
+ scrollbar-width: none;
77
+ }