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,133 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { X, Printer } from 'lucide-react';
|
|
5
|
+
import { formatMoney } from '@/lib/utils';
|
|
6
|
+
import { useAppStore } from '@/store/app-store';
|
|
7
|
+
|
|
8
|
+
interface SaleReceiptProps {
|
|
9
|
+
open: boolean;
|
|
10
|
+
onClose: () => void;
|
|
11
|
+
saleData: {
|
|
12
|
+
invoice_number: string;
|
|
13
|
+
created_at: string;
|
|
14
|
+
items: { medicine_id: string; quantity: number; unit_price: number; line_total: number; name?: string }[];
|
|
15
|
+
total_amount: number;
|
|
16
|
+
payment_method: string;
|
|
17
|
+
customer?: { id: string; name: string } | null;
|
|
18
|
+
} | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function SaleReceipt({ open, onClose, saleData }: SaleReceiptProps) {
|
|
22
|
+
const { currency } = useAppStore();
|
|
23
|
+
|
|
24
|
+
if (!open || !saleData) return null;
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
|
28
|
+
<div className="bg-background text-foreground border-border w-full max-w-md rounded-lg shadow-xl overflow-hidden flex flex-col max-h-[90vh]">
|
|
29
|
+
|
|
30
|
+
{/* Header (Not printed) */}
|
|
31
|
+
<div className="flex justify-between items-center p-4 border-b border-border bg-surface print:hidden">
|
|
32
|
+
<h2 className="text-lg font-semibold">Sale Receipt</h2>
|
|
33
|
+
<button onClick={onClose} className="p-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-full">
|
|
34
|
+
<X size={20} />
|
|
35
|
+
</button>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
{/* Receipt content (Scrollable area) */}
|
|
39
|
+
<div className="p-6 overflow-y-auto flex-1 bg-gray-50 dark:bg-gray-900 print:bg-white flex justify-center">
|
|
40
|
+
|
|
41
|
+
{/* Actual printed area */}
|
|
42
|
+
<div
|
|
43
|
+
id="receipt-print-area"
|
|
44
|
+
className="bg-white text-black w-full max-w-[300px] p-4 shadow-sm border border-gray-200 print:shadow-none print:border-none font-mono text-sm"
|
|
45
|
+
style={{ width: '80mm', minHeight: '100px' }}
|
|
46
|
+
>
|
|
47
|
+
{/* Header */}
|
|
48
|
+
<div className="text-center mb-4">
|
|
49
|
+
<h1 className="font-bold text-lg">Pharma ERP</h1>
|
|
50
|
+
<p className="text-xs">Main Branch</p>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
{/* Info */}
|
|
54
|
+
<div className="mb-4 text-xs">
|
|
55
|
+
<p>Invoice #: {saleData.invoice_number}</p>
|
|
56
|
+
<p>Date: {new Date(saleData.created_at).toLocaleString()}</p>
|
|
57
|
+
{saleData.customer && <p>Customer: {saleData.customer.name}</p>}
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<div className="border-t border-dashed border-gray-400 my-2"></div>
|
|
61
|
+
|
|
62
|
+
{/* Items */}
|
|
63
|
+
<table className="w-full text-xs text-left mb-4">
|
|
64
|
+
<thead>
|
|
65
|
+
<tr>
|
|
66
|
+
<th className="py-1">Item</th>
|
|
67
|
+
<th className="py-1 text-center">Qty</th>
|
|
68
|
+
<th className="py-1 text-right">Price</th>
|
|
69
|
+
<th className="py-1 text-right">Total</th>
|
|
70
|
+
</tr>
|
|
71
|
+
</thead>
|
|
72
|
+
<tbody>
|
|
73
|
+
{saleData.items.map((item, idx) => (
|
|
74
|
+
<tr key={idx}>
|
|
75
|
+
<td className="py-1 truncate max-w-[100px]">{item.name || 'Item'}</td>
|
|
76
|
+
<td className="py-1 text-center">{item.quantity}</td>
|
|
77
|
+
<td className="py-1 text-right">{formatMoney(item.unit_price, currency)}</td>
|
|
78
|
+
<td className="py-1 text-right">{formatMoney(item.line_total, currency)}</td>
|
|
79
|
+
</tr>
|
|
80
|
+
))}
|
|
81
|
+
</tbody>
|
|
82
|
+
</table>
|
|
83
|
+
|
|
84
|
+
<div className="border-t border-dashed border-gray-400 my-2"></div>
|
|
85
|
+
|
|
86
|
+
{/* Totals */}
|
|
87
|
+
<div className="text-xs space-y-1 mb-4">
|
|
88
|
+
<div className="flex justify-between">
|
|
89
|
+
<span>Subtotal:</span>
|
|
90
|
+
<span>{formatMoney(saleData.total_amount, currency)}</span>
|
|
91
|
+
</div>
|
|
92
|
+
<div className="flex justify-between">
|
|
93
|
+
<span>Tax:</span>
|
|
94
|
+
<span>{formatMoney(0, currency)}</span>
|
|
95
|
+
</div>
|
|
96
|
+
<div className="flex justify-between font-bold text-base mt-2">
|
|
97
|
+
<span>Total:</span>
|
|
98
|
+
<span>{formatMoney(saleData.total_amount, currency)}</span>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
{/* Payment Info */}
|
|
103
|
+
<div className="text-xs mb-6">
|
|
104
|
+
<p>Payment Method: <span className="capitalize">{saleData.payment_method}</span></p>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
{/* Footer */}
|
|
108
|
+
<div className="text-center text-xs mt-4">
|
|
109
|
+
<p>Thank you for your purchase!</p>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
{/* Footer actions (Not printed) */}
|
|
115
|
+
<div className="p-4 border-t border-border flex justify-end gap-3 print:hidden bg-surface">
|
|
116
|
+
<button
|
|
117
|
+
onClick={onClose}
|
|
118
|
+
className="px-4 py-2 border border-border rounded-md hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
|
119
|
+
>
|
|
120
|
+
Close
|
|
121
|
+
</button>
|
|
122
|
+
<button
|
|
123
|
+
onClick={() => window.print()}
|
|
124
|
+
className="px-4 py-2 bg-pos-500 text-white rounded-md hover:bg-pos-600 transition-colors flex items-center gap-2"
|
|
125
|
+
>
|
|
126
|
+
<Printer size={18} />
|
|
127
|
+
Print Receipt
|
|
128
|
+
</button>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { cn } from '@/lib/utils'; // Assuming cn exists
|
|
3
|
+
|
|
4
|
+
const badgeVariants = {
|
|
5
|
+
success: 'bg-green-500/10 text-green-400 border border-green-500/20',
|
|
6
|
+
warning: 'bg-amber-500/10 text-amber-400 border border-amber-500/20',
|
|
7
|
+
danger: 'bg-red-500/10 text-red-400 border border-red-500/20',
|
|
8
|
+
info: 'bg-blue-500/10 text-blue-400 border border-blue-500/20',
|
|
9
|
+
default: 'bg-gray-800 text-gray-300 border border-gray-700',
|
|
10
|
+
outline: 'text-foreground border border-input',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
14
|
+
variant?: keyof typeof badgeVariants;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function Badge({ className, variant = 'default', ...props }: BadgeProps) {
|
|
18
|
+
return (
|
|
19
|
+
<div
|
|
20
|
+
className={cn(
|
|
21
|
+
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2",
|
|
22
|
+
badgeVariants[variant],
|
|
23
|
+
className
|
|
24
|
+
)}
|
|
25
|
+
{...props}
|
|
26
|
+
/>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState, useRef, useEffect } from 'react';
|
|
4
|
+
import * as Dialog from '@radix-ui/react-dialog';
|
|
5
|
+
import { Upload, X, File as FileIcon, AlertCircle, CheckCircle2, ChevronRight, FileSpreadsheet, Download, RefreshCw, FileText } from 'lucide-react';
|
|
6
|
+
import { Button } from './button';
|
|
7
|
+
import { api } from '@/lib/api';
|
|
8
|
+
import toast from 'react-hot-toast';
|
|
9
|
+
import Papa from 'papaparse';
|
|
10
|
+
import * as XLSX from 'xlsx';
|
|
11
|
+
import { useTranslations } from 'next-intl';
|
|
12
|
+
|
|
13
|
+
interface BulkImportModalProps {
|
|
14
|
+
open: boolean;
|
|
15
|
+
onOpenChange: (open: boolean) => void;
|
|
16
|
+
onImportComplete: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type Step = 1 | 2 | 3;
|
|
20
|
+
|
|
21
|
+
interface ParsedRow {
|
|
22
|
+
index: number;
|
|
23
|
+
data: any;
|
|
24
|
+
isValid: boolean;
|
|
25
|
+
errors: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function BulkImportModal({ open, onOpenChange, onImportComplete }: BulkImportModalProps) {
|
|
29
|
+
const t = useTranslations();
|
|
30
|
+
const [step, setStep] = useState<Step>(1);
|
|
31
|
+
const [file, setFile] = useState<File | null>(null);
|
|
32
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
33
|
+
const [parsedData, setParsedData] = useState<ParsedRow[]>([]);
|
|
34
|
+
const [headers, setHeaders] = useState<string[]>([]);
|
|
35
|
+
const [isImporting, setIsImporting] = useState(false);
|
|
36
|
+
const [importResults, setImportResults] = useState<{ success: number; errors: any[] } | null>(null);
|
|
37
|
+
|
|
38
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!open) {
|
|
42
|
+
setTimeout(() => {
|
|
43
|
+
setStep(1);
|
|
44
|
+
setFile(null);
|
|
45
|
+
setParsedData([]);
|
|
46
|
+
setHeaders([]);
|
|
47
|
+
setImportResults(null);
|
|
48
|
+
}, 300);
|
|
49
|
+
}
|
|
50
|
+
}, [open]);
|
|
51
|
+
|
|
52
|
+
const handleDragOver = (e: React.DragEvent) => {
|
|
53
|
+
e.preventDefault();
|
|
54
|
+
setIsDragging(true);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const handleDragLeave = () => {
|
|
58
|
+
setIsDragging(false);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const handleDrop = (e: React.DragEvent) => {
|
|
62
|
+
e.preventDefault();
|
|
63
|
+
setIsDragging(false);
|
|
64
|
+
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
|
65
|
+
handleFileSelect(e.dataTransfer.files[0]);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const handleFileSelect = (selectedFile: File) => {
|
|
70
|
+
const validTypes = ['text/csv', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-excel'];
|
|
71
|
+
const validExtensions = ['.csv', '.xlsx', '.xls'];
|
|
72
|
+
const fileExt = selectedFile.name.substring(selectedFile.name.lastIndexOf('.')).toLowerCase();
|
|
73
|
+
|
|
74
|
+
if (!validTypes.includes(selectedFile.type) && !validExtensions.includes(fileExt)) {
|
|
75
|
+
toast.error('Invalid file type. Please upload a CSV or Excel file.');
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
setFile(selectedFile);
|
|
79
|
+
parseFile(selectedFile);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const validateRow = (row: any): { isValid: boolean; errors: string[] } => {
|
|
83
|
+
const errors: string[] = [];
|
|
84
|
+
|
|
85
|
+
// Check for keys with or without the asterisk
|
|
86
|
+
const name_en = row['name_en *'] || row.name_en;
|
|
87
|
+
const selling_price = row['selling_price *'] || row.selling_price;
|
|
88
|
+
const base_unit = row['base_unit *'] || row.base_unit;
|
|
89
|
+
|
|
90
|
+
if (!name_en) errors.push('name_en is required');
|
|
91
|
+
if (selling_price === undefined || selling_price === null || selling_price === '') {
|
|
92
|
+
errors.push('selling_price is required');
|
|
93
|
+
} else {
|
|
94
|
+
const price = Number(selling_price);
|
|
95
|
+
if (isNaN(price) || price <= 0) errors.push('selling_price must be a positive number');
|
|
96
|
+
}
|
|
97
|
+
if (!base_unit) errors.push('base_unit is required');
|
|
98
|
+
|
|
99
|
+
const boolFields = ['is_active', 'requires_prescription'];
|
|
100
|
+
boolFields.forEach(field => {
|
|
101
|
+
if (row[field] !== undefined && row[field] !== '') {
|
|
102
|
+
const val = String(row[field]).toLowerCase().trim();
|
|
103
|
+
if (!['yes', 'no', 'true', 'false', '1', '0'].includes(val)) {
|
|
104
|
+
errors.push(`${field} must be boolean (yes/no/true/false)`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
return { isValid: errors.length === 0, errors };
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const parseFile = (fileToParse: File) => {
|
|
113
|
+
const fileExt = fileToParse.name.substring(fileToParse.name.lastIndexOf('.')).toLowerCase();
|
|
114
|
+
|
|
115
|
+
if (fileExt === '.csv') {
|
|
116
|
+
Papa.parse(fileToParse, {
|
|
117
|
+
header: true,
|
|
118
|
+
skipEmptyLines: true,
|
|
119
|
+
complete: (results) => {
|
|
120
|
+
const rows: ParsedRow[] = results.data.map((row: any, index: number) => {
|
|
121
|
+
const validation = validateRow(row);
|
|
122
|
+
return {
|
|
123
|
+
index: index + 1,
|
|
124
|
+
data: row,
|
|
125
|
+
isValid: validation.isValid,
|
|
126
|
+
errors: validation.errors
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
setHeaders(results.meta.fields || []);
|
|
130
|
+
setParsedData(rows);
|
|
131
|
+
setStep(2);
|
|
132
|
+
},
|
|
133
|
+
error: () => {
|
|
134
|
+
toast.error('Error parsing CSV file');
|
|
135
|
+
setFile(null);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
} else {
|
|
139
|
+
const reader = new FileReader();
|
|
140
|
+
reader.onload = (e) => {
|
|
141
|
+
try {
|
|
142
|
+
const data = e.target?.result;
|
|
143
|
+
const workbook = XLSX.read(data, { type: 'binary' });
|
|
144
|
+
const firstSheetName = workbook.SheetNames[0];
|
|
145
|
+
const worksheet = workbook.Sheets[firstSheetName];
|
|
146
|
+
const jsonData = XLSX.utils.sheet_to_json(worksheet, { defval: '' });
|
|
147
|
+
|
|
148
|
+
if (jsonData.length > 0) {
|
|
149
|
+
setHeaders(Object.keys(jsonData[0] as object));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const rows: ParsedRow[] = jsonData.map((row: any, index: number) => {
|
|
153
|
+
const validation = validateRow(row);
|
|
154
|
+
return {
|
|
155
|
+
index: index + 1,
|
|
156
|
+
data: row,
|
|
157
|
+
isValid: validation.isValid,
|
|
158
|
+
errors: validation.errors
|
|
159
|
+
};
|
|
160
|
+
});
|
|
161
|
+
setParsedData(rows);
|
|
162
|
+
setStep(2);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
toast.error('Error parsing Excel file');
|
|
165
|
+
setFile(null);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
reader.readAsBinaryString(fileToParse);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const handleDownloadTemplate = async (format: 'csv' | 'xlsx') => {
|
|
173
|
+
try {
|
|
174
|
+
await api.downloadImportTemplate(format);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
toast.error('Failed to download template');
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const handleImport = async () => {
|
|
181
|
+
if (!file) return;
|
|
182
|
+
setIsImporting(true);
|
|
183
|
+
try {
|
|
184
|
+
const res = await api.bulkImportMedicines(file);
|
|
185
|
+
setImportResults({
|
|
186
|
+
success: res.successful || 0,
|
|
187
|
+
errors: res.errors || []
|
|
188
|
+
});
|
|
189
|
+
setStep(3);
|
|
190
|
+
} catch (error: any) {
|
|
191
|
+
toast.error(error.message || 'Import failed');
|
|
192
|
+
} finally {
|
|
193
|
+
setIsImporting(false);
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const handleClose = () => {
|
|
198
|
+
onOpenChange(false);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const handleDone = () => {
|
|
202
|
+
onImportComplete();
|
|
203
|
+
handleClose();
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const validCount = parsedData.filter(r => r.isValid).length;
|
|
207
|
+
const errorCount = parsedData.length - validCount;
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
|
211
|
+
<Dialog.Portal>
|
|
212
|
+
<Dialog.Overlay className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
|
|
213
|
+
<Dialog.Content className="fixed left-[50%] top-[50%] z-50 w-full max-w-4xl translate-x-[-50%] translate-y-[-50%] rounded-xl bg-slate-900 border border-slate-800 shadow-2xl p-6 flex flex-col min-h-[500px] max-h-[90vh] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95">
|
|
214
|
+
|
|
215
|
+
{/* Header */}
|
|
216
|
+
<div className="flex items-center justify-between mb-6">
|
|
217
|
+
<Dialog.Title className="text-xl font-semibold text-slate-100">
|
|
218
|
+
Bulk Import Medicines
|
|
219
|
+
</Dialog.Title>
|
|
220
|
+
<Dialog.Close asChild>
|
|
221
|
+
<button className="text-slate-400 hover:text-slate-100 hover:bg-slate-800 p-2 rounded-full transition-colors">
|
|
222
|
+
<X className="w-5 h-5" />
|
|
223
|
+
</button>
|
|
224
|
+
</Dialog.Close>
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
{/* Stepper */}
|
|
228
|
+
<div className="flex items-center justify-center mb-8">
|
|
229
|
+
<div className={`flex items-center ${step >= 1 ? 'text-blue-500' : 'text-slate-500'}`}>
|
|
230
|
+
<div className={`w-8 h-8 rounded-full flex items-center justify-center border-2 ${step >= 1 ? 'border-blue-500 bg-blue-500/20' : 'border-slate-600'}`}>
|
|
231
|
+
1
|
|
232
|
+
</div>
|
|
233
|
+
<span className="ml-2 font-medium">Upload</span>
|
|
234
|
+
</div>
|
|
235
|
+
<div className={`w-16 h-px mx-4 ${step >= 2 ? 'bg-blue-500' : 'bg-slate-700'}`} />
|
|
236
|
+
<div className={`flex items-center ${step >= 2 ? 'text-blue-500' : 'text-slate-500'}`}>
|
|
237
|
+
<div className={`w-8 h-8 rounded-full flex items-center justify-center border-2 ${step >= 2 ? 'border-blue-500 bg-blue-500/20' : 'border-slate-600'}`}>
|
|
238
|
+
2
|
|
239
|
+
</div>
|
|
240
|
+
<span className="ml-2 font-medium">Preview</span>
|
|
241
|
+
</div>
|
|
242
|
+
<div className={`w-16 h-px mx-4 ${step >= 3 ? 'bg-blue-500' : 'bg-slate-700'}`} />
|
|
243
|
+
<div className={`flex items-center ${step >= 3 ? 'text-blue-500' : 'text-slate-500'}`}>
|
|
244
|
+
<div className={`w-8 h-8 rounded-full flex items-center justify-center border-2 ${step >= 3 ? 'border-blue-500 bg-blue-500/20' : 'border-slate-600'}`}>
|
|
245
|
+
3
|
|
246
|
+
</div>
|
|
247
|
+
<span className="ml-2 font-medium">Results</span>
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
|
|
251
|
+
{/* Content Area */}
|
|
252
|
+
<div className="flex-1 overflow-hidden min-h-0 flex flex-col">
|
|
253
|
+
{step === 1 && (
|
|
254
|
+
<div className="h-full flex flex-col items-center justify-center space-y-6">
|
|
255
|
+
|
|
256
|
+
<div className="flex space-x-4 mb-4">
|
|
257
|
+
<Button variant="outline" size="sm" onClick={() => handleDownloadTemplate('csv')} className="border-slate-700 text-slate-300 hover:bg-slate-800">
|
|
258
|
+
<FileText className="w-4 h-4 mr-2" /> Download CSV Template
|
|
259
|
+
</Button>
|
|
260
|
+
<Button variant="outline" size="sm" onClick={() => handleDownloadTemplate('xlsx')} className="border-slate-700 text-slate-300 hover:bg-slate-800">
|
|
261
|
+
<FileSpreadsheet className="w-4 h-4 mr-2" /> Download Excel Template
|
|
262
|
+
</Button>
|
|
263
|
+
</div>
|
|
264
|
+
|
|
265
|
+
<div
|
|
266
|
+
className={`w-full max-w-2xl p-12 border-2 border-dashed rounded-xl flex flex-col items-center justify-center transition-all duration-200 ${isDragging ? 'border-blue-500 bg-blue-500/10 scale-[1.02]' : 'border-slate-700 bg-slate-800/50 hover:bg-slate-800 hover:border-slate-600'}`}
|
|
267
|
+
onDragOver={handleDragOver}
|
|
268
|
+
onDragLeave={handleDragLeave}
|
|
269
|
+
onDrop={handleDrop}
|
|
270
|
+
>
|
|
271
|
+
<div className="w-16 h-16 bg-blue-500/20 text-blue-400 rounded-full flex items-center justify-center mb-4">
|
|
272
|
+
<Upload className="w-8 h-8" />
|
|
273
|
+
</div>
|
|
274
|
+
<h3 className="text-xl font-medium text-slate-200 mb-2">Drop your file here</h3>
|
|
275
|
+
<p className="text-slate-400 mb-6 text-center">Supports CSV and Excel (.xlsx, .xls) files</p>
|
|
276
|
+
|
|
277
|
+
<input
|
|
278
|
+
type="file"
|
|
279
|
+
ref={fileInputRef}
|
|
280
|
+
onChange={(e) => e.target.files && handleFileSelect(e.target.files[0])}
|
|
281
|
+
className="hidden"
|
|
282
|
+
accept=".csv,.xlsx,.xls,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel"
|
|
283
|
+
/>
|
|
284
|
+
<Button onClick={() => fileInputRef.current?.click()} className="bg-blue-600 hover:bg-blue-700 text-white">
|
|
285
|
+
Browse Files
|
|
286
|
+
</Button>
|
|
287
|
+
</div>
|
|
288
|
+
</div>
|
|
289
|
+
)}
|
|
290
|
+
|
|
291
|
+
{step === 2 && (
|
|
292
|
+
<div className="h-full flex flex-col min-h-0">
|
|
293
|
+
<div className="flex items-center justify-between mb-4">
|
|
294
|
+
<div className="flex items-center space-x-4">
|
|
295
|
+
<div className="flex items-center px-3 py-1.5 rounded-md bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
|
|
296
|
+
<CheckCircle2 className="w-4 h-4 mr-2" />
|
|
297
|
+
<span className="font-medium">{validCount} valid rows</span>
|
|
298
|
+
</div>
|
|
299
|
+
{errorCount > 0 && (
|
|
300
|
+
<div className="flex items-center px-3 py-1.5 rounded-md bg-red-500/10 text-red-400 border border-red-500/20">
|
|
301
|
+
<AlertCircle className="w-4 h-4 mr-2" />
|
|
302
|
+
<span className="font-medium">{errorCount} errors</span>
|
|
303
|
+
</div>
|
|
304
|
+
)}
|
|
305
|
+
</div>
|
|
306
|
+
<div className="flex items-center space-x-2">
|
|
307
|
+
<Button variant="outline" onClick={() => { setStep(1); setFile(null); setParsedData([]); }}>
|
|
308
|
+
Back
|
|
309
|
+
</Button>
|
|
310
|
+
<Button
|
|
311
|
+
onClick={handleImport}
|
|
312
|
+
disabled={validCount === 0 || isImporting}
|
|
313
|
+
className="bg-blue-600 hover:bg-blue-700 text-white"
|
|
314
|
+
>
|
|
315
|
+
{isImporting ? <RefreshCw className="w-4 h-4 mr-2 animate-spin" /> : <Upload className="w-4 h-4 mr-2" />}
|
|
316
|
+
Import {validCount} Medicines
|
|
317
|
+
</Button>
|
|
318
|
+
</div>
|
|
319
|
+
</div>
|
|
320
|
+
|
|
321
|
+
<div className="flex-1 overflow-auto rounded-lg border border-slate-700/50 bg-slate-900 shadow-inner">
|
|
322
|
+
<table className="w-full text-sm text-left">
|
|
323
|
+
<thead className="text-xs text-slate-400 uppercase bg-slate-800/80 sticky top-0 z-10 backdrop-blur-md">
|
|
324
|
+
<tr>
|
|
325
|
+
<th className="px-4 py-3 w-10"></th>
|
|
326
|
+
{headers.slice(0, 8).map(header => (
|
|
327
|
+
<th key={header} className="px-4 py-3 font-medium">{header}</th>
|
|
328
|
+
))}
|
|
329
|
+
{headers.length > 8 && <th className="px-4 py-3 font-medium">...</th>}
|
|
330
|
+
</tr>
|
|
331
|
+
</thead>
|
|
332
|
+
<tbody className="divide-y divide-slate-800/50">
|
|
333
|
+
{parsedData.map((row) => (
|
|
334
|
+
<tr key={row.index} className={`${row.isValid ? 'bg-emerald-500/5 hover:bg-emerald-500/10' : 'bg-red-500/5 hover:bg-red-500/10'} transition-colors`}>
|
|
335
|
+
<td className="px-4 py-3 text-center">
|
|
336
|
+
{row.isValid ? (
|
|
337
|
+
<CheckCircle2 className="w-4 h-4 text-emerald-500" />
|
|
338
|
+
) : (
|
|
339
|
+
<div className="relative group inline-block cursor-help">
|
|
340
|
+
<AlertCircle className="w-4 h-4 text-red-500" />
|
|
341
|
+
<div className="absolute left-1/2 -translate-x-1/2 bottom-full mb-2 hidden group-hover:block w-48 bg-red-950 text-red-200 text-xs rounded p-2 border border-red-900 shadow-lg z-20">
|
|
342
|
+
<ul className="list-disc pl-3">
|
|
343
|
+
{row.errors.map((e, i) => <li key={i}>{e}</li>)}
|
|
344
|
+
</ul>
|
|
345
|
+
</div>
|
|
346
|
+
</div>
|
|
347
|
+
)}
|
|
348
|
+
</td>
|
|
349
|
+
{headers.slice(0, 8).map(header => (
|
|
350
|
+
<td key={header} className="px-4 py-3 truncate max-w-[150px] text-slate-300">
|
|
351
|
+
{row.data[header]?.toString() || ''}
|
|
352
|
+
</td>
|
|
353
|
+
))}
|
|
354
|
+
{headers.length > 8 && <td className="px-4 py-3 text-slate-500">...</td>}
|
|
355
|
+
</tr>
|
|
356
|
+
))}
|
|
357
|
+
</tbody>
|
|
358
|
+
</table>
|
|
359
|
+
</div>
|
|
360
|
+
</div>
|
|
361
|
+
)}
|
|
362
|
+
|
|
363
|
+
{step === 3 && importResults && (
|
|
364
|
+
<div className="h-full flex flex-col items-center justify-center animate-in zoom-in-95 duration-500">
|
|
365
|
+
<div className="w-20 h-20 bg-emerald-500/20 rounded-full flex items-center justify-center mb-6">
|
|
366
|
+
<CheckCircle2 className="w-10 h-10 text-emerald-500" />
|
|
367
|
+
</div>
|
|
368
|
+
<h2 className="text-2xl font-bold text-slate-100 mb-2">Import Complete</h2>
|
|
369
|
+
<p className="text-slate-400 mb-8 text-center max-w-md">
|
|
370
|
+
Successfully imported <span className="font-bold text-emerald-400">{importResults.success}</span> medicines.
|
|
371
|
+
</p>
|
|
372
|
+
|
|
373
|
+
{importResults.errors && importResults.errors.length > 0 && (
|
|
374
|
+
<div className="w-full max-w-2xl bg-red-950/30 border border-red-900/50 rounded-lg p-4 mb-8 max-h-[200px] overflow-auto">
|
|
375
|
+
<h4 className="text-red-400 font-medium mb-2 flex items-center">
|
|
376
|
+
<AlertCircle className="w-4 h-4 mr-2" /> Failed Rows ({importResults.errors.length})
|
|
377
|
+
</h4>
|
|
378
|
+
<ul className="space-y-1 text-sm text-red-300/80">
|
|
379
|
+
{importResults.errors.map((err, i) => (
|
|
380
|
+
<li key={i} className="flex">
|
|
381
|
+
<span className="font-mono text-red-400 mr-2">Row {err.row || '?'}:</span>
|
|
382
|
+
<span>{err.error || err.message || 'Unknown error'}</span>
|
|
383
|
+
</li>
|
|
384
|
+
))}
|
|
385
|
+
</ul>
|
|
386
|
+
</div>
|
|
387
|
+
)}
|
|
388
|
+
|
|
389
|
+
<Button onClick={handleDone} size="lg" className="bg-blue-600 hover:bg-blue-700 text-white min-w-[200px]">
|
|
390
|
+
Done
|
|
391
|
+
</Button>
|
|
392
|
+
</div>
|
|
393
|
+
)}
|
|
394
|
+
</div>
|
|
395
|
+
|
|
396
|
+
</Dialog.Content>
|
|
397
|
+
</Dialog.Portal>
|
|
398
|
+
</Dialog.Root>
|
|
399
|
+
);
|
|
400
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { cva, type VariantProps } from 'class-variance-authority';
|
|
2
|
+
import { cn } from '@/lib/utils';
|
|
3
|
+
import { Loader2 } from 'lucide-react';
|
|
4
|
+
import { ButtonHTMLAttributes, forwardRef } from 'react';
|
|
5
|
+
|
|
6
|
+
const buttonVariants = cva(
|
|
7
|
+
'inline-flex items-center justify-center rounded-lg font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 disabled:opacity-50 disabled:pointer-events-none',
|
|
8
|
+
{
|
|
9
|
+
variants: {
|
|
10
|
+
variant: {
|
|
11
|
+
primary: 'bg-brand-500 text-white hover:bg-brand-600 shadow-sm',
|
|
12
|
+
secondary: 'bg-surface text-foreground border hover:bg-gray-100 dark:hover:bg-gray-800',
|
|
13
|
+
ghost: 'hover:bg-gray-100 dark:hover:bg-gray-800 text-foreground',
|
|
14
|
+
danger: 'bg-danger text-white hover:bg-red-600',
|
|
15
|
+
success: 'bg-success text-white hover:bg-green-600',
|
|
16
|
+
outline: 'border-2 border-brand-500 text-brand-600 hover:bg-brand-50 dark:text-brand-400 dark:hover:bg-brand-950',
|
|
17
|
+
},
|
|
18
|
+
size: {
|
|
19
|
+
xs: 'h-7 px-2 text-xs',
|
|
20
|
+
sm: 'h-9 px-3 text-sm',
|
|
21
|
+
md: 'h-10 px-4 py-2',
|
|
22
|
+
lg: 'h-12 px-8 text-lg',
|
|
23
|
+
xl: 'h-14 px-10 text-xl',
|
|
24
|
+
icon: 'h-10 w-10',
|
|
25
|
+
},
|
|
26
|
+
fullWidth: {
|
|
27
|
+
true: 'w-full',
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
defaultVariants: {
|
|
31
|
+
variant: 'primary',
|
|
32
|
+
size: 'md',
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
export interface ButtonProps
|
|
38
|
+
extends ButtonHTMLAttributes<HTMLButtonElement>,
|
|
39
|
+
VariantProps<typeof buttonVariants> {
|
|
40
|
+
loading?: boolean;
|
|
41
|
+
leadingIcon?: React.ReactNode;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
45
|
+
({ className, variant, size, fullWidth, loading, leadingIcon, children, ...props }, ref) => {
|
|
46
|
+
return (
|
|
47
|
+
<button
|
|
48
|
+
className={cn(buttonVariants({ variant, size, fullWidth, className }))}
|
|
49
|
+
ref={ref}
|
|
50
|
+
disabled={loading || props.disabled}
|
|
51
|
+
{...props}
|
|
52
|
+
>
|
|
53
|
+
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
54
|
+
{!loading && leadingIcon && <span className="mr-2">{leadingIcon}</span>}
|
|
55
|
+
{children}
|
|
56
|
+
</button>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
);
|
|
60
|
+
Button.displayName = 'Button';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { HTMLAttributes, forwardRef } from 'react';
|
|
2
|
+
import { cn } from '@/lib/utils';
|
|
3
|
+
|
|
4
|
+
export const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
|
5
|
+
({ className, ...props }, ref) => (
|
|
6
|
+
<div
|
|
7
|
+
ref={ref}
|
|
8
|
+
className={cn("rounded-xl border border-border bg-background shadow-sm", className)}
|
|
9
|
+
{...props}
|
|
10
|
+
/>
|
|
11
|
+
)
|
|
12
|
+
);
|
|
13
|
+
Card.displayName = "Card";
|