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,753 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useEffect, useMemo } from 'react';
4
+ import { api } from '@/lib/api';
5
+ import { DOSAGE_FORMS, BASE_UNITS } from '@/lib/constants';
6
+ import { SlideOver } from '@/components/ui/slide-over';
7
+ import { Select } from '@/components/ui/select';
8
+ import { Switch } from '@/components/ui/switch';
9
+ import { Badge } from '@/components/ui/badge';
10
+ import { ConfirmDialog } from '@/components/ui/confirm-dialog';
11
+ import { toast } from 'react-hot-toast';
12
+ import { Search, Plus, Edit, Trash2, Package, Pill, Beaker, Tag, DollarSign, Archive, AlertCircle, ChevronDown, Upload } from 'lucide-react';
13
+ import { BulkImportModal } from '@/components/ui/bulk-import-modal';
14
+ import { cn } from '@/lib/utils';
15
+ import { useRouter, useParams, useSearchParams } from 'next/navigation';
16
+ import { useAppStore } from '@/store/app-store';
17
+
18
+ export default function ProductsPage() {
19
+ const router = useRouter();
20
+ const params = useParams();
21
+ const searchParams = useSearchParams();
22
+ const locale = params.locale as string;
23
+ const [medicines, setMedicines] = useState<any[]>([]);
24
+ const [categories, setCategories] = useState<any[]>([]);
25
+ const [suppliers, setSuppliers] = useState<any[]>([]);
26
+ const [loading, setLoading] = useState(true);
27
+ const { currency } = useAppStore();
28
+
29
+ const [search, setSearch] = useState('');
30
+ const [categoryFilter, setCategoryFilter] = useState('all');
31
+ const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all');
32
+
33
+ const [slideOpen, setSlideOpen] = useState(false);
34
+ const [importModalOpen, setImportModalOpen] = useState(false);
35
+ const [editingId, setEditingId] = useState<string | null>(null);
36
+
37
+ const [deleteDialog, setDeleteDialog] = useState({ open: false, id: '', name: '' });
38
+ const [isSaving, setIsSaving] = useState(false);
39
+
40
+ // Form State
41
+ const defaultForm = {
42
+ name: '',
43
+ name_ar: '',
44
+ sku: '',
45
+ barcode: '',
46
+ item_type: '',
47
+ therapeutic_category: '',
48
+ dosage_form: '',
49
+ generic_name: '',
50
+ brand_name: '',
51
+ strength: '',
52
+ manufacturer: '',
53
+ description: '',
54
+ selling_price: '',
55
+ base_unit: '',
56
+ units_per_pack: 1,
57
+ reorder_level: 10,
58
+ max_stock: 0,
59
+ requires_prescription: false,
60
+ controlled_substance: false,
61
+ cold_chain: false,
62
+ allow_loose_sale: false,
63
+ // Batch
64
+ has_initial_batch: false,
65
+ batch_quantity: '',
66
+ purchase_price: '',
67
+ production_date: '',
68
+ expiry_date: '',
69
+ batch_number: '',
70
+ supplier_id: ''
71
+ };
72
+ const [formData, setFormData] = useState(defaultForm);
73
+
74
+ useEffect(() => {
75
+ fetchData();
76
+ }, []);
77
+
78
+ const fetchData = async () => {
79
+ try {
80
+ setLoading(true);
81
+ const [medsRes, catsRes, suppsRes] = await Promise.all([
82
+ api.getMedicines().catch(() => []),
83
+ api.getCategoryTree().catch(() => []),
84
+ api.getSuppliers().catch(() => [])
85
+ ]);
86
+ setMedicines(medsRes as any[]);
87
+ setCategories(catsRes as any[]);
88
+ setSuppliers(suppsRes as any[]);
89
+ } catch (error) {
90
+ toast.error('Failed to load data');
91
+ } finally {
92
+ setLoading(false);
93
+ }
94
+ };
95
+
96
+ const paramId = searchParams.get('id');
97
+ useEffect(() => {
98
+ if (paramId && medicines.length > 0 && !slideOpen) {
99
+ const med = medicines.find(m => m.id === paramId);
100
+ if (med) {
101
+ openEdit(med);
102
+ }
103
+ }
104
+ }, [paramId, medicines]);
105
+
106
+ const openAdd = () => {
107
+ setEditingId(null);
108
+ setFormData({
109
+ ...defaultForm,
110
+ sku: `MED-${Date.now()}`,
111
+ batch_number: `BATCH-${Date.now().toString().slice(-6)}`
112
+ });
113
+ setSlideOpen(true);
114
+ };
115
+
116
+ const openEdit = (med: any) => {
117
+ setEditingId(med.id);
118
+ setFormData({
119
+ ...defaultForm,
120
+ name: med.name_en || '',
121
+ name_ar: med.name_ar || '',
122
+ sku: med.sku || '',
123
+ barcode: med.barcode || '',
124
+ item_type: med.category?.parent_id || med.category_id || '',
125
+ therapeutic_category: med.category?.parent_id ? med.category_id : '',
126
+ dosage_form: med.dosage_form || '',
127
+ generic_name: med.generic_name || '',
128
+ brand_name: med.brand_name || '',
129
+ strength: med.strength || '',
130
+ manufacturer: med.manufacturer || '',
131
+ description: med.description || '',
132
+ selling_price: med.selling_price?.toString() || '',
133
+ base_unit: med.base_unit || '',
134
+ units_per_pack: med.units_per_pack || 1,
135
+ reorder_level: med.reorder_level || 10,
136
+ max_stock: med.max_stock || 0,
137
+ requires_prescription: med.requires_prescription || false,
138
+ controlled_substance: med.controlled_substance || false,
139
+ cold_chain: med.cold_chain || false,
140
+ allow_loose_sale: med.allow_loose_sale || false,
141
+ });
142
+ setSlideOpen(true);
143
+ };
144
+
145
+ const handleDelete = async () => {
146
+ try {
147
+ setIsSaving(true);
148
+ await api.deleteMedicine(deleteDialog.id);
149
+ toast.success('Product deleted');
150
+ setMedicines(prev => prev.filter(m => m.id !== deleteDialog.id));
151
+ setDeleteDialog({ open: false, id: '', name: '' });
152
+ } catch (error: any) {
153
+ toast.error(error.message || 'Failed to delete product');
154
+ } finally {
155
+ setIsSaving(false);
156
+ }
157
+ };
158
+
159
+ const handleSave = async () => {
160
+ if (!formData.name || !formData.sku || !formData.selling_price || !formData.item_type) {
161
+ toast.error('Please fill all required fields');
162
+ return;
163
+ }
164
+
165
+ try {
166
+ setIsSaving(true);
167
+
168
+ const category_id = formData.therapeutic_category || formData.item_type;
169
+
170
+ const payload: any = {
171
+ name: formData.name,
172
+ name_ar: formData.name_ar,
173
+ sku: formData.sku,
174
+ barcode: formData.barcode,
175
+ category_id,
176
+ dosage_form: formData.dosage_form,
177
+ generic_name: formData.generic_name,
178
+ brand_name: formData.brand_name,
179
+ strength: formData.strength,
180
+ manufacturer: formData.manufacturer,
181
+ description: formData.description,
182
+ selling_price: Number(formData.selling_price),
183
+ base_unit: formData.base_unit,
184
+ units_per_pack: Number(formData.units_per_pack),
185
+ reorder_level: Number(formData.reorder_level),
186
+ max_stock: Number(formData.max_stock),
187
+ requires_prescription: formData.requires_prescription,
188
+ controlled_substance: formData.controlled_substance,
189
+ cold_chain: formData.cold_chain,
190
+ allow_loose_sale: formData.allow_loose_sale,
191
+ is_active: true
192
+ };
193
+
194
+ if (editingId) {
195
+ await api.updateMedicine(editingId, payload);
196
+ toast.success('Product updated');
197
+ } else {
198
+ if (formData.has_initial_batch) {
199
+ if (!formData.batch_quantity || !formData.purchase_price || !formData.expiry_date) {
200
+ toast.error('Please fill required batch fields');
201
+ setIsSaving(false);
202
+ return;
203
+ }
204
+ await api.createMedicineWithBatch({
205
+ medicine: payload,
206
+ batch: {
207
+ batch_number: formData.batch_number,
208
+ quantity: Number(formData.batch_quantity),
209
+ purchase_price: Number(formData.purchase_price),
210
+ production_date: formData.production_date || null,
211
+ expiry_date: formData.expiry_date,
212
+ supplier_id: formData.supplier_id || null
213
+ }
214
+ });
215
+ } else {
216
+ await api.createMedicine(payload);
217
+ }
218
+ toast.success('Product created');
219
+ }
220
+ setSlideOpen(false);
221
+ fetchData();
222
+ } catch (error: any) {
223
+ toast.error(error.message || 'Failed to save product');
224
+ } finally {
225
+ setIsSaving(false);
226
+ }
227
+ };
228
+
229
+ const getCategoryName = (id: string) => {
230
+ for (const c1 of categories) {
231
+ if (c1.id === id) return c1.name_en;
232
+ if (c1.children) {
233
+ for (const c2 of c1.children) {
234
+ if (c2.id === id) return c2.name_en;
235
+ }
236
+ }
237
+ }
238
+ return 'Uncategorized';
239
+ };
240
+
241
+ const filteredMedicines = useMemo(() => {
242
+ return medicines.filter(med => {
243
+ const matchSearch = search.toLowerCase() === '' ||
244
+ med.name_en?.toLowerCase().includes(search.toLowerCase()) ||
245
+ med.sku?.toLowerCase().includes(search.toLowerCase()) ||
246
+ med.barcode?.toLowerCase().includes(search.toLowerCase());
247
+
248
+ const matchCategory = categoryFilter === 'all' ||
249
+ med.category_id === categoryFilter ||
250
+ med.category?.parent_id === categoryFilter;
251
+
252
+ const matchStatus = statusFilter === 'all' ||
253
+ (statusFilter === 'active' && med.is_active) ||
254
+ (statusFilter === 'inactive' && !med.is_active);
255
+
256
+ return matchSearch && matchCategory && matchStatus;
257
+ });
258
+ }, [medicines, search, categoryFilter, statusFilter]);
259
+
260
+ const level1Cats = categories || [];
261
+ const selectedLevel1 = level1Cats.find(c => c.id === formData.item_type);
262
+ const level2Cats = selectedLevel1?.children || [];
263
+ const showTherapeutic = selectedLevel1?.name_en?.toLowerCase().includes('prescription') || level2Cats.length > 0;
264
+
265
+ return (
266
+ <div className="flex flex-col gap-6 bg-transparent text-foreground">
267
+ {/* Header */}
268
+ <header className="flex-none rounded-xl border border-border bg-surface p-6 shadow-sm">
269
+ <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
270
+ <div>
271
+ <h1 className="text-2xl font-bold tracking-tight">Medicine Catalog</h1>
272
+ <div className="flex items-center gap-3 mt-1">
273
+ <p className="text-sm text-muted-foreground">Manage medicines, pricing, and initial stock</p>
274
+ <div className="flex items-center rounded-full bg-blue-500/10 px-2.5 py-0.5 text-xs font-medium text-blue-400 ring-1 ring-inset ring-blue-500/20">
275
+ {medicines.length} Total Items
276
+ </div>
277
+ </div>
278
+ </div>
279
+ <div className="flex gap-2">
280
+ <button
281
+ onClick={() => setImportModalOpen(true)}
282
+ className="inline-flex h-10 items-center justify-center rounded-lg border border-border bg-transparent px-4 text-sm font-medium text-foreground transition-colors hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 focus:ring-offset-[#0B1220] gap-2"
283
+ >
284
+ <Upload className="h-4 w-4" />
285
+ Import
286
+ </button>
287
+ <button
288
+ onClick={openAdd}
289
+ 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 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 focus:ring-offset-[#0B1220] gap-2 shadow-lg shadow-brand-500/20"
290
+ >
291
+ <Plus className="h-4 w-4" />
292
+ Add Product
293
+ </button>
294
+ </div>
295
+ </div>
296
+
297
+ {/* Filters */}
298
+ <div className="mt-6 flex flex-col gap-4 sm:flex-row sm:items-center">
299
+ <div className="relative flex-1 max-w-md">
300
+ <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
301
+ <input
302
+ type="text"
303
+ placeholder="Search by name, SKU, or barcode..."
304
+ value={search}
305
+ onChange={(e) => setSearch(e.target.value)}
306
+ className="h-10 w-full rounded-lg border border-border bg-background pl-10 pr-4 text-sm text-foreground placeholder-gray-500 focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
307
+ />
308
+ </div>
309
+ <div className="flex items-center gap-3">
310
+ <Select
311
+ value={categoryFilter}
312
+ onChange={setCategoryFilter}
313
+ options={[{value: 'all', label: 'All Categories'}, ...level1Cats.map(c => ({value: c.id, label: c.name_en}))]}
314
+ className="w-[200px]"
315
+ />
316
+ <div className="flex items-center rounded-lg border border-border bg-background p-1">
317
+ <button
318
+ onClick={() => setStatusFilter('all')}
319
+ className={cn("px-3 py-1.5 text-xs font-medium rounded-md transition-colors", statusFilter === 'all' ? "bg-gray-700 text-foreground" : "text-muted-foreground hover:text-foreground")}
320
+ >
321
+ All
322
+ </button>
323
+ <button
324
+ onClick={() => setStatusFilter('active')}
325
+ className={cn("px-3 py-1.5 text-xs font-medium rounded-md transition-colors", statusFilter === 'active' ? "bg-brand-500/20 text-brand-400" : "text-muted-foreground hover:text-foreground")}
326
+ >
327
+ Active
328
+ </button>
329
+ <button
330
+ onClick={() => setStatusFilter('inactive')}
331
+ className={cn("px-3 py-1.5 text-xs font-medium rounded-md transition-colors", statusFilter === 'inactive' ? "bg-gray-700 text-foreground" : "text-muted-foreground hover:text-foreground")}
332
+ >
333
+ Inactive
334
+ </button>
335
+ </div>
336
+ </div>
337
+ </div>
338
+ </header>
339
+
340
+ {/* Main Table */}
341
+ <main className="flex-1">
342
+ <div className="rounded-xl border border-border bg-surface overflow-hidden shadow-sm">
343
+ <div className="overflow-x-auto">
344
+ <table className="w-full text-left text-sm text-foreground">
345
+ <thead className="bg-background text-xs uppercase text-muted-foreground border-b border-border">
346
+ <tr>
347
+ <th className="px-6 py-4 font-semibold">Product Name</th>
348
+ <th className="px-6 py-4 font-semibold">SKU / Barcode</th>
349
+ <th className="px-6 py-4 font-semibold">Category</th>
350
+ <th className="px-6 py-4 font-semibold">Price ({currency})</th>
351
+ <th className="px-6 py-4 font-semibold">Stock</th>
352
+ <th className="px-6 py-4 font-semibold">Status</th>
353
+ <th className="px-6 py-4 font-semibold text-right">Actions</th>
354
+ </tr>
355
+ </thead>
356
+ <tbody className="divide-y divide-gray-800">
357
+ {loading ? (
358
+ [...Array(5)].map((_, i) => (
359
+ <tr key={i} className="animate-pulse">
360
+ <td className="px-6 py-4"><div className="h-4 bg-gray-800 rounded w-3/4"></div></td>
361
+ <td className="px-6 py-4"><div className="h-4 bg-gray-800 rounded w-1/2"></div></td>
362
+ <td className="px-6 py-4"><div className="h-4 bg-gray-800 rounded w-2/3"></div></td>
363
+ <td className="px-6 py-4"><div className="h-4 bg-gray-800 rounded w-1/3"></div></td>
364
+ <td className="px-6 py-4"><div className="h-4 bg-gray-800 rounded w-1/4"></div></td>
365
+ <td className="px-6 py-4"><div className="h-4 bg-gray-800 rounded w-1/3"></div></td>
366
+ <td className="px-6 py-4"><div className="h-4 bg-gray-800 rounded w-8 ml-auto"></div></td>
367
+ </tr>
368
+ ))
369
+ ) : filteredMedicines.length === 0 ? (
370
+ <tr>
371
+ <td colSpan={7} className="px-6 py-16 text-center">
372
+ <div className="flex flex-col items-center justify-center">
373
+ <Package className="h-12 w-12 text-gray-600 mb-4" />
374
+ <h3 className="text-lg font-medium text-foreground">No products found</h3>
375
+ <p className="text-sm text-muted-foreground mt-1 max-w-sm">Get started by adding a new product to your catalog or adjust your filters.</p>
376
+ <button onClick={openAdd} className="mt-6 text-brand-400 hover:text-brand-300 font-medium text-sm flex items-center gap-1">
377
+ <Plus className="h-4 w-4" /> Add your first product
378
+ </button>
379
+ </div>
380
+ </td>
381
+ </tr>
382
+ ) : (
383
+ filteredMedicines.map((med) => {
384
+ const totalStock = med.batches?.reduce((acc: number, b: any) => acc + (b.quantity || 0), 0) || 0;
385
+ return (
386
+ <tr
387
+ key={med.id}
388
+ onClick={() => router.push(`/${locale}/catalog/products/${med.id}`)}
389
+ className="transition-colors hover:bg-gray-800/40 group cursor-pointer"
390
+ >
391
+ <td className="px-6 py-4">
392
+ <div className="flex flex-col">
393
+ <span className="font-medium text-foreground">{med.name_en}</span>
394
+ {med.strength && <span className="text-xs text-brand-400 mt-0.5">{med.strength}</span>}
395
+ </div>
396
+ </td>
397
+ <td className="px-6 py-4">
398
+ <div className="flex flex-col">
399
+ <span className="font-mono text-xs text-foreground">{med.sku}</span>
400
+ {med.barcode && <span className="text-xs text-muted-foreground">{med.barcode}</span>}
401
+ </div>
402
+ </td>
403
+ <td className="px-6 py-4">
404
+ <div className="flex flex-col">
405
+ <span className="text-sm">{getCategoryName(med.category_id)}</span>
406
+ {med.dosage_form && <span className="text-xs text-muted-foreground capitalize">{med.dosage_form.replace('_', ' ')}</span>}
407
+ </div>
408
+ </td>
409
+ <td className="px-6 py-4">
410
+ <span className="font-medium text-foreground">{Number(med.selling_price).toFixed(2)}</span>
411
+ </td>
412
+ <td className="px-6 py-4">
413
+ <div className="flex items-center gap-2">
414
+ <span className={cn("font-medium", totalStock <= med.reorder_level ? "text-amber-400" : totalStock === 0 ? "text-red-400" : "text-green-400")}>
415
+ {totalStock}
416
+ </span>
417
+ <span className="text-xs text-muted-foreground">{med.base_unit || 'Unit'}</span>
418
+ </div>
419
+ </td>
420
+ <td className="px-6 py-4">
421
+ <Badge variant={med.is_active ? 'success' : 'danger'}>
422
+ {med.is_active ? 'Active' : 'Inactive'}
423
+ </Badge>
424
+ </td>
425
+ <td className="px-6 py-4 text-right">
426
+ <div className="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
427
+ <button
428
+ onClick={(e) => {
429
+ e.stopPropagation();
430
+ openEdit(med);
431
+ }}
432
+ className="p-1.5 text-muted-foreground hover:text-brand-400 hover:bg-brand-500/10 rounded-md transition-colors"
433
+ title="Edit"
434
+ >
435
+ <Edit className="h-4 w-4" />
436
+ </button>
437
+ <button
438
+ onClick={(e) => {
439
+ e.stopPropagation();
440
+ setDeleteDialog({ open: true, id: med.id, name: med.name_en });
441
+ }}
442
+ className="p-1.5 text-muted-foreground hover:text-red-400 hover:bg-red-500/10 rounded-md transition-colors"
443
+ title="Delete"
444
+ >
445
+ <Trash2 className="h-4 w-4" />
446
+ </button>
447
+ </div>
448
+ </td>
449
+ </tr>
450
+ );
451
+ })
452
+ )}
453
+ </tbody>
454
+ </table>
455
+ </div>
456
+ </div>
457
+ </main>
458
+
459
+ {/* SlideOver Form */}
460
+ <SlideOver
461
+ open={slideOpen}
462
+ onClose={() => setSlideOpen(false)}
463
+ title={editingId ? 'Edit Product' : 'Add New Product'}
464
+ footer={
465
+ <div className="flex justify-end gap-3">
466
+ <button
467
+ onClick={() => setSlideOpen(false)}
468
+ className="px-4 py-2 text-sm font-medium text-foreground hover:text-foreground transition-colors"
469
+ >
470
+ Cancel
471
+ </button>
472
+ <button
473
+ onClick={handleSave}
474
+ disabled={isSaving}
475
+ 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"
476
+ >
477
+ {isSaving ? 'Saving...' : 'Save Product'}
478
+ </button>
479
+ </div>
480
+ }
481
+ >
482
+ <div className="space-y-8 pb-8">
483
+ {/* Basic Info */}
484
+ <section className="space-y-4">
485
+ <div className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
486
+ <Pill className="h-4 w-4" /> Basic Information
487
+ </div>
488
+ <div className="space-y-4">
489
+ <div className="grid grid-cols-1 gap-4">
490
+ <div className="space-y-1.5">
491
+ <label className="text-sm text-muted-foreground">Name *</label>
492
+ <input
493
+ type="text"
494
+ value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})}
495
+ 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"
496
+ />
497
+ </div>
498
+ <div className="space-y-1.5">
499
+ <label className="text-sm text-muted-foreground">Generic Name</label>
500
+ <input
501
+ type="text"
502
+ value={formData.generic_name} onChange={e => setFormData({...formData, generic_name: e.target.value})}
503
+ 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"
504
+ />
505
+ </div>
506
+ <div className="space-y-1.5">
507
+ <label className="text-sm text-muted-foreground">Manufacturer</label>
508
+ <input
509
+ type="text"
510
+ value={formData.manufacturer} onChange={e => setFormData({...formData, manufacturer: e.target.value})}
511
+ 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"
512
+ />
513
+ </div>
514
+ </div>
515
+ <div className="grid grid-cols-2 gap-4">
516
+ <div className="space-y-1.5">
517
+ <label className="text-sm text-muted-foreground">SKU *</label>
518
+ <input
519
+ type="text"
520
+ value={formData.sku} onChange={e => setFormData({...formData, sku: e.target.value})}
521
+ className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground font-mono focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
522
+ />
523
+ </div>
524
+ <div className="space-y-1.5">
525
+ <label className="text-sm text-muted-foreground">Barcode</label>
526
+ <input
527
+ type="text"
528
+ value={formData.barcode} onChange={e => setFormData({...formData, barcode: e.target.value})}
529
+ className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground font-mono focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
530
+ />
531
+ </div>
532
+ </div>
533
+ </div>
534
+ </section>
535
+
536
+ {/* Classification */}
537
+ <section className="space-y-4">
538
+ <div className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
539
+ <Tag className="h-4 w-4" /> Classification
540
+ </div>
541
+ <div className="space-y-4">
542
+ <div className="grid grid-cols-1 gap-4">
543
+ <div className="space-y-1.5">
544
+ <label className="text-sm text-muted-foreground">Item Type *</label>
545
+ <Select
546
+ value={formData.item_type}
547
+ onChange={v => setFormData({...formData, item_type: v, therapeutic_category: ''})}
548
+ options={level1Cats.map(c => ({value: c.id, label: c.name_en}))}
549
+ placeholder="Select item type..."
550
+ />
551
+ </div>
552
+ {showTherapeutic && (
553
+ <div className="space-y-1.5 animate-in fade-in slide-in-from-top-2">
554
+ <label className="text-sm text-muted-foreground">Therapeutic Category</label>
555
+ <Select
556
+ value={formData.therapeutic_category}
557
+ onChange={v => setFormData({...formData, therapeutic_category: v})}
558
+ options={level2Cats.map((c: any) => ({value: c.id, label: c.name_en}))}
559
+ placeholder="Select therapeutic category..."
560
+ />
561
+ </div>
562
+ )}
563
+ <div className="space-y-1.5">
564
+ <label className="text-sm text-muted-foreground">Dosage Form</label>
565
+ <Select
566
+ value={formData.dosage_form}
567
+ onChange={v => setFormData({...formData, dosage_form: v})}
568
+ options={DOSAGE_FORMS.map(d => ({value: d.value, label: d.label_en}))}
569
+ placeholder="Select dosage form..."
570
+ />
571
+ </div>
572
+ </div>
573
+ </div>
574
+ </section>
575
+
576
+ {/* Details */}
577
+ <section className="space-y-4">
578
+ <div className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
579
+ <Beaker className="h-4 w-4" /> Product Details
580
+ </div>
581
+ <div className="space-y-4">
582
+
583
+
584
+ <div className="space-y-1.5">
585
+ <label className="text-sm text-muted-foreground">Description</label>
586
+ <textarea
587
+ rows={3}
588
+ value={formData.description} onChange={e => setFormData({...formData, description: e.target.value})}
589
+ 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"
590
+ />
591
+ </div>
592
+ </div>
593
+ </section>
594
+
595
+ {/* Pricing & Units */}
596
+ <section className="space-y-4">
597
+ <div className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
598
+ <DollarSign className="h-4 w-4" /> Pricing & Units
599
+ </div>
600
+ <div className="grid grid-cols-2 gap-4">
601
+ <div className="space-y-1.5">
602
+ <label className="text-sm text-muted-foreground">Selling Price ({currency}) *</label>
603
+ <input
604
+ type="number" step="0.01" min="0"
605
+ value={formData.selling_price} onChange={e => setFormData({...formData, selling_price: e.target.value})}
606
+ 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"
607
+ />
608
+ </div>
609
+ <div className="space-y-1.5">
610
+ <label className="text-sm text-muted-foreground">Base Unit</label>
611
+ <Select
612
+ value={formData.base_unit}
613
+ onChange={v => setFormData({...formData, base_unit: v})}
614
+ options={BASE_UNITS.map(u => ({value: u.value, label: u.label_en}))}
615
+ placeholder="Select unit..."
616
+ />
617
+ </div>
618
+ <div className="space-y-1.5">
619
+ <label className="text-sm text-muted-foreground">Units per Pack</label>
620
+ <input
621
+ type="number" min="1"
622
+ value={formData.units_per_pack} onChange={e => setFormData({...formData, units_per_pack: parseInt(e.target.value) || 1})}
623
+ 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"
624
+ />
625
+ </div>
626
+ </div>
627
+ </section>
628
+
629
+ {/* Initial Stock (Only on creation) */}
630
+ {!editingId && (
631
+ <section className="space-y-4 rounded-xl border border-brand-500/20 bg-brand-500/5 p-4">
632
+ <div className="flex items-center justify-between">
633
+ <div className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-brand-400">
634
+ <Archive className="h-4 w-4" /> Initial Stock Batch
635
+ </div>
636
+ <Switch
637
+ checked={formData.has_initial_batch}
638
+ onCheckedChange={c => setFormData({...formData, has_initial_batch: c})}
639
+ />
640
+ </div>
641
+
642
+ {formData.has_initial_batch && (
643
+ <div className="grid grid-cols-2 gap-4 pt-2 animate-in fade-in slide-in-from-top-2">
644
+ <div className="space-y-1.5">
645
+ <label className="text-sm text-muted-foreground">Quantity *</label>
646
+ <input
647
+ type="number" min="1"
648
+ value={formData.batch_quantity} onChange={e => setFormData({...formData, batch_quantity: e.target.value})}
649
+ 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"
650
+ />
651
+ </div>
652
+ <div className="space-y-1.5">
653
+ <label className="text-sm text-muted-foreground">Purchase Price ({currency}) *</label>
654
+ <input
655
+ type="number" step="0.01" min="0"
656
+ value={formData.purchase_price} onChange={e => setFormData({...formData, purchase_price: e.target.value})}
657
+ 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"
658
+ />
659
+ </div>
660
+ <div className="space-y-1.5">
661
+ <label className="text-sm text-muted-foreground">Batch Number</label>
662
+ <input
663
+ type="text"
664
+ value={formData.batch_number} onChange={e => setFormData({...formData, batch_number: e.target.value})}
665
+ 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"
666
+ />
667
+ </div>
668
+ <div className="space-y-1.5">
669
+ <label className="text-sm text-muted-foreground">Supplier</label>
670
+ <Select
671
+ value={formData.supplier_id}
672
+ onChange={v => setFormData({...formData, supplier_id: v})}
673
+ options={suppliers.map(s => ({value: s.id, label: s.name}))}
674
+ placeholder="Select supplier..."
675
+ />
676
+ </div>
677
+ <div className="space-y-1.5">
678
+ <label className="text-sm text-muted-foreground">Production Date</label>
679
+ <input
680
+ type="date"
681
+ value={formData.production_date} onChange={e => setFormData({...formData, production_date: e.target.value})}
682
+ 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"
683
+ style={{ colorScheme: 'dark' }}
684
+ />
685
+ </div>
686
+ <div className="space-y-1.5">
687
+ <label className="text-sm text-muted-foreground">Expiry Date *</label>
688
+ <input
689
+ type="date"
690
+ value={formData.expiry_date} onChange={e => setFormData({...formData, expiry_date: e.target.value})}
691
+ 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"
692
+ style={{ colorScheme: 'dark' }}
693
+ />
694
+ </div>
695
+ </div>
696
+ )}
697
+ </section>
698
+ )}
699
+
700
+ {/* Flags */}
701
+ <section className="space-y-4">
702
+ <div className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
703
+ <AlertCircle className="h-4 w-4" /> Properties & Flags
704
+ </div>
705
+ <div className="grid grid-cols-1 gap-4 rounded-xl border border-border bg-background/30 p-4">
706
+ <Switch
707
+ checked={formData.requires_prescription}
708
+ onCheckedChange={c => setFormData({...formData, requires_prescription: c})}
709
+ label="Requires Prescription"
710
+ description="Rx icon will be shown. Pharmacist approval needed for sale."
711
+ />
712
+ <Switch
713
+ checked={formData.controlled_substance}
714
+ onCheckedChange={c => setFormData({...formData, controlled_substance: c})}
715
+ label="Controlled Substance"
716
+ description="Strict tracking and reporting requirements applied."
717
+ />
718
+ <Switch
719
+ checked={formData.cold_chain}
720
+ onCheckedChange={c => setFormData({...formData, cold_chain: c})}
721
+ label="Cold Chain Required"
722
+ description="Must be stored in refrigerator (2°C to 8°C)."
723
+ />
724
+ <Switch
725
+ checked={formData.allow_loose_sale}
726
+ onCheckedChange={c => setFormData({...formData, allow_loose_sale: c})}
727
+ label="Allow Loose Sale"
728
+ description="Can be sold in individual units (e.g., single tablets) rather than full pack."
729
+ />
730
+ </div>
731
+ </section>
732
+ </div>
733
+ </SlideOver>
734
+
735
+ {/* Delete Confirmation */}
736
+ <ConfirmDialog
737
+ open={deleteDialog.open}
738
+ onCancel={() => setDeleteDialog({ open: false, id: '', name: '' })}
739
+ onConfirm={handleDelete}
740
+ title="Delete Product"
741
+ message={`Are you sure you want to delete ${deleteDialog.name}? This action cannot be undone.`}
742
+ isLoading={isSaving}
743
+ />
744
+
745
+ {/* Bulk Import Modal */}
746
+ <BulkImportModal
747
+ open={importModalOpen}
748
+ onOpenChange={setImportModalOpen}
749
+ onImportComplete={fetchData}
750
+ />
751
+ </div>
752
+ );
753
+ }