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,765 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useEffect, useMemo } from 'react';
4
+ import { api } from '@/lib/api';
5
+ import { SlideOver } from '@/components/ui/slide-over';
6
+ import { Select } from '@/components/ui/select';
7
+ import { Badge } from '@/components/ui/badge';
8
+ import { toast } from 'react-hot-toast';
9
+ import { useAppStore } from '@/store/app-store';
10
+ import {
11
+ Search, Plus, Settings2, Clock, Package, AlertTriangle,
12
+ TrendingDown, DollarSign, ArrowUpCircle, ArrowDownCircle,
13
+ Hash, Calendar, Truck, X
14
+ } from 'lucide-react';
15
+ import { cn, formatCompactNumber } from '@/lib/utils';
16
+ import { useRouter, useParams } from 'next/navigation';
17
+
18
+ // ─── Types ───────────────────────────────────────────────────────────────
19
+ interface StockItem {
20
+ medicine: any;
21
+ total_quantity: number;
22
+ }
23
+
24
+ // ─── Main Component ─────────────────────────────────────────────────────
25
+ export default function InventoryPage() {
26
+ const router = useRouter();
27
+ const params = useParams();
28
+ const locale = params.locale as string;
29
+ const { currency } = useAppStore();
30
+
31
+ // ── Data State ──
32
+ const [stockItems, setStockItems] = useState<StockItem[]>([]);
33
+ const [medicines, setMedicines] = useState<any[]>([]);
34
+ const [lowStockItems, setLowStockItems] = useState<any[]>([]);
35
+ const [expiringBatches, setExpiringBatches] = useState<any[]>([]);
36
+ const [expiredBatches, setExpiredBatches] = useState<any[]>([]);
37
+ const [movements, setMovements] = useState<any[]>([]);
38
+ const [suppliers, setSuppliers] = useState<any[]>([]);
39
+ const [loading, setLoading] = useState(true);
40
+
41
+ // ── Filter State ──
42
+ const [search, setSearch] = useState('');
43
+ const [stockFilter, setStockFilter] = useState<'all' | 'in_stock' | 'low' | 'out' | 'expiring'>('all');
44
+
45
+ // ── Panel State ──
46
+ const [addStockOpen, setAddStockOpen] = useState(false);
47
+ const [adjustOpen, setAdjustOpen] = useState(false);
48
+ const [historyOpen, setHistoryOpen] = useState(false);
49
+ const [isSaving, setIsSaving] = useState(false);
50
+
51
+ // ── Add Stock Form ──
52
+ const [addForm, setAddForm] = useState({
53
+ medicine_id: '',
54
+ batch_number: '',
55
+ quantity: '',
56
+ purchase_price: '',
57
+ production_date: '',
58
+ expiry_date: '',
59
+ supplier_id: '',
60
+ });
61
+
62
+ // ── Adjust Stock Form ──
63
+ const [adjustForm, setAdjustForm] = useState({
64
+ medicine_id: '',
65
+ batch_id: '',
66
+ adjustment_type: 'IN',
67
+ quantity: '',
68
+ reason: '',
69
+ });
70
+ const [adjustBatches, setAdjustBatches] = useState<any[]>([]);
71
+
72
+ // ── Fetch All Data ──
73
+ useEffect(() => {
74
+ fetchData();
75
+ }, []);
76
+
77
+ const fetchData = async () => {
78
+ try {
79
+ setLoading(true);
80
+ const [stockRes, medsRes, lowRes, expiringRes, expiredRes, movRes, supRes] = await Promise.all([
81
+ api.getStock().catch(() => []),
82
+ api.getMedicines().catch(() => []),
83
+ api.getLowStock().catch(() => []),
84
+ api.getExpiring().catch(() => []),
85
+ api.getExpired().catch(() => []),
86
+ api.getMovements().catch(() => []),
87
+ api.getSuppliers().catch(() => []),
88
+ ]);
89
+ setStockItems(stockRes as StockItem[]);
90
+ setMedicines(medsRes as any[]);
91
+ setLowStockItems(lowRes as any[]);
92
+ setExpiringBatches(expiringRes as any[]);
93
+ setExpiredBatches(expiredRes as any[]);
94
+ setMovements(movRes as any[]);
95
+ setSuppliers(supRes as any[]);
96
+ } catch (error) {
97
+ toast.error('Failed to load inventory data');
98
+ } finally {
99
+ setLoading(false);
100
+ }
101
+ };
102
+
103
+ // ── Computed Stats ──
104
+ const stats = useMemo(() => {
105
+ const totalProducts = stockItems.length;
106
+ const lowStock = stockItems.filter(item => {
107
+ const qty = Number(item.total_quantity);
108
+ return qty > 0 && qty <= (item.medicine?.reorder_level || 0);
109
+ }).length;
110
+ const outOfStock = stockItems.filter(item => Number(item.total_quantity) === 0).length;
111
+ const expiringSoon = expiringBatches.length;
112
+ const totalValue = stockItems.reduce((sum, item) => {
113
+ return sum + (Number(item.total_quantity) * Number(item.medicine?.selling_price || 0));
114
+ }, 0);
115
+ return { totalProducts, lowStock, outOfStock, expiringSoon, totalValue };
116
+ }, [stockItems, expiringBatches]);
117
+
118
+ const totalCurrentStock = useMemo(() => {
119
+ return stockItems.reduce((sum, item) => sum + Number(item.total_quantity || 0), 0);
120
+ }, [stockItems]);
121
+
122
+ // ── Medicine Name Lookup ──
123
+ const getMedicineName = (id: string) => {
124
+ const med = medicines.find(m => m.id === id);
125
+ return med?.name_en || 'Unknown';
126
+ };
127
+
128
+ // ── Nearest Expiry Lookup (from expiring batches) ──
129
+ const expiryMap = useMemo(() => {
130
+ const map: Record<string, string> = {};
131
+ for (const batch of [...expiringBatches, ...expiredBatches]) {
132
+ const medId = batch.medicine_id;
133
+ if (!map[medId] || new Date(batch.expiry_date) < new Date(map[medId])) {
134
+ map[medId] = batch.expiry_date;
135
+ }
136
+ }
137
+ return map;
138
+ }, [expiringBatches, expiredBatches]);
139
+
140
+ // ── Expiry Helpers ──
141
+ const getDaysUntilExpiry = (dateStr: string) => {
142
+ const diff = new Date(dateStr).getTime() - Date.now();
143
+ return Math.ceil(diff / (1000 * 60 * 60 * 24));
144
+ };
145
+
146
+ const getExpiryColor = (days: number) => {
147
+ if (days <= 0) return 'text-red-400';
148
+ if (days <= 30) return 'text-red-400';
149
+ if (days <= 60) return 'text-amber-400';
150
+ return 'text-yellow-400';
151
+ };
152
+
153
+ // ── Filtered Stock Items ──
154
+ const filteredItems = useMemo(() => {
155
+ return stockItems.filter(item => {
156
+ const med = item.medicine;
157
+ const qty = Number(item.total_quantity);
158
+ const reorder = med?.reorder_level || 0;
159
+
160
+ const matchSearch = search === '' ||
161
+ med?.name_en?.toLowerCase().includes(search.toLowerCase()) ||
162
+ med?.sku?.toLowerCase().includes(search.toLowerCase()) ||
163
+ med?.barcode?.toLowerCase().includes(search.toLowerCase());
164
+
165
+ const matchFilter =
166
+ stockFilter === 'all' ||
167
+ (stockFilter === 'in_stock' && qty > reorder) ||
168
+ (stockFilter === 'low' && qty > 0 && qty <= reorder) ||
169
+ (stockFilter === 'out' && qty === 0) ||
170
+ (stockFilter === 'expiring' && expiryMap[med?.id] && getDaysUntilExpiry(expiryMap[med?.id]) <= 90);
171
+
172
+ return matchSearch && matchFilter;
173
+ });
174
+ }, [stockItems, search, stockFilter]);
175
+
176
+ // ── Stock Status Helper ──
177
+ const getStockStatus = (qty: number, reorderLevel: number) => {
178
+ if (qty === 0) return { label: 'Out of Stock', variant: 'danger' as const, color: 'text-red-400' };
179
+ if (qty <= reorderLevel) return { label: 'Low Stock', variant: 'warning' as const, color: 'text-amber-400' };
180
+ return { label: 'In Stock', variant: 'success' as const, color: 'text-green-400' };
181
+ };
182
+
183
+
184
+ // ── Add Stock Handler ──
185
+ const handleAddStock = async () => {
186
+ if (!addForm.medicine_id || !addForm.quantity || !addForm.purchase_price || !addForm.expiry_date) {
187
+ toast.error('Please fill all required fields');
188
+ return;
189
+ }
190
+ try {
191
+ setIsSaving(true);
192
+ await api.createBatch(addForm.medicine_id, {
193
+ batch_number: addForm.batch_number || `BATCH-${Date.now().toString().slice(-6)}`,
194
+ quantity_received: Number(addForm.quantity),
195
+ purchase_price: Number(addForm.purchase_price),
196
+ production_date: addForm.production_date || null,
197
+ expiry_date: addForm.expiry_date,
198
+ supplier_id: addForm.supplier_id || null,
199
+ });
200
+ toast.success('Stock added successfully');
201
+ setAddStockOpen(false);
202
+ setAddForm({ medicine_id: '', batch_number: '', quantity: '', purchase_price: '', production_date: '', expiry_date: '', supplier_id: '' });
203
+ fetchData();
204
+ } catch (error: any) {
205
+ toast.error(error.message || 'Failed to add stock');
206
+ } finally {
207
+ setIsSaving(false);
208
+ }
209
+ };
210
+
211
+ // ── Adjust Stock Handler ──
212
+ const handleAdjustStock = async () => {
213
+ if (!adjustForm.medicine_id || !adjustForm.batch_id || !adjustForm.quantity || !adjustForm.reason) {
214
+ toast.error('Please fill all required fields');
215
+ return;
216
+ }
217
+ try {
218
+ setIsSaving(true);
219
+ await api.createAdjustment({
220
+ medicine_id: adjustForm.medicine_id,
221
+ batch_id: adjustForm.batch_id,
222
+ quantity: Number(adjustForm.quantity),
223
+ reason: adjustForm.reason,
224
+ adjustment_type: adjustForm.adjustment_type,
225
+ });
226
+ toast.success('Stock adjusted successfully');
227
+ setAdjustOpen(false);
228
+ setAdjustForm({ medicine_id: '', batch_id: '', adjustment_type: 'IN', quantity: '', reason: '' });
229
+ setAdjustBatches([]);
230
+ fetchData();
231
+ } catch (error: any) {
232
+ toast.error(error.message || 'Failed to adjust stock');
233
+ } finally {
234
+ setIsSaving(false);
235
+ }
236
+ };
237
+
238
+ // ── Fetch Batches for Adjust Form ──
239
+ const onAdjustMedicineChange = async (medicineId: string) => {
240
+ setAdjustForm(prev => ({ ...prev, medicine_id: medicineId, batch_id: '' }));
241
+ if (medicineId) {
242
+ try {
243
+ const batches = await api.getMedicineStock(medicineId) as any[];
244
+ setAdjustBatches(batches);
245
+ } catch {
246
+ setAdjustBatches([]);
247
+ }
248
+ } else {
249
+ setAdjustBatches([]);
250
+ }
251
+ };
252
+
253
+ // ── Stock Progress Bar ──
254
+ const StockBar = ({ qty, reorder }: { qty: number; reorder: number }) => {
255
+ const max = Math.max(reorder * 2, qty, 1);
256
+ const percent = Math.min((qty / max) * 100, 100);
257
+ const color = qty === 0 ? 'bg-red-500' : qty <= reorder ? 'bg-amber-500' : 'bg-green-500';
258
+ return (
259
+ <div className="flex items-center gap-3">
260
+ <span className={cn("font-semibold text-sm tabular-nums w-8 text-right", qty === 0 ? 'text-red-400' : qty <= reorder ? 'text-amber-400' : 'text-foreground')}>
261
+ {qty}
262
+ </span>
263
+ <div className="flex-1 h-2 bg-border rounded-full overflow-hidden min-w-[60px]">
264
+ <div className={cn("h-full rounded-full transition-all duration-500", color)} style={{ width: `${percent}%` }} />
265
+ </div>
266
+ </div>
267
+ );
268
+ };
269
+
270
+ // ═══════════════════════════════════════════════════════════════════════
271
+ // RENDER
272
+ // ═══════════════════════════════════════════════════════════════════════
273
+ return (
274
+ <div className="flex flex-col gap-6">
275
+
276
+ {/* ── Header ─────────────────────────────────────────────────────── */}
277
+ <header className="flex-none rounded-xl border border-border bg-surface p-6 shadow-sm">
278
+ <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
279
+ <div>
280
+ <h1 className="text-2xl font-bold tracking-tight text-foreground">Inventory</h1>
281
+ <p className="text-sm text-muted-foreground mt-1">
282
+ Current Stock: <span className="text-foreground font-semibold text-base">{totalCurrentStock.toLocaleString()}</span> units
283
+ </p>
284
+ </div>
285
+ <div className="flex items-center gap-2">
286
+ <button
287
+ onClick={() => {
288
+ setAddForm({ medicine_id: '', batch_number: `BATCH-${Date.now().toString().slice(-6)}`, quantity: '', purchase_price: '', production_date: '', expiry_date: '', supplier_id: '' });
289
+ setAddStockOpen(true);
290
+ }}
291
+ className="inline-flex h-10 items-center justify-center rounded-lg bg-brand-500 px-4 text-sm font-medium text-white transition-colors hover:bg-brand-600 gap-2 shadow-lg shadow-brand-500/20"
292
+ >
293
+ <Plus className="h-4 w-4" />
294
+ Add Stock
295
+ </button>
296
+ <button
297
+ onClick={() => {
298
+ setAdjustForm({ medicine_id: '', batch_id: '', adjustment_type: 'IN', quantity: '', reason: '' });
299
+ setAdjustBatches([]);
300
+ setAdjustOpen(true);
301
+ }}
302
+ className="inline-flex h-10 items-center justify-center rounded-lg border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-surface gap-2"
303
+ >
304
+ <Settings2 className="h-4 w-4" />
305
+ Adjust Stock
306
+ </button>
307
+ <button
308
+ onClick={() => setHistoryOpen(true)}
309
+ className="inline-flex h-10 items-center justify-center rounded-lg border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-surface gap-2"
310
+ >
311
+ <Clock className="h-4 w-4" />
312
+ View History
313
+ </button>
314
+ </div>
315
+ </div>
316
+ </header>
317
+
318
+ {/* ── Stat Cards ─────────────────────────────────────────────────── */}
319
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
320
+ <button onClick={() => setStockFilter('all')} className="rounded-xl border border-border bg-surface p-5 shadow-sm text-left hover:border-brand-500/50 transition-colors focus:outline-none focus:ring-2 focus:ring-brand-500/50">
321
+ <div className="flex items-center gap-3">
322
+ <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-brand-500/10">
323
+ <Package className="h-5 w-5 text-brand-500" />
324
+ </div>
325
+ <div>
326
+ <p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Total Products</p>
327
+ <p className="text-2xl font-bold text-foreground">{stats.totalProducts}</p>
328
+ </div>
329
+ </div>
330
+ </button>
331
+
332
+ <button onClick={() => setStockFilter('out')} className="rounded-xl border border-border bg-surface p-5 shadow-sm text-left hover:border-red-500/50 transition-colors focus:outline-none focus:ring-2 focus:ring-red-500/50">
333
+ <div className="flex items-center gap-3">
334
+ <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-red-500/10">
335
+ <Package className="h-5 w-5 text-red-500" />
336
+ </div>
337
+ <div>
338
+ <p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Out of Stock</p>
339
+ <p className="text-2xl font-bold text-red-500">{stats.outOfStock}</p>
340
+ </div>
341
+ </div>
342
+ </button>
343
+
344
+ <button onClick={() => setStockFilter('low')} className="rounded-xl border border-border bg-surface p-5 shadow-sm text-left hover:border-amber-500/50 transition-colors focus:outline-none focus:ring-2 focus:ring-amber-500/50">
345
+ <div className="flex items-center gap-3">
346
+ <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-500/10">
347
+ <TrendingDown className="h-5 w-5 text-amber-500" />
348
+ </div>
349
+ <div>
350
+ <p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Low Stock</p>
351
+ <p className="text-2xl font-bold text-amber-500">{stats.lowStock}</p>
352
+ </div>
353
+ </div>
354
+ </button>
355
+
356
+ <button onClick={() => setStockFilter('expiring')} className="rounded-xl border border-border bg-surface p-5 shadow-sm text-left hover:border-red-500/50 transition-colors focus:outline-none focus:ring-2 focus:ring-red-500/50">
357
+ <div className="flex items-center gap-3">
358
+ <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-red-500/10">
359
+ <AlertTriangle className="h-5 w-5 text-red-500" />
360
+ </div>
361
+ <div>
362
+ <p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Expiring Soon</p>
363
+ <p className="text-2xl font-bold text-red-500">{stats.expiringSoon}</p>
364
+ </div>
365
+ </div>
366
+ </button>
367
+
368
+ <div className="rounded-xl border border-border bg-surface p-5 shadow-sm text-left">
369
+ <div className="flex items-center gap-3">
370
+ <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-500/10">
371
+ <DollarSign className="h-5 w-5 text-green-500" />
372
+ </div>
373
+ <div>
374
+ <p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Stock Value</p>
375
+ <p className="text-2xl font-bold text-foreground">{formatCompactNumber(stats.totalValue).toLowerCase()} <span className="text-sm font-normal text-muted-foreground">{currency}</span></p>
376
+ </div>
377
+ </div>
378
+ </div>
379
+ </div>
380
+
381
+ {/* ── Search & Filters ───────────────────────────────────────────── */}
382
+ <div className="rounded-xl border border-border bg-surface p-4 shadow-sm">
383
+ <div className="flex flex-col gap-4 sm:flex-row sm:items-center">
384
+ <div className="relative flex-1 w-full sm:max-w-md">
385
+ <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
386
+ <input
387
+ type="text"
388
+ placeholder="Search by name, SKU, or barcode..."
389
+ value={search}
390
+ onChange={(e) => setSearch(e.target.value)}
391
+ className="h-10 w-full rounded-lg border border-border bg-background pl-10 pr-4 text-sm text-foreground placeholder:text-muted-foreground focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
392
+ />
393
+ </div>
394
+ <div className="flex items-center rounded-lg border border-border bg-background p-1 overflow-x-auto w-full sm:w-auto">
395
+ {(['all', 'in_stock', 'low', 'out', 'expiring'] as const).map(filter => {
396
+ const labels: Record<string, string> = { all: 'All', in_stock: 'In Stock', low: 'Low', out: 'Out', expiring: 'Expiring' };
397
+ const isActive = stockFilter === filter;
398
+ return (
399
+ <button
400
+ key={filter}
401
+ onClick={() => setStockFilter(filter)}
402
+ className={cn(
403
+ "px-3 py-1.5 text-xs font-medium rounded-md transition-colors whitespace-nowrap",
404
+ isActive
405
+ ? filter === 'low' ? 'bg-amber-500/20 text-amber-400'
406
+ : filter === 'out' ? 'bg-red-500/20 text-red-400'
407
+ : filter === 'expiring' ? 'bg-orange-500/20 text-orange-400'
408
+ : filter === 'in_stock' ? 'bg-green-500/20 text-green-400'
409
+ : 'bg-brand-500/20 text-brand-400'
410
+ : 'text-muted-foreground hover:text-foreground'
411
+ )}
412
+ >
413
+ {labels[filter]}
414
+ </button>
415
+ );
416
+ })}
417
+ </div>
418
+ </div>
419
+ </div>
420
+
421
+ {/* ── Stock Table ─────────────────────────────────────────────────── */}
422
+ <main className="rounded-xl border border-border bg-surface overflow-hidden shadow-sm">
423
+ <div className="overflow-x-auto">
424
+ <table className="w-full text-left text-sm text-foreground">
425
+ <thead className="bg-background text-xs uppercase text-muted-foreground border-b border-border">
426
+ <tr>
427
+ <th className="px-6 py-4 font-semibold">Product Name</th>
428
+ <th className="px-6 py-4 font-semibold hidden md:table-cell">SKU</th>
429
+ <th className="px-6 py-4 font-semibold w-48">Stock Level</th>
430
+ <th className="px-6 py-4 font-semibold hidden lg:table-cell">Nearest Expiry</th>
431
+ <th className="px-6 py-4 font-semibold">Status</th>
432
+ </tr>
433
+ </thead>
434
+ <tbody className="divide-y divide-border">
435
+ {loading ? (
436
+ [...Array(6)].map((_, i) => (
437
+ <tr key={i} className="animate-pulse">
438
+ <td className="px-6 py-4"><div className="h-4 bg-border rounded w-3/4" /></td>
439
+ <td className="px-6 py-4 hidden md:table-cell"><div className="h-4 bg-border rounded w-1/2" /></td>
440
+ <td className="px-6 py-4"><div className="h-4 bg-border rounded w-full" /></td>
441
+ <td className="px-6 py-4 hidden lg:table-cell"><div className="h-4 bg-border rounded w-2/3" /></td>
442
+ <td className="px-6 py-4"><div className="h-4 bg-border rounded w-1/3" /></td>
443
+ </tr>
444
+ ))
445
+ ) : filteredItems.length === 0 ? (
446
+ <tr>
447
+ <td colSpan={5} className="px-6 py-16 text-center">
448
+ <div className="flex flex-col items-center justify-center">
449
+ <Package className="h-12 w-12 text-muted-foreground/50 mb-4" />
450
+ <h3 className="text-lg font-medium text-foreground">No items found</h3>
451
+ <p className="text-sm text-muted-foreground mt-1">Try adjusting your search or filters.</p>
452
+ </div>
453
+ </td>
454
+ </tr>
455
+ ) : (
456
+ filteredItems.map((item) => {
457
+ const med = item.medicine;
458
+ const qty = Number(item.total_quantity);
459
+ const reorder = 10;
460
+ const status = getStockStatus(qty, reorder);
461
+ const nearestExpiry = expiryMap[med?.id];
462
+ const daysLeft = nearestExpiry ? getDaysUntilExpiry(nearestExpiry) : null;
463
+
464
+ return (
465
+ <tr
466
+ key={med?.id}
467
+ onClick={() => router.push(`/${locale}/catalog/products/${med?.id}`)}
468
+ className="transition-colors hover:bg-background/50 group cursor-pointer"
469
+ >
470
+ <td className="px-6 py-4">
471
+ <div className="flex flex-col">
472
+ <span className="font-medium text-foreground">{med?.name_en}</span>
473
+ {med?.strength && <span className="text-xs text-brand-500 mt-0.5">{med.strength}</span>}
474
+ </div>
475
+ </td>
476
+ <td className="px-6 py-4 hidden md:table-cell">
477
+ <span className="font-mono text-xs text-muted-foreground">{med?.sku}</span>
478
+ </td>
479
+ <td className="px-6 py-4">
480
+ <StockBar qty={qty} reorder={reorder} />
481
+ </td>
482
+ <td className="px-6 py-4 hidden lg:table-cell">
483
+ {nearestExpiry ? (
484
+ <div className="flex flex-col">
485
+ <span className={cn("text-sm font-medium", getExpiryColor(daysLeft || 0))}>
486
+ {new Date(nearestExpiry).toLocaleDateString('en-GB', { month: 'short', year: 'numeric' })}
487
+ </span>
488
+ <span className={cn("text-xs", getExpiryColor(daysLeft || 0))}>
489
+ {daysLeft !== null && daysLeft <= 0 ? 'Expired' : `${daysLeft}d left`}
490
+ </span>
491
+ </div>
492
+ ) : (
493
+ <span className="text-sm text-muted-foreground">—</span>
494
+ )}
495
+ </td>
496
+ <td className="px-6 py-4">
497
+ <Badge variant={status.variant}>{status.label}</Badge>
498
+ </td>
499
+ </tr>
500
+ );
501
+ })
502
+ )}
503
+ </tbody>
504
+ </table>
505
+ </div>
506
+ </main>
507
+
508
+ {/* ═══════════════════════════════════════════════════════════════════
509
+ SLIDE-OVER: Add Stock
510
+ ═══════════════════════════════════════════════════════════════════ */}
511
+ <SlideOver
512
+ open={addStockOpen}
513
+ onClose={() => setAddStockOpen(false)}
514
+ title="Add Stock"
515
+ footer={
516
+ <div className="flex justify-end gap-3">
517
+ <button onClick={() => setAddStockOpen(false)} className="px-4 py-2 text-sm font-medium text-foreground hover:text-foreground transition-colors">
518
+ Cancel
519
+ </button>
520
+ <button
521
+ onClick={handleAddStock}
522
+ disabled={isSaving}
523
+ className="inline-flex h-10 items-center justify-center rounded-lg bg-brand-500 px-4 text-sm font-medium text-white transition-colors hover:bg-brand-600 disabled:opacity-50"
524
+ >
525
+ {isSaving ? 'Adding...' : 'Add Stock'}
526
+ </button>
527
+ </div>
528
+ }
529
+ >
530
+ <div className="space-y-6 pb-6">
531
+ {/* Medicine Select */}
532
+ <div className="space-y-1.5">
533
+ <label className="text-sm font-medium text-muted-foreground">Medicine *</label>
534
+ <Select
535
+ value={addForm.medicine_id}
536
+ onChange={v => setAddForm({ ...addForm, medicine_id: v })}
537
+ options={medicines.map(m => ({ value: m.id, label: `${m.name_en}${m.strength ? ` (${m.strength})` : ''}` }))}
538
+ placeholder="Select medicine..."
539
+ />
540
+ </div>
541
+
542
+ <div className="grid grid-cols-2 gap-4">
543
+ <div className="space-y-1.5">
544
+ <label className="text-sm font-medium text-muted-foreground">Quantity *</label>
545
+ <input
546
+ type="number" min="1"
547
+ value={addForm.quantity}
548
+ onChange={e => setAddForm({ ...addForm, quantity: e.target.value })}
549
+ className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
550
+ placeholder="100"
551
+ />
552
+ </div>
553
+ <div className="space-y-1.5">
554
+ <label className="text-sm font-medium text-muted-foreground">Purchase Price ({currency}) *</label>
555
+ <input
556
+ type="number" step="0.01" min="0"
557
+ value={addForm.purchase_price}
558
+ onChange={e => setAddForm({ ...addForm, purchase_price: e.target.value })}
559
+ className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
560
+ placeholder="8.50"
561
+ />
562
+ </div>
563
+ </div>
564
+
565
+ <div className="space-y-1.5">
566
+ <label className="text-sm font-medium text-muted-foreground">Batch Number</label>
567
+ <input
568
+ type="text"
569
+ value={addForm.batch_number}
570
+ onChange={e => setAddForm({ ...addForm, batch_number: e.target.value })}
571
+ 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"
572
+ />
573
+ </div>
574
+
575
+ <div className="space-y-1.5">
576
+ <label className="text-sm font-medium text-muted-foreground">Supplier</label>
577
+ <Select
578
+ value={addForm.supplier_id}
579
+ onChange={v => setAddForm({ ...addForm, supplier_id: v })}
580
+ options={suppliers.map(s => ({ value: s.id, label: s.name }))}
581
+ placeholder="Select supplier..."
582
+ />
583
+ </div>
584
+
585
+ <div className="grid grid-cols-2 gap-4">
586
+ <div className="space-y-1.5">
587
+ <label className="text-sm font-medium text-muted-foreground">Production Date</label>
588
+ <input
589
+ type="date"
590
+ value={addForm.production_date}
591
+ onChange={e => setAddForm({ ...addForm, production_date: e.target.value })}
592
+ className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
593
+ />
594
+ </div>
595
+ <div className="space-y-1.5">
596
+ <label className="text-sm font-medium text-muted-foreground">Expiry Date *</label>
597
+ <input
598
+ type="date"
599
+ value={addForm.expiry_date}
600
+ onChange={e => setAddForm({ ...addForm, expiry_date: e.target.value })}
601
+ className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
602
+ />
603
+ </div>
604
+ </div>
605
+ </div>
606
+ </SlideOver>
607
+
608
+ {/* ═══════════════════════════════════════════════════════════════════
609
+ SLIDE-OVER: Adjust Stock
610
+ ═══════════════════════════════════════════════════════════════════ */}
611
+ <SlideOver
612
+ open={adjustOpen}
613
+ onClose={() => setAdjustOpen(false)}
614
+ title="Adjust Stock"
615
+ footer={
616
+ <div className="flex justify-end gap-3">
617
+ <button onClick={() => setAdjustOpen(false)} className="px-4 py-2 text-sm font-medium text-foreground hover:text-foreground transition-colors">
618
+ Cancel
619
+ </button>
620
+ <button
621
+ onClick={handleAdjustStock}
622
+ disabled={isSaving}
623
+ className="inline-flex h-10 items-center justify-center rounded-lg bg-brand-500 px-4 text-sm font-medium text-white transition-colors hover:bg-brand-600 disabled:opacity-50"
624
+ >
625
+ {isSaving ? 'Adjusting...' : 'Confirm Adjustment'}
626
+ </button>
627
+ </div>
628
+ }
629
+ >
630
+ <div className="space-y-6 pb-6">
631
+ {/* Medicine Select */}
632
+ <div className="space-y-1.5">
633
+ <label className="text-sm font-medium text-muted-foreground">Medicine *</label>
634
+ <Select
635
+ value={adjustForm.medicine_id}
636
+ onChange={onAdjustMedicineChange}
637
+ options={medicines.map(m => ({ value: m.id, label: `${m.name_en}${m.strength ? ` (${m.strength})` : ''}` }))}
638
+ placeholder="Select medicine..."
639
+ />
640
+ </div>
641
+
642
+ {/* Batch Select (loads after medicine is picked) */}
643
+ {adjustForm.medicine_id && (
644
+ <div className="space-y-1.5">
645
+ <label className="text-sm font-medium text-muted-foreground">Batch *</label>
646
+ {adjustBatches.length === 0 ? (
647
+ <p className="text-sm text-muted-foreground italic py-2">No batches with remaining stock found for this medicine.</p>
648
+ ) : (
649
+ <Select
650
+ value={adjustForm.batch_id}
651
+ onChange={v => setAdjustForm({ ...adjustForm, batch_id: v })}
652
+ options={adjustBatches.map(b => ({
653
+ value: b.id,
654
+ label: `${b.batch_number} — Qty: ${Number(b.quantity_remaining)} — Exp: ${new Date(b.expiry_date).toLocaleDateString('en-GB', { month: 'short', year: 'numeric' })}`
655
+ }))}
656
+ placeholder="Select batch..."
657
+ />
658
+ )}
659
+ </div>
660
+ )}
661
+
662
+ {/* Adjustment Type */}
663
+ <div className="space-y-1.5">
664
+ <label className="text-sm font-medium text-muted-foreground">Adjustment Type *</label>
665
+ <div className="grid grid-cols-2 gap-3">
666
+ <button
667
+ type="button"
668
+ onClick={() => setAdjustForm({ ...adjustForm, adjustment_type: 'IN' })}
669
+ className={cn(
670
+ "flex items-center justify-center gap-2 rounded-lg border-2 px-4 py-3 text-sm font-medium transition-all",
671
+ adjustForm.adjustment_type === 'IN'
672
+ ? 'border-green-500 bg-green-500/10 text-green-400'
673
+ : 'border-border bg-background text-muted-foreground hover:border-green-500/50'
674
+ )}
675
+ >
676
+ <ArrowUpCircle className="h-5 w-5" />
677
+ Add Stock
678
+ </button>
679
+ <button
680
+ type="button"
681
+ onClick={() => setAdjustForm({ ...adjustForm, adjustment_type: 'OUT' })}
682
+ className={cn(
683
+ "flex items-center justify-center gap-2 rounded-lg border-2 px-4 py-3 text-sm font-medium transition-all",
684
+ adjustForm.adjustment_type === 'OUT'
685
+ ? 'border-red-500 bg-red-500/10 text-red-400'
686
+ : 'border-border bg-background text-muted-foreground hover:border-red-500/50'
687
+ )}
688
+ >
689
+ <ArrowDownCircle className="h-5 w-5" />
690
+ Remove Stock
691
+ </button>
692
+ </div>
693
+ </div>
694
+
695
+ <div className="space-y-1.5">
696
+ <label className="text-sm font-medium text-muted-foreground">Quantity *</label>
697
+ <input
698
+ type="number" min="1"
699
+ value={adjustForm.quantity}
700
+ onChange={e => setAdjustForm({ ...adjustForm, quantity: e.target.value })}
701
+ className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
702
+ placeholder="Enter quantity"
703
+ />
704
+ </div>
705
+
706
+ <div className="space-y-1.5">
707
+ <label className="text-sm font-medium text-muted-foreground">Reason *</label>
708
+ <textarea
709
+ rows={3}
710
+ value={adjustForm.reason}
711
+ onChange={e => setAdjustForm({ ...adjustForm, reason: e.target.value })}
712
+ className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500 resize-none"
713
+ placeholder="e.g. Damaged goods, stocktake correction, returned items..."
714
+ />
715
+ </div>
716
+ </div>
717
+ </SlideOver>
718
+
719
+ {/* ═══════════════════════════════════════════════════════════════════
720
+ SLIDE-OVER: View History
721
+ ═══════════════════════════════════════════════════════════════════ */}
722
+ <SlideOver
723
+ open={historyOpen}
724
+ onClose={() => setHistoryOpen(false)}
725
+ title="Inventory History"
726
+ >
727
+ <div className="space-y-3 pb-6">
728
+ {movements.length === 0 ? (
729
+ <div className="flex flex-col items-center justify-center py-12">
730
+ <Clock className="h-12 w-12 text-muted-foreground/50 mb-4" />
731
+ <h3 className="text-lg font-medium text-foreground">No history yet</h3>
732
+ <p className="text-sm text-muted-foreground mt-1">Stock movements will appear here.</p>
733
+ </div>
734
+ ) : (
735
+ movements.map((mov, i) => {
736
+ const isIn = mov.movement_type === 'IN' || mov.movement_type === 'ADD' || mov.movement_type === 'SALE_RETURN';
737
+ return (
738
+ <div key={mov.id || i} className="flex items-start gap-3 rounded-lg border border-border bg-background p-4">
739
+ <div className={cn("flex h-9 w-9 shrink-0 items-center justify-center rounded-full", isIn ? 'bg-green-500/10' : 'bg-red-500/10')}>
740
+ {isIn ? <ArrowUpCircle className="h-5 w-5 text-green-500" /> : <ArrowDownCircle className="h-5 w-5 text-red-500" />}
741
+ </div>
742
+ <div className="flex-1 min-w-0">
743
+ <div className="flex items-center justify-between">
744
+ <span className="font-medium text-sm text-foreground">{getMedicineName(mov.medicine_id)}</span>
745
+ <span className={cn("font-semibold text-sm tabular-nums", isIn ? 'text-green-400' : 'text-red-400')}>
746
+ {isIn ? '+' : '-'}{Number(mov.quantity)}
747
+ </span>
748
+ </div>
749
+ <div className="flex items-center gap-2 mt-1">
750
+ <Badge variant={isIn ? 'success' : 'danger'}>{mov.movement_type}</Badge>
751
+ {mov.notes && <span className="text-xs text-muted-foreground truncate">{mov.notes}</span>}
752
+ </div>
753
+ <p className="text-xs text-muted-foreground mt-1.5">
754
+ {new Date(mov.created_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
755
+ </p>
756
+ </div>
757
+ </div>
758
+ );
759
+ })
760
+ )}
761
+ </div>
762
+ </SlideOver>
763
+ </div>
764
+ );
765
+ }