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.
- package/.env.example +34 -0
- package/LICENSE +21 -0
- package/README.md +479 -0
- package/backend/Dockerfile +44 -0
- package/backend/app/__init__.py +1 -0
- package/backend/app/core/__init__.py +1 -0
- package/backend/app/core/audit.py +33 -0
- package/backend/app/core/config.py +16 -0
- package/backend/app/core/database.py +26 -0
- package/backend/app/core/deps.py +49 -0
- package/backend/app/core/exceptions.py +43 -0
- package/backend/app/core/security.py +32 -0
- package/backend/app/main.py +54 -0
- package/backend/app/modules/__init__.py +0 -0
- package/backend/app/modules/admin/__init__.py +1 -0
- package/backend/app/modules/admin/models.py +15 -0
- package/backend/app/modules/admin/router.py +353 -0
- package/backend/app/modules/auth/__init__.py +0 -0
- package/backend/app/modules/auth/models.py +16 -0
- package/backend/app/modules/auth/router.py +73 -0
- package/backend/app/modules/auth/schemas.py +29 -0
- package/backend/app/modules/cash_sessions/__init__.py +0 -0
- package/backend/app/modules/cash_sessions/models.py +19 -0
- package/backend/app/modules/cash_sessions/router.py +85 -0
- package/backend/app/modules/cash_sessions/schemas.py +31 -0
- package/backend/app/modules/customers/__init__.py +0 -0
- package/backend/app/modules/customers/models.py +37 -0
- package/backend/app/modules/customers/router.py +296 -0
- package/backend/app/modules/customers/schemas.py +72 -0
- package/backend/app/modules/inventory/__init__.py +0 -0
- package/backend/app/modules/inventory/models.py +16 -0
- package/backend/app/modules/inventory/router.py +150 -0
- package/backend/app/modules/inventory/schemas.py +24 -0
- package/backend/app/modules/medicines/__init__.py +0 -0
- package/backend/app/modules/medicines/models.py +54 -0
- package/backend/app/modules/medicines/router.py +631 -0
- package/backend/app/modules/medicines/schemas.py +153 -0
- package/backend/app/modules/medicines/template_generator.py +118 -0
- package/backend/app/modules/notifications/__init__.py +1 -0
- package/backend/app/modules/notifications/models.py +15 -0
- package/backend/app/modules/notifications/router.py +89 -0
- package/backend/app/modules/notifications/schemas.py +22 -0
- package/backend/app/modules/organizations/__init__.py +0 -0
- package/backend/app/modules/organizations/models.py +16 -0
- package/backend/app/modules/organizations/router.py +49 -0
- package/backend/app/modules/organizations/schemas.py +31 -0
- package/backend/app/modules/reports/__init__.py +0 -0
- package/backend/app/modules/reports/router.py +409 -0
- package/backend/app/modules/roles/__init__.py +1 -0
- package/backend/app/modules/roles/models.py +43 -0
- package/backend/app/modules/roles/router.py +89 -0
- package/backend/app/modules/roles/schemas.py +55 -0
- package/backend/app/modules/sales/__init__.py +0 -0
- package/backend/app/modules/sales/models.py +44 -0
- package/backend/app/modules/sales/router.py +63 -0
- package/backend/app/modules/sales/schemas.py +42 -0
- package/backend/app/modules/sales/service.py +128 -0
- package/backend/app/modules/suppliers/__init__.py +0 -0
- package/backend/app/modules/suppliers/models.py +14 -0
- package/backend/app/modules/suppliers/router.py +56 -0
- package/backend/app/modules/suppliers/schemas.py +27 -0
- package/backend/app/modules/users/__init__.py +0 -0
- package/backend/app/modules/users/router.py +74 -0
- package/backend/app/seed.py +275 -0
- package/backend/data/.gitkeep +1 -0
- package/backend/requirements.txt +17 -0
- package/bin/cli.js +288 -0
- package/docker-compose.dev.yml +42 -0
- package/docker-compose.yml +59 -0
- package/frontend/Dockerfile +56 -0
- package/frontend/next.config.ts +19 -0
- package/frontend/package.json +60 -0
- package/frontend/pnpm-lock.yaml +5678 -0
- package/frontend/pnpm-workspace.yaml +6 -0
- package/frontend/postcss.config.mjs +6 -0
- package/frontend/public/logo.png +0 -0
- package/frontend/src/app/[locale]/(app)/admin/page.tsx +1426 -0
- package/frontend/src/app/[locale]/(app)/catalog/products/[id]/page.tsx +505 -0
- package/frontend/src/app/[locale]/(app)/catalog/products/page.tsx +753 -0
- package/frontend/src/app/[locale]/(app)/customers/[id]/page.tsx +500 -0
- package/frontend/src/app/[locale]/(app)/customers/page.tsx +538 -0
- package/frontend/src/app/[locale]/(app)/dashboard/page.tsx +175 -0
- package/frontend/src/app/[locale]/(app)/inventory/page.tsx +765 -0
- package/frontend/src/app/[locale]/(app)/layout.tsx +60 -0
- package/frontend/src/app/[locale]/(app)/purchasing/page.tsx +1 -0
- package/frontend/src/app/[locale]/(app)/reports/page.tsx +794 -0
- package/frontend/src/app/[locale]/(app)/sales/page.tsx +296 -0
- package/frontend/src/app/[locale]/(auth)/login/page.tsx +100 -0
- package/frontend/src/app/[locale]/(pos)/layout.tsx +33 -0
- package/frontend/src/app/[locale]/(pos)/pos/page.tsx +463 -0
- package/frontend/src/app/[locale]/(pos)/pos/pos-data.ts +89 -0
- package/frontend/src/app/[locale]/layout.tsx +30 -0
- package/frontend/src/app/[locale]/page.tsx +6 -0
- package/frontend/src/app/globals.css +77 -0
- package/frontend/src/app/layout.tsx +23 -0
- package/frontend/src/app/print.css +18 -0
- package/frontend/src/components/pos/CartPanel.tsx +525 -0
- package/frontend/src/components/pos/CashSessionGuard.tsx +255 -0
- package/frontend/src/components/pos/CloseSessionModal.tsx +160 -0
- package/frontend/src/components/pos/PaymentModal.tsx +266 -0
- package/frontend/src/components/pos/PaymentSettingsModal.tsx +472 -0
- package/frontend/src/components/pos/ProductCatalog.tsx +140 -0
- package/frontend/src/components/pos/SaleReceipt.tsx +133 -0
- package/frontend/src/components/ui/badge.tsx +28 -0
- package/frontend/src/components/ui/bulk-import-modal.tsx +400 -0
- package/frontend/src/components/ui/button.tsx +60 -0
- package/frontend/src/components/ui/card.tsx +13 -0
- package/frontend/src/components/ui/confirm-dialog.tsx +50 -0
- package/frontend/src/components/ui/input.tsx +32 -0
- package/frontend/src/components/ui/notification-center.tsx +228 -0
- package/frontend/src/components/ui/select.tsx +59 -0
- package/frontend/src/components/ui/sidebar.tsx +91 -0
- package/frontend/src/components/ui/slide-over.tsx +43 -0
- package/frontend/src/components/ui/stat-card.tsx +40 -0
- package/frontend/src/components/ui/switch.tsx +38 -0
- package/frontend/src/components/ui/topbar.tsx +97 -0
- package/frontend/src/i18n/request.ts +13 -0
- package/frontend/src/i18n/routing.ts +9 -0
- package/frontend/src/lib/api.ts +240 -0
- package/frontend/src/lib/constants.ts +30 -0
- package/frontend/src/lib/utils.ts +32 -0
- package/frontend/src/messages/ar.json +106 -0
- package/frontend/src/messages/en.json +106 -0
- package/frontend/src/middleware.ts +8 -0
- package/frontend/src/store/app-store.ts +37 -0
- package/frontend/src/store/auth-store.ts +52 -0
- package/frontend/src/store/payment-methods-store.ts +144 -0
- package/frontend/src/store/pos-store.ts +344 -0
- package/frontend/tailwind.config.ts +85 -0
- package/frontend/tsconfig.json +27 -0
- package/img/backgraund-logo.png +0 -0
- package/img/full-logo.png +0 -0
- package/img/logo.png +0 -0
- package/nginx/nginx.conf +67 -0
- package/package.json +88 -0
- package/start.bat +90 -0
- 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
|
+
}
|