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,505 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useEffect, useMemo, use } from 'react';
4
+ import { useRouter } from 'next/navigation';
5
+ import { useAppStore } from '@/store/app-store';
6
+ import { ArrowLeft, Plus, Edit, Ban, Package, Activity, DollarSign, Calendar, Archive } from 'lucide-react';
7
+ import { api } from '@/lib/api';
8
+ import { Badge } from '@/components/ui/badge';
9
+ import { SlideOver } from '@/components/ui/slide-over';
10
+ import { Select } from '@/components/ui/select';
11
+ import { ConfirmDialog } from '@/components/ui/confirm-dialog';
12
+ import { toast } from 'react-hot-toast';
13
+ import { cn } from '@/lib/utils';
14
+ import { format, differenceInDays } from 'date-fns';
15
+
16
+ export default function MedicineDetailPage({ params }: { params: Promise<{ id: string }> }) {
17
+ const router = useRouter();
18
+ const { id } = use(params);
19
+
20
+ const [medicine, setMedicine] = useState<any>(null);
21
+ const [categories, setCategories] = useState<any[]>([]);
22
+ const [suppliers, setSuppliers] = useState<any[]>([]);
23
+ const [loading, setLoading] = useState(true);
24
+ const { currency } = useAppStore();
25
+
26
+ // SlideOver State
27
+ const [slideOpen, setSlideOpen] = useState(false);
28
+ const [editingBatch, setEditingBatch] = useState<any>(null);
29
+ const [isSaving, setIsSaving] = useState(false);
30
+
31
+ const [deactivateDialog, setDeactivateDialog] = useState({ open: false, batchId: '', batchNumber: '' });
32
+
33
+ const defaultBatchForm = {
34
+ batch_number: '',
35
+ quantity: '',
36
+ purchase_price: '',
37
+ production_date: '',
38
+ expiry_date: '',
39
+ supplier_id: '',
40
+ notes: '',
41
+ status: 'ACTIVE'
42
+ };
43
+ const [batchForm, setBatchForm] = useState(defaultBatchForm);
44
+
45
+ useEffect(() => {
46
+ fetchData();
47
+ }, [id]);
48
+
49
+ const fetchData = async () => {
50
+ try {
51
+ setLoading(true);
52
+ const [medRes, catsRes, suppsRes] = await Promise.all([
53
+ api.getMedicine(id),
54
+ api.getCategoryTree().catch(() => []),
55
+ api.getSuppliers().catch(() => [])
56
+ ]);
57
+ setMedicine(medRes);
58
+ setCategories(catsRes as any[]);
59
+ setSuppliers(suppsRes as any[]);
60
+ } catch (error: any) {
61
+ toast.error(error.message || 'Failed to load medicine details');
62
+ } finally {
63
+ setLoading(false);
64
+ }
65
+ };
66
+
67
+ const getCategoryName = (catId: string) => {
68
+ for (const c1 of categories) {
69
+ if (c1.id === catId) return c1.name_en;
70
+ if (c1.children) {
71
+ for (const c2 of c1.children) {
72
+ if (c2.id === catId) return c2.name_en;
73
+ }
74
+ }
75
+ }
76
+ return 'Uncategorized';
77
+ };
78
+
79
+ const openAddBatch = () => {
80
+ setEditingBatch(null);
81
+ setBatchForm({
82
+ ...defaultBatchForm,
83
+ batch_number: `BATCH-${Date.now().toString().slice(-6)}`
84
+ });
85
+ setSlideOpen(true);
86
+ };
87
+
88
+ const openEditBatch = (batch: any) => {
89
+ setEditingBatch(batch);
90
+ setBatchForm({
91
+ batch_number: batch.batch_number || '',
92
+ quantity: batch.quantity?.toString() || '',
93
+ purchase_price: batch.purchase_price?.toString() || '',
94
+ production_date: batch.production_date ? batch.production_date.split('T')[0] : '',
95
+ expiry_date: batch.expiry_date ? batch.expiry_date.split('T')[0] : '',
96
+ supplier_id: batch.supplier_id || '',
97
+ notes: batch.notes || '',
98
+ status: batch.status || 'ACTIVE'
99
+ });
100
+ setSlideOpen(true);
101
+ };
102
+
103
+ const handleSaveBatch = async () => {
104
+ try {
105
+ setIsSaving(true);
106
+ if (editingBatch) {
107
+ // Edit mode
108
+ const payload = {
109
+ purchase_price: Number(batchForm.purchase_price),
110
+ production_date: batchForm.production_date || null,
111
+ expiry_date: batchForm.expiry_date,
112
+ status: batchForm.status,
113
+ notes: batchForm.notes,
114
+ supplier_id: batchForm.supplier_id || null,
115
+ };
116
+ await api.updateBatch(id, editingBatch.id, payload);
117
+ toast.success('Batch updated successfully');
118
+ } else {
119
+ // Add mode
120
+ if (!batchForm.quantity || !batchForm.purchase_price || !batchForm.expiry_date) {
121
+ toast.error('Please fill required fields');
122
+ setIsSaving(false);
123
+ return;
124
+ }
125
+ const payload = {
126
+ batch_number: batchForm.batch_number,
127
+ quantity: Number(batchForm.quantity),
128
+ purchase_price: Number(batchForm.purchase_price),
129
+ production_date: batchForm.production_date || null,
130
+ expiry_date: batchForm.expiry_date,
131
+ supplier_id: batchForm.supplier_id || null,
132
+ notes: batchForm.notes
133
+ };
134
+ await api.createBatch(id, payload);
135
+ toast.success('Batch added successfully');
136
+ }
137
+ setSlideOpen(false);
138
+ fetchData();
139
+ } catch (error: any) {
140
+ toast.error(error.message || 'Failed to save batch');
141
+ } finally {
142
+ setIsSaving(false);
143
+ }
144
+ };
145
+
146
+ const handleDeactivateBatch = async () => {
147
+ try {
148
+ setIsSaving(true);
149
+ await api.deactivateBatch(id, deactivateDialog.batchId);
150
+ toast.success('Batch deactivated successfully');
151
+ setDeactivateDialog({ open: false, batchId: '', batchNumber: '' });
152
+ fetchData();
153
+ } catch (error: any) {
154
+ toast.error(error.message || 'Failed to deactivate batch');
155
+ } finally {
156
+ setIsSaving(false);
157
+ }
158
+ };
159
+
160
+ if (loading) {
161
+ return <div className="p-8 text-center text-muted-foreground animate-pulse">Loading medicine details...</div>;
162
+ }
163
+
164
+ if (!medicine) {
165
+ return <div className="p-8 text-center text-muted-foreground">Medicine not found.</div>;
166
+ }
167
+
168
+ const activeBatches = medicine.batches?.filter((b: any) => b.status === 'ACTIVE' || b.status === 'NEAR_EXPIRY') || [];
169
+ const totalStock = activeBatches.reduce((acc: number, b: any) => acc + (b.quantity || 0), 0);
170
+
171
+ // Calculate average cost
172
+ let totalValue = 0;
173
+ let totalQty = 0;
174
+ activeBatches.forEach((b: any) => {
175
+ totalValue += (b.purchase_price || 0) * (b.quantity || 0);
176
+ totalQty += (b.quantity || 0);
177
+ });
178
+ const avgCost = totalQty > 0 ? (totalValue / totalQty).toFixed(2) : '0.00';
179
+
180
+ const getBatchStatusVariant = (status: string, expiry: string) => {
181
+ if (status !== 'ACTIVE') {
182
+ if (status === 'EXPIRED' || status === 'RECALLED' || status === 'DAMAGED') return 'danger';
183
+ if (status === 'QUARANTINED') return 'warning';
184
+ return 'default';
185
+ }
186
+
187
+ // Check expiry
188
+ if (expiry) {
189
+ const daysToExpiry = differenceInDays(new Date(expiry), new Date());
190
+ if (daysToExpiry < 0) return 'danger';
191
+ if (daysToExpiry <= 90) return 'warning';
192
+ }
193
+ return 'success';
194
+ };
195
+
196
+ const getBatchStatusLabel = (status: string, expiry: string) => {
197
+ if (status !== 'ACTIVE') return status;
198
+ if (expiry) {
199
+ const daysToExpiry = differenceInDays(new Date(expiry), new Date());
200
+ if (daysToExpiry < 0) return 'EXPIRED';
201
+ if (daysToExpiry <= 90) return 'NEAR EXPIRY';
202
+ }
203
+ return 'ACTIVE';
204
+ };
205
+
206
+ return (
207
+ <div className="flex flex-col gap-6 bg-transparent text-foreground">
208
+ {/* Header */}
209
+ <header className="flex-none rounded-xl border border-border bg-surface p-6 shadow-sm">
210
+ <div className="flex items-center gap-4 mb-4">
211
+ <button
212
+ onClick={() => router.push('/catalog/products')}
213
+ className="p-2 -ml-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-gray-800 transition-colors"
214
+ >
215
+ <ArrowLeft className="h-5 w-5" />
216
+ </button>
217
+ <div className="flex-1 flex justify-between items-start">
218
+ <div>
219
+ <div className="flex items-center gap-3">
220
+ <h1 className="text-2xl font-bold tracking-tight text-foreground">{medicine.name_en}</h1>
221
+ <Badge variant={medicine.is_active ? 'success' : 'danger'}>
222
+ {medicine.is_active ? 'Active' : 'Inactive'}
223
+ </Badge>
224
+ </div>
225
+ <div className="flex flex-wrap gap-x-6 gap-y-2 mt-2 text-sm text-muted-foreground">
226
+ {medicine.generic_name && (
227
+ <div className="flex items-center gap-1">
228
+ <span className="font-semibold text-gray-400">Generic:</span> {medicine.generic_name}
229
+ </div>
230
+ )}
231
+ {medicine.manufacturer && (
232
+ <div className="flex items-center gap-1">
233
+ <span className="font-semibold text-gray-400">Manufacturer:</span> {medicine.manufacturer}
234
+ </div>
235
+ )}
236
+ <div className="flex items-center gap-1">
237
+ <span className="font-semibold text-gray-400">Category:</span> {getCategoryName(medicine.category_id)}
238
+ </div>
239
+ {medicine.dosage_form && (
240
+ <div className="flex items-center gap-1">
241
+ <span className="font-semibold text-gray-400">Form:</span> <span className="capitalize">{medicine.dosage_form.replace('_', ' ')}</span>
242
+ </div>
243
+ )}
244
+ </div>
245
+ </div>
246
+ <div className="text-right bg-background border border-border rounded-lg p-3">
247
+ <div className="text-xs text-muted-foreground uppercase font-semibold mb-1">Selling Price</div>
248
+ <div className="text-xl font-bold text-brand-400">{currency} {Number(medicine.selling_price).toFixed(2)}</div>
249
+ </div>
250
+ </div>
251
+ </div>
252
+ </header>
253
+
254
+ {/* Stats Cards */}
255
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
256
+ <div className="rounded-xl border border-border bg-surface p-5 flex items-center gap-4">
257
+ <div className="p-3 bg-brand-500/10 text-brand-400 rounded-lg">
258
+ <Package className="h-6 w-6" />
259
+ </div>
260
+ <div>
261
+ <div className="text-sm font-medium text-muted-foreground">Total Active Stock</div>
262
+ <div className="text-2xl font-bold text-foreground mt-0.5">{totalStock} <span className="text-sm font-normal text-muted-foreground">{medicine.base_unit || 'Units'}</span></div>
263
+ </div>
264
+ </div>
265
+
266
+ <div className="rounded-xl border border-border bg-surface p-5 flex items-center gap-4">
267
+ <div className="p-3 bg-blue-500/10 text-blue-400 rounded-lg">
268
+ <Activity className="h-6 w-6" />
269
+ </div>
270
+ <div>
271
+ <div className="text-sm font-medium text-muted-foreground">Active Batches</div>
272
+ <div className="text-2xl font-bold text-foreground mt-0.5">{activeBatches.length}</div>
273
+ </div>
274
+ </div>
275
+
276
+ <div className="rounded-xl border border-border bg-surface p-5 flex items-center gap-4">
277
+ <div className="p-3 bg-green-500/10 text-green-400 rounded-lg">
278
+ <DollarSign className="h-6 w-6" />
279
+ </div>
280
+ <div>
281
+ <div className="text-sm font-medium text-muted-foreground">Average Cost</div>
282
+ <div className="text-2xl font-bold text-foreground mt-0.5"><span className="text-sm font-normal text-muted-foreground mr-1">{currency}</span>{avgCost}</div>
283
+ </div>
284
+ </div>
285
+ </div>
286
+
287
+ {/* Batches Table */}
288
+ <main className="flex-1">
289
+ <div className="rounded-xl border border-border bg-surface shadow-sm">
290
+ <div className="flex items-center justify-between p-4 border-b border-border">
291
+ <h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
292
+ <Archive className="h-5 w-5 text-muted-foreground" />
293
+ Batch Management
294
+ </h2>
295
+ <button
296
+ onClick={openAddBatch}
297
+ className="inline-flex h-9 items-center justify-center rounded-lg bg-brand-500 px-3 text-sm font-medium text-foreground transition-colors hover:bg-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-500 gap-1.5 shadow-sm"
298
+ >
299
+ <Plus className="h-4 w-4" />
300
+ Add Batch
301
+ </button>
302
+ </div>
303
+
304
+ <div className="overflow-x-auto">
305
+ <table className="w-full text-left text-sm text-foreground">
306
+ <thead className="bg-background text-xs uppercase text-muted-foreground border-b border-border">
307
+ <tr>
308
+ <th className="px-6 py-4 font-semibold">Batch Number</th>
309
+ <th className="px-6 py-4 font-semibold">Quantity</th>
310
+ <th className="px-6 py-4 font-semibold">Purchase Price</th>
311
+ <th className="px-6 py-4 font-semibold">Expiry Date</th>
312
+ <th className="px-6 py-4 font-semibold">Supplier</th>
313
+ <th className="px-6 py-4 font-semibold">Status</th>
314
+ <th className="px-6 py-4 font-semibold text-right">Actions</th>
315
+ </tr>
316
+ </thead>
317
+ <tbody className="divide-y divide-gray-800">
318
+ {(!medicine.batches || medicine.batches.length === 0) ? (
319
+ <tr>
320
+ <td colSpan={7} className="px-6 py-12 text-center text-muted-foreground">
321
+ No batches found for this product.
322
+ </td>
323
+ </tr>
324
+ ) : (
325
+ medicine.batches.map((batch: any) => (
326
+ <tr key={batch.id} className="transition-colors hover:bg-gray-800/40 group">
327
+ <td className="px-6 py-4 font-mono text-xs">{batch.batch_number}</td>
328
+ <td className="px-6 py-4 font-medium">{batch.quantity}</td>
329
+ <td className="px-6 py-4">{currency} {Number(batch.purchase_price).toFixed(2)}</td>
330
+ <td className="px-6 py-4">
331
+ <div className="flex items-center gap-1.5">
332
+ <Calendar className="h-3.5 w-3.5 text-muted-foreground" />
333
+ {batch.expiry_date ? format(new Date(batch.expiry_date), 'dd MMM yyyy') : '-'}
334
+ </div>
335
+ </td>
336
+ <td className="px-6 py-4 text-muted-foreground">
337
+ {suppliers.find(s => s.id === batch.supplier_id)?.name || '-'}
338
+ </td>
339
+ <td className="px-6 py-4">
340
+ <Badge variant={getBatchStatusVariant(batch.status, batch.expiry_date)}>
341
+ {getBatchStatusLabel(batch.status, batch.expiry_date)}
342
+ </Badge>
343
+ </td>
344
+ <td className="px-6 py-4 text-right">
345
+ <div className="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
346
+ <button
347
+ onClick={() => openEditBatch(batch)}
348
+ className="p-1.5 text-muted-foreground hover:text-brand-400 hover:bg-brand-500/10 rounded-md transition-colors"
349
+ title="Edit Batch"
350
+ >
351
+ <Edit className="h-4 w-4" />
352
+ </button>
353
+ {batch.status !== 'INACTIVE' && (
354
+ <button
355
+ onClick={() => setDeactivateDialog({ open: true, batchId: batch.id, batchNumber: batch.batch_number })}
356
+ className="p-1.5 text-muted-foreground hover:text-amber-400 hover:bg-amber-500/10 rounded-md transition-colors"
357
+ title="Deactivate Batch"
358
+ >
359
+ <Ban className="h-4 w-4" />
360
+ </button>
361
+ )}
362
+ </div>
363
+ </td>
364
+ </tr>
365
+ ))
366
+ )}
367
+ </tbody>
368
+ </table>
369
+ </div>
370
+ </div>
371
+ </main>
372
+
373
+ {/* SlideOver Form for Add/Edit Batch */}
374
+ <SlideOver
375
+ open={slideOpen}
376
+ onClose={() => setSlideOpen(false)}
377
+ title={editingBatch ? 'Edit Batch' : 'Add New Batch'}
378
+ footer={
379
+ <div className="flex justify-end gap-3">
380
+ <button
381
+ onClick={() => setSlideOpen(false)}
382
+ className="px-4 py-2 text-sm font-medium text-foreground hover:text-foreground transition-colors"
383
+ >
384
+ Cancel
385
+ </button>
386
+ <button
387
+ onClick={handleSaveBatch}
388
+ disabled={isSaving}
389
+ className="inline-flex h-10 items-center justify-center rounded-lg bg-brand-500 px-4 text-sm font-medium text-foreground transition-colors hover:bg-brand-600 disabled:opacity-50"
390
+ >
391
+ {isSaving ? 'Saving...' : 'Save Batch'}
392
+ </button>
393
+ </div>
394
+ }
395
+ >
396
+ <div className="space-y-6 pb-8">
397
+ <div className="grid grid-cols-1 gap-4">
398
+ <div className="space-y-1.5">
399
+ <label className="text-sm text-muted-foreground">Batch Number {editingBatch ? '' : '*'}</label>
400
+ <input
401
+ type="text"
402
+ value={batchForm.batch_number}
403
+ onChange={e => setBatchForm({...batchForm, batch_number: e.target.value})}
404
+ disabled={!!editingBatch}
405
+ className={cn("w-full rounded-lg border border-border bg-background px-3 py-2 text-sm font-mono focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500", editingBatch && "opacity-60 cursor-not-allowed")}
406
+ />
407
+ </div>
408
+
409
+ {!editingBatch && (
410
+ <div className="space-y-1.5">
411
+ <label className="text-sm text-muted-foreground">Quantity Received *</label>
412
+ <input
413
+ type="number" min="1"
414
+ value={batchForm.quantity}
415
+ onChange={e => setBatchForm({...batchForm, quantity: e.target.value})}
416
+ 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"
417
+ />
418
+ </div>
419
+ )}
420
+
421
+ <div className="space-y-1.5">
422
+ <label className="text-sm text-muted-foreground">Purchase Price ({currency}) *</label>
423
+ <input
424
+ type="number" step="0.01" min="0"
425
+ value={batchForm.purchase_price}
426
+ onChange={e => setBatchForm({...batchForm, purchase_price: e.target.value})}
427
+ 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"
428
+ />
429
+ </div>
430
+
431
+ <div className="space-y-1.5">
432
+ <label className="text-sm text-muted-foreground">Supplier</label>
433
+ <Select
434
+ value={batchForm.supplier_id}
435
+ onChange={v => setBatchForm({...batchForm, supplier_id: v})}
436
+ options={suppliers.map(s => ({value: s.id, label: s.name}))}
437
+ placeholder="Select supplier..."
438
+ />
439
+ </div>
440
+
441
+ <div className="space-y-1.5">
442
+ <label className="text-sm text-muted-foreground">Production Date</label>
443
+ <input
444
+ type="date"
445
+ value={batchForm.production_date}
446
+ onChange={e => setBatchForm({...batchForm, production_date: e.target.value})}
447
+ 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"
448
+ style={{ colorScheme: 'dark' }}
449
+ />
450
+ </div>
451
+
452
+ <div className="space-y-1.5">
453
+ <label className="text-sm text-muted-foreground">Expiry Date *</label>
454
+ <input
455
+ type="date"
456
+ value={batchForm.expiry_date}
457
+ onChange={e => setBatchForm({...batchForm, expiry_date: e.target.value})}
458
+ 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"
459
+ style={{ colorScheme: 'dark' }}
460
+ />
461
+ </div>
462
+
463
+ {editingBatch && (
464
+ <div className="space-y-1.5">
465
+ <label className="text-sm text-muted-foreground">Status</label>
466
+ <Select
467
+ value={batchForm.status}
468
+ onChange={v => setBatchForm({...batchForm, status: v})}
469
+ options={[
470
+ { value: 'ACTIVE', label: 'Active' },
471
+ { value: 'QUARANTINED', label: 'Quarantined' },
472
+ { value: 'RECALLED', label: 'Recalled' },
473
+ { value: 'DAMAGED', label: 'Damaged' },
474
+ { value: 'INACTIVE', label: 'Inactive' }
475
+ ]}
476
+ />
477
+ </div>
478
+ )}
479
+
480
+ <div className="space-y-1.5">
481
+ <label className="text-sm text-muted-foreground">Notes</label>
482
+ <textarea
483
+ rows={3}
484
+ value={batchForm.notes}
485
+ onChange={e => setBatchForm({...batchForm, notes: e.target.value})}
486
+ 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"
487
+ />
488
+ </div>
489
+ </div>
490
+ </div>
491
+ </SlideOver>
492
+
493
+ {/* Deactivate Confirmation */}
494
+ <ConfirmDialog
495
+ open={deactivateDialog.open}
496
+ onCancel={() => setDeactivateDialog({ open: false, batchId: '', batchNumber: '' })}
497
+ onConfirm={handleDeactivateBatch}
498
+ title="Deactivate Batch"
499
+ message={`Are you sure you want to deactivate batch ${deactivateDialog.batchNumber}? This will hide it from active stock.`}
500
+ isLoading={isSaving}
501
+ confirmText="Deactivate"
502
+ />
503
+ </div>
504
+ );
505
+ }