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,472 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState } from 'react';
|
|
4
|
+
import { X, Plus, Trash2, Edit2, Check, ToggleLeft, ToggleRight, TrendingUp, DollarSign, Smartphone, Banknote } from 'lucide-react';
|
|
5
|
+
import { usePaymentMethodsStore } from '@/store/payment-methods-store';
|
|
6
|
+
import type { PaymentMethod } from '@/store/payment-methods-store';
|
|
7
|
+
import { useAppStore } from '@/store/app-store';
|
|
8
|
+
import { formatMoney } from '@/lib/utils';
|
|
9
|
+
|
|
10
|
+
interface PaymentSettingsModalProps {
|
|
11
|
+
open: boolean;
|
|
12
|
+
onClose: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const EMOJI_OPTIONS = ['🏦', '📱', '💳', '💰', '🏧', '📲', '🪙', '💸', '🔄', '🛒'];
|
|
16
|
+
const COLOR_OPTIONS = ['#3B82F6', '#F59E0B', '#8B5CF6', '#EF4444', '#10B981', '#EC4899', '#06B6D4', '#F97316'];
|
|
17
|
+
|
|
18
|
+
export default function PaymentSettingsModal({ open, onClose }: PaymentSettingsModalProps) {
|
|
19
|
+
const { currency } = useAppStore();
|
|
20
|
+
const { methods, addMethod, updateMethod, deleteMethod, toggleMethod, completedSales, getSalesTotals, clearSalesHistory, requireCashSession, setRequireCashSession } = usePaymentMethodsStore();
|
|
21
|
+
|
|
22
|
+
const [activeTab, setActiveTab] = useState<'methods' | 'summary'>('methods');
|
|
23
|
+
const [isAdding, setIsAdding] = useState(false);
|
|
24
|
+
const [editingId, setEditingId] = useState<string | null>(null);
|
|
25
|
+
|
|
26
|
+
// Add form state
|
|
27
|
+
const [newName, setNewName] = useState('');
|
|
28
|
+
const [newLogo, setNewLogo] = useState('🏦');
|
|
29
|
+
const [newColor, setNewColor] = useState('#3B82F6');
|
|
30
|
+
|
|
31
|
+
// Edit form state
|
|
32
|
+
const [editName, setEditName] = useState('');
|
|
33
|
+
const [editLogo, setEditLogo] = useState('');
|
|
34
|
+
const [editColor, setEditColor] = useState('');
|
|
35
|
+
|
|
36
|
+
const mobileMethods = methods.filter((m) => m.type === 'mobile');
|
|
37
|
+
const totals = getSalesTotals();
|
|
38
|
+
|
|
39
|
+
const handleAdd = () => {
|
|
40
|
+
if (!newName.trim()) return;
|
|
41
|
+
addMethod({
|
|
42
|
+
name: newName.trim(),
|
|
43
|
+
type: 'mobile',
|
|
44
|
+
logo: newLogo,
|
|
45
|
+
enabled: true,
|
|
46
|
+
color: newColor,
|
|
47
|
+
});
|
|
48
|
+
setNewName('');
|
|
49
|
+
setNewLogo('🏦');
|
|
50
|
+
setNewColor('#3B82F6');
|
|
51
|
+
setIsAdding(false);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const startEdit = (method: PaymentMethod) => {
|
|
55
|
+
setEditingId(method.id);
|
|
56
|
+
setEditName(method.name);
|
|
57
|
+
setEditLogo(method.logo);
|
|
58
|
+
setEditColor(method.color);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const handleSaveEdit = () => {
|
|
62
|
+
if (!editingId || !editName.trim()) return;
|
|
63
|
+
updateMethod(editingId, {
|
|
64
|
+
name: editName.trim(),
|
|
65
|
+
logo: editLogo,
|
|
66
|
+
color: editColor,
|
|
67
|
+
});
|
|
68
|
+
setEditingId(null);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const handleDelete = (id: string) => {
|
|
72
|
+
if (id === 'cash') return; // Can't delete cash
|
|
73
|
+
deleteMethod(id);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
if (!open) return null;
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[60] flex items-center justify-center p-4">
|
|
80
|
+
<div className="bg-background rounded-[16px] max-w-[640px] w-[95%] max-h-[85vh] shadow-[0_25px_50px_rgba(0,0,0,0.25)] flex flex-col overflow-hidden">
|
|
81
|
+
{/* Header */}
|
|
82
|
+
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
|
83
|
+
<h2 className="text-xl font-bold text-foreground">Payment Settings</h2>
|
|
84
|
+
<button onClick={onClose} className="p-2 hover:bg-surface rounded-full text-foreground/50 transition-colors">
|
|
85
|
+
<X size={22} />
|
|
86
|
+
</button>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
{/* Tabs */}
|
|
90
|
+
<div className="flex border-b border-border">
|
|
91
|
+
<button
|
|
92
|
+
onClick={() => setActiveTab('methods')}
|
|
93
|
+
className={`flex-1 py-3 text-sm font-medium transition-colors ${
|
|
94
|
+
activeTab === 'methods'
|
|
95
|
+
? 'text-pos-500 border-b-2 border-pos-500'
|
|
96
|
+
: 'text-foreground/50 hover:text-foreground/70'
|
|
97
|
+
}`}
|
|
98
|
+
>
|
|
99
|
+
Payment Methods
|
|
100
|
+
</button>
|
|
101
|
+
<button
|
|
102
|
+
onClick={() => setActiveTab('summary')}
|
|
103
|
+
className={`flex-1 py-3 text-sm font-medium transition-colors relative ${
|
|
104
|
+
activeTab === 'summary'
|
|
105
|
+
? 'text-pos-500 border-b-2 border-pos-500'
|
|
106
|
+
: 'text-foreground/50 hover:text-foreground/70'
|
|
107
|
+
}`}
|
|
108
|
+
>
|
|
109
|
+
Sales Summary
|
|
110
|
+
{completedSales.length > 0 && (
|
|
111
|
+
<span className="ml-1.5 bg-pos-500 text-white text-[10px] font-bold rounded-full px-1.5 py-0.5">
|
|
112
|
+
{completedSales.length}
|
|
113
|
+
</span>
|
|
114
|
+
)}
|
|
115
|
+
</button>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
{/* Content */}
|
|
119
|
+
<div className="flex-1 overflow-y-auto p-6">
|
|
120
|
+
{/* ===== METHODS TAB ===== */}
|
|
121
|
+
{activeTab === 'methods' && (
|
|
122
|
+
<div>
|
|
123
|
+
{/* Cash - always present, not editable */}
|
|
124
|
+
<div className="text-[11px] font-semibold text-foreground/40 uppercase tracking-wide mb-2">Cash Payment</div>
|
|
125
|
+
<div className="flex items-center justify-between bg-surface rounded-xl p-4 mb-6 border border-border">
|
|
126
|
+
<div className="flex items-center gap-3">
|
|
127
|
+
<div className="w-10 h-10 rounded-full bg-green-100 dark:bg-green-500/20 flex items-center justify-center text-xl">
|
|
128
|
+
💵
|
|
129
|
+
</div>
|
|
130
|
+
<div>
|
|
131
|
+
<div className="font-semibold text-foreground">Cash</div>
|
|
132
|
+
<div className="text-[11px] text-foreground/40">Default payment method</div>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
<span className="text-xs text-green-500 font-medium bg-green-100 dark:bg-green-500/20 px-2 py-1 rounded-full">
|
|
136
|
+
Always On
|
|
137
|
+
</span>
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
{/* Mobile Bank Methods */}
|
|
141
|
+
<div className="flex items-center justify-between mb-3">
|
|
142
|
+
<div className="text-[11px] font-semibold text-foreground/40 uppercase tracking-wide">Mobile Bank Methods</div>
|
|
143
|
+
<button
|
|
144
|
+
onClick={() => setIsAdding(true)}
|
|
145
|
+
className="flex items-center gap-1 text-xs font-medium text-pos-500 hover:text-pos-600 transition-colors"
|
|
146
|
+
>
|
|
147
|
+
<Plus size={14} /> Add New
|
|
148
|
+
</button>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<div className="space-y-2">
|
|
152
|
+
{mobileMethods.map((method) => (
|
|
153
|
+
<div key={method.id} className="bg-surface rounded-xl p-3 border border-border">
|
|
154
|
+
{editingId === method.id ? (
|
|
155
|
+
/* Edit mode */
|
|
156
|
+
<div className="space-y-3">
|
|
157
|
+
<div className="flex gap-2">
|
|
158
|
+
<input
|
|
159
|
+
type="text"
|
|
160
|
+
value={editName}
|
|
161
|
+
onChange={(e) => setEditName(e.target.value)}
|
|
162
|
+
className="flex-1 bg-background border border-border rounded-lg px-3 py-2 text-sm text-foreground focus:outline-none focus:border-pos-400"
|
|
163
|
+
placeholder="Method name"
|
|
164
|
+
/>
|
|
165
|
+
<button onClick={handleSaveEdit} className="bg-pos-500 text-white px-3 py-2 rounded-lg hover:bg-pos-600 transition-colors">
|
|
166
|
+
<Check size={16} />
|
|
167
|
+
</button>
|
|
168
|
+
<button onClick={() => setEditingId(null)} className="bg-surface border border-border text-foreground/50 px-3 py-2 rounded-lg hover:bg-border transition-colors">
|
|
169
|
+
<X size={16} />
|
|
170
|
+
</button>
|
|
171
|
+
</div>
|
|
172
|
+
{/* Emoji picker */}
|
|
173
|
+
<div>
|
|
174
|
+
<div className="text-[10px] text-foreground/40 mb-1">Icon</div>
|
|
175
|
+
<div className="flex gap-1.5 flex-wrap">
|
|
176
|
+
{EMOJI_OPTIONS.map((emoji) => (
|
|
177
|
+
<button
|
|
178
|
+
key={emoji}
|
|
179
|
+
onClick={() => setEditLogo(emoji)}
|
|
180
|
+
className={`w-9 h-9 rounded-lg flex items-center justify-center text-lg transition-all ${
|
|
181
|
+
editLogo === emoji ? 'bg-pos-100 dark:bg-pos-500/20 border-2 border-pos-400 scale-110' : 'bg-background border border-border hover:bg-surface'
|
|
182
|
+
}`}
|
|
183
|
+
>
|
|
184
|
+
{emoji}
|
|
185
|
+
</button>
|
|
186
|
+
))}
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
{/* Color picker */}
|
|
190
|
+
<div>
|
|
191
|
+
<div className="text-[10px] text-foreground/40 mb-1">Color</div>
|
|
192
|
+
<div className="flex gap-1.5 flex-wrap">
|
|
193
|
+
{COLOR_OPTIONS.map((color) => (
|
|
194
|
+
<button
|
|
195
|
+
key={color}
|
|
196
|
+
onClick={() => setEditColor(color)}
|
|
197
|
+
className={`w-7 h-7 rounded-full transition-all ${
|
|
198
|
+
editColor === color ? 'ring-2 ring-pos-500 ring-offset-2 ring-offset-background scale-110' : 'hover:scale-105'
|
|
199
|
+
}`}
|
|
200
|
+
style={{ backgroundColor: color }}
|
|
201
|
+
/>
|
|
202
|
+
))}
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
) : (
|
|
207
|
+
/* Display mode */
|
|
208
|
+
<div className="flex items-center justify-between">
|
|
209
|
+
<div className="flex items-center gap-3">
|
|
210
|
+
<div
|
|
211
|
+
className="w-10 h-10 rounded-full flex items-center justify-center text-xl"
|
|
212
|
+
style={{ backgroundColor: method.color + '20' }}
|
|
213
|
+
>
|
|
214
|
+
{method.logo}
|
|
215
|
+
</div>
|
|
216
|
+
<div>
|
|
217
|
+
<div className={`font-semibold ${method.enabled ? 'text-foreground' : 'text-foreground/40 line-through'}`}>{method.name}</div>
|
|
218
|
+
<div className="text-[11px] text-foreground/40">Mobile Bank</div>
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
<div className="flex items-center gap-2">
|
|
222
|
+
<button
|
|
223
|
+
onClick={() => toggleMethod(method.id)}
|
|
224
|
+
className="text-foreground/40 hover:text-foreground/70 transition-colors"
|
|
225
|
+
title={method.enabled ? 'Disable' : 'Enable'}
|
|
226
|
+
>
|
|
227
|
+
{method.enabled ? (
|
|
228
|
+
<ToggleRight size={24} className="text-pos-500" />
|
|
229
|
+
) : (
|
|
230
|
+
<ToggleLeft size={24} />
|
|
231
|
+
)}
|
|
232
|
+
</button>
|
|
233
|
+
<button
|
|
234
|
+
onClick={() => startEdit(method)}
|
|
235
|
+
className="p-1.5 hover:bg-background rounded-lg text-foreground/40 hover:text-foreground/70 transition-colors"
|
|
236
|
+
>
|
|
237
|
+
<Edit2 size={14} />
|
|
238
|
+
</button>
|
|
239
|
+
<button
|
|
240
|
+
onClick={() => handleDelete(method.id)}
|
|
241
|
+
className="p-1.5 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg text-foreground/40 hover:text-red-500 transition-colors"
|
|
242
|
+
>
|
|
243
|
+
<Trash2 size={14} />
|
|
244
|
+
</button>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
)}
|
|
248
|
+
</div>
|
|
249
|
+
))}
|
|
250
|
+
|
|
251
|
+
{mobileMethods.length === 0 && !isAdding && (
|
|
252
|
+
<div className="text-center py-6 text-foreground/30">
|
|
253
|
+
<Smartphone size={24} className="mx-auto mb-2 opacity-50" />
|
|
254
|
+
<p className="text-sm">No mobile bank methods</p>
|
|
255
|
+
</div>
|
|
256
|
+
)}
|
|
257
|
+
</div>
|
|
258
|
+
|
|
259
|
+
{/* Add New Form */}
|
|
260
|
+
{isAdding && (
|
|
261
|
+
<div className="mt-3 bg-surface rounded-xl p-4 border-2 border-dashed border-pos-300">
|
|
262
|
+
<div className="text-sm font-semibold text-foreground mb-3">New Mobile Bank</div>
|
|
263
|
+
<div className="space-y-3">
|
|
264
|
+
<input
|
|
265
|
+
type="text"
|
|
266
|
+
value={newName}
|
|
267
|
+
onChange={(e) => setNewName(e.target.value)}
|
|
268
|
+
className="w-full bg-background border border-border rounded-lg px-3 py-2 text-sm text-foreground placeholder:text-foreground/30 focus:outline-none focus:border-pos-400"
|
|
269
|
+
placeholder="e.g. MTN Mobile Money"
|
|
270
|
+
autoFocus
|
|
271
|
+
/>
|
|
272
|
+
{/* Emoji picker */}
|
|
273
|
+
<div>
|
|
274
|
+
<div className="text-[10px] text-foreground/40 mb-1">Icon</div>
|
|
275
|
+
<div className="flex gap-1.5 flex-wrap">
|
|
276
|
+
{EMOJI_OPTIONS.map((emoji) => (
|
|
277
|
+
<button
|
|
278
|
+
key={emoji}
|
|
279
|
+
onClick={() => setNewLogo(emoji)}
|
|
280
|
+
className={`w-9 h-9 rounded-lg flex items-center justify-center text-lg transition-all ${
|
|
281
|
+
newLogo === emoji ? 'bg-pos-100 dark:bg-pos-500/20 border-2 border-pos-400 scale-110' : 'bg-background border border-border hover:bg-surface'
|
|
282
|
+
}`}
|
|
283
|
+
>
|
|
284
|
+
{emoji}
|
|
285
|
+
</button>
|
|
286
|
+
))}
|
|
287
|
+
</div>
|
|
288
|
+
</div>
|
|
289
|
+
{/* Color picker */}
|
|
290
|
+
<div>
|
|
291
|
+
<div className="text-[10px] text-foreground/40 mb-1">Color</div>
|
|
292
|
+
<div className="flex gap-1.5 flex-wrap">
|
|
293
|
+
{COLOR_OPTIONS.map((color) => (
|
|
294
|
+
<button
|
|
295
|
+
key={color}
|
|
296
|
+
onClick={() => setNewColor(color)}
|
|
297
|
+
className={`w-7 h-7 rounded-full transition-all ${
|
|
298
|
+
newColor === color ? 'ring-2 ring-pos-500 ring-offset-2 ring-offset-background scale-110' : 'hover:scale-105'
|
|
299
|
+
}`}
|
|
300
|
+
style={{ backgroundColor: color }}
|
|
301
|
+
/>
|
|
302
|
+
))}
|
|
303
|
+
</div>
|
|
304
|
+
</div>
|
|
305
|
+
<div className="flex gap-2 pt-1">
|
|
306
|
+
<button
|
|
307
|
+
onClick={handleAdd}
|
|
308
|
+
disabled={!newName.trim()}
|
|
309
|
+
className="flex-1 bg-pos-500 text-white py-2 rounded-lg text-sm font-semibold hover:bg-pos-600 transition-colors disabled:opacity-40"
|
|
310
|
+
>
|
|
311
|
+
Add Method
|
|
312
|
+
</button>
|
|
313
|
+
<button
|
|
314
|
+
onClick={() => setIsAdding(false)}
|
|
315
|
+
className="bg-surface border border-border text-foreground/60 py-2 px-4 rounded-lg text-sm hover:bg-border transition-colors"
|
|
316
|
+
>
|
|
317
|
+
Cancel
|
|
318
|
+
</button>
|
|
319
|
+
</div>
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
322
|
+
)}
|
|
323
|
+
|
|
324
|
+
{/* Register Shift Setting */}
|
|
325
|
+
<div className="mt-6 pt-4 border-t border-border">
|
|
326
|
+
<div className="text-[11px] font-semibold text-foreground/40 uppercase tracking-wide mb-2">Shift & Register Controls</div>
|
|
327
|
+
<div className="flex items-center justify-between bg-surface rounded-xl p-4 border border-border">
|
|
328
|
+
<div>
|
|
329
|
+
<div className="font-semibold text-foreground text-sm">Require Cash Register Shifts</div>
|
|
330
|
+
<div className="text-xs text-foreground/50 mt-0.5">Prompt cashier to open with float and close with count</div>
|
|
331
|
+
</div>
|
|
332
|
+
<button
|
|
333
|
+
onClick={() => setRequireCashSession(!requireCashSession)}
|
|
334
|
+
className="text-foreground/40 hover:text-foreground/70 transition-colors ml-4"
|
|
335
|
+
title={requireCashSession ? 'Enabled' : 'Disabled'}
|
|
336
|
+
>
|
|
337
|
+
{requireCashSession ? (
|
|
338
|
+
<ToggleRight size={28} className="text-pos-500" />
|
|
339
|
+
) : (
|
|
340
|
+
<ToggleLeft size={28} />
|
|
341
|
+
)}
|
|
342
|
+
</button>
|
|
343
|
+
</div>
|
|
344
|
+
</div>
|
|
345
|
+
</div>
|
|
346
|
+
)}
|
|
347
|
+
|
|
348
|
+
{/* ===== SALES SUMMARY TAB ===== */}
|
|
349
|
+
{activeTab === 'summary' && (
|
|
350
|
+
<div>
|
|
351
|
+
{/* Overview Cards */}
|
|
352
|
+
<div className="grid grid-cols-3 gap-3 mb-6">
|
|
353
|
+
<div className="bg-surface rounded-xl p-4 border border-border text-center">
|
|
354
|
+
<div className="text-[10px] font-semibold text-foreground/40 uppercase mb-1">Total Sales</div>
|
|
355
|
+
<div className="text-xl font-black text-foreground">{totals.totalCount}</div>
|
|
356
|
+
<div className="text-sm font-semibold text-pos-500 mt-1">{formatMoney(totals.grandTotal, currency)}</div>
|
|
357
|
+
</div>
|
|
358
|
+
<div className="bg-green-50 dark:bg-green-500/10 rounded-xl p-4 border border-green-200 dark:border-green-500/20 text-center">
|
|
359
|
+
<div className="text-[10px] font-semibold text-green-600 dark:text-green-400 uppercase mb-1">Cash</div>
|
|
360
|
+
<div className="text-xl font-black text-green-700 dark:text-green-300">{totals.byMethod['cash']?.count || 0}</div>
|
|
361
|
+
<div className="text-sm font-semibold text-green-600 dark:text-green-400 mt-1">{formatMoney(totals.cash, currency)}</div>
|
|
362
|
+
</div>
|
|
363
|
+
<div className="bg-blue-50 dark:bg-blue-500/10 rounded-xl p-4 border border-blue-200 dark:border-blue-500/20 text-center">
|
|
364
|
+
<div className="text-[10px] font-semibold text-blue-600 dark:text-blue-400 uppercase mb-1">Mobile</div>
|
|
365
|
+
<div className="text-xl font-black text-blue-700 dark:text-blue-300">{Object.values(totals.byMethod).filter((_, i) => i > 0).reduce((sum, m) => sum + m.count, 0) || (totals.totalCount - (totals.byMethod['cash']?.count || 0))}</div>
|
|
366
|
+
<div className="text-sm font-semibold text-blue-600 dark:text-blue-400 mt-1">{formatMoney(totals.mobile, currency)}</div>
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
|
|
370
|
+
{/* Per-method breakdown */}
|
|
371
|
+
<div className="text-[11px] font-semibold text-foreground/40 uppercase tracking-wide mb-3">Breakdown by Method</div>
|
|
372
|
+
<div className="space-y-2 mb-6">
|
|
373
|
+
{Object.entries(totals.byMethod).map(([methodId, data]) => {
|
|
374
|
+
const method = methods.find((m) => m.id === methodId);
|
|
375
|
+
const percentage = totals.grandTotal > 0 ? (data.total / totals.grandTotal) * 100 : 0;
|
|
376
|
+
return (
|
|
377
|
+
<div key={methodId} className="bg-surface rounded-xl p-3 border border-border">
|
|
378
|
+
<div className="flex items-center justify-between mb-2">
|
|
379
|
+
<div className="flex items-center gap-2">
|
|
380
|
+
<div
|
|
381
|
+
className="w-8 h-8 rounded-full flex items-center justify-center text-lg"
|
|
382
|
+
style={{ backgroundColor: (method?.color || '#6B7280') + '20' }}
|
|
383
|
+
>
|
|
384
|
+
{method?.logo || '💳'}
|
|
385
|
+
</div>
|
|
386
|
+
<div>
|
|
387
|
+
<div className="text-sm font-semibold text-foreground">{data.name}</div>
|
|
388
|
+
<div className="text-[10px] text-foreground/40">{data.count} sales</div>
|
|
389
|
+
</div>
|
|
390
|
+
</div>
|
|
391
|
+
<div className="text-right">
|
|
392
|
+
<div className="text-sm font-bold text-foreground">{formatMoney(data.total, currency)}</div>
|
|
393
|
+
<div className="text-[10px] text-foreground/40">{percentage.toFixed(1)}%</div>
|
|
394
|
+
</div>
|
|
395
|
+
</div>
|
|
396
|
+
{/* Progress bar */}
|
|
397
|
+
<div className="w-full h-1.5 bg-border rounded-full overflow-hidden">
|
|
398
|
+
<div
|
|
399
|
+
className="h-full rounded-full transition-all duration-500"
|
|
400
|
+
style={{
|
|
401
|
+
width: `${percentage}%`,
|
|
402
|
+
backgroundColor: method?.color || '#6B7280',
|
|
403
|
+
}}
|
|
404
|
+
/>
|
|
405
|
+
</div>
|
|
406
|
+
</div>
|
|
407
|
+
);
|
|
408
|
+
})}
|
|
409
|
+
|
|
410
|
+
{Object.keys(totals.byMethod).length === 0 && (
|
|
411
|
+
<div className="text-center py-8 text-foreground/30">
|
|
412
|
+
<TrendingUp size={28} className="mx-auto mb-2 opacity-50" />
|
|
413
|
+
<p className="text-sm">No sales recorded yet</p>
|
|
414
|
+
<p className="text-[11px] mt-1">Complete a sale to see the breakdown</p>
|
|
415
|
+
</div>
|
|
416
|
+
)}
|
|
417
|
+
</div>
|
|
418
|
+
|
|
419
|
+
{/* Recent sales */}
|
|
420
|
+
{completedSales.length > 0 && (
|
|
421
|
+
<>
|
|
422
|
+
<div className="flex items-center justify-between mb-3">
|
|
423
|
+
<div className="text-[11px] font-semibold text-foreground/40 uppercase tracking-wide">
|
|
424
|
+
Recent Sales ({completedSales.length})
|
|
425
|
+
</div>
|
|
426
|
+
<button
|
|
427
|
+
onClick={() => {
|
|
428
|
+
if (confirm('Clear all sales history?')) clearSalesHistory();
|
|
429
|
+
}}
|
|
430
|
+
className="text-[11px] text-red-500 hover:text-red-600 font-medium"
|
|
431
|
+
>
|
|
432
|
+
Clear History
|
|
433
|
+
</button>
|
|
434
|
+
</div>
|
|
435
|
+
<div className="space-y-1.5 max-h-[200px] overflow-y-auto">
|
|
436
|
+
{[...completedSales].reverse().slice(0, 20).map((sale) => {
|
|
437
|
+
const method = methods.find((m) => m.id === sale.paymentMethodId);
|
|
438
|
+
return (
|
|
439
|
+
<div key={sale.id} className="flex items-center justify-between bg-surface rounded-lg p-2.5 border border-border">
|
|
440
|
+
<div className="flex items-center gap-2 flex-1 min-w-0">
|
|
441
|
+
<div
|
|
442
|
+
className="w-7 h-7 rounded-full flex items-center justify-center text-sm shrink-0"
|
|
443
|
+
style={{ backgroundColor: (method?.color || '#6B7280') + '20' }}
|
|
444
|
+
>
|
|
445
|
+
{method?.logo || '💳'}
|
|
446
|
+
</div>
|
|
447
|
+
<div className="min-w-0">
|
|
448
|
+
<div className="text-xs font-medium text-foreground truncate">
|
|
449
|
+
{sale.items.length} item{sale.items.length > 1 ? 's' : ''}
|
|
450
|
+
{sale.customerName && ` • ${sale.customerName}`}
|
|
451
|
+
</div>
|
|
452
|
+
<div className="text-[10px] text-foreground/40">
|
|
453
|
+
{sale.paymentMethodName} • {new Date(sale.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
|
454
|
+
</div>
|
|
455
|
+
</div>
|
|
456
|
+
</div>
|
|
457
|
+
<span className="text-sm font-bold text-foreground shrink-0 ml-2">
|
|
458
|
+
{formatMoney(sale.total, currency)}
|
|
459
|
+
</span>
|
|
460
|
+
</div>
|
|
461
|
+
);
|
|
462
|
+
})}
|
|
463
|
+
</div>
|
|
464
|
+
</>
|
|
465
|
+
)}
|
|
466
|
+
</div>
|
|
467
|
+
)}
|
|
468
|
+
</div>
|
|
469
|
+
</div>
|
|
470
|
+
</div>
|
|
471
|
+
);
|
|
472
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useMemo } from 'react';
|
|
4
|
+
import { Star, MoreHorizontal } from 'lucide-react';
|
|
5
|
+
import { useAppStore } from '@/store/app-store';
|
|
6
|
+
import { formatMoney } from '@/lib/utils';
|
|
7
|
+
|
|
8
|
+
export interface Product {
|
|
9
|
+
id: string;
|
|
10
|
+
medicine_id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
category: string;
|
|
13
|
+
price: number;
|
|
14
|
+
stock: number;
|
|
15
|
+
batch: string;
|
|
16
|
+
lot_number: string;
|
|
17
|
+
expiry: string;
|
|
18
|
+
requires_prescription: boolean;
|
|
19
|
+
favorite: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ProductCatalogProps {
|
|
23
|
+
products: Product[];
|
|
24
|
+
categories: string[];
|
|
25
|
+
onProductClick: (product: Product) => void;
|
|
26
|
+
isLoading?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export default function ProductCatalog({ products, categories, onProductClick, isLoading }: ProductCatalogProps) {
|
|
30
|
+
const [activeCategory, setActiveCategory] = useState('All');
|
|
31
|
+
const { currency } = useAppStore();
|
|
32
|
+
|
|
33
|
+
const filteredProducts = useMemo(() => {
|
|
34
|
+
if (activeCategory === 'All') return products;
|
|
35
|
+
return products.filter((p) => p.category === activeCategory);
|
|
36
|
+
}, [products, activeCategory]);
|
|
37
|
+
|
|
38
|
+
if (isLoading) {
|
|
39
|
+
return (
|
|
40
|
+
<div className="flex-1 flex flex-col bg-gray-100 dark:bg-[#0B1121] overflow-hidden">
|
|
41
|
+
{/* Skeleton category bar */}
|
|
42
|
+
<div className="flex overflow-x-auto bg-white dark:bg-[#111A2C] border-b border-gray-200 dark:border-gray-800 p-2 gap-2">
|
|
43
|
+
{Array.from({ length: 6 }).map((_, i) => (
|
|
44
|
+
<div key={i} className="h-8 w-20 bg-gray-200 dark:bg-gray-700 rounded-md animate-pulse" />
|
|
45
|
+
))}
|
|
46
|
+
</div>
|
|
47
|
+
{/* Skeleton product grid */}
|
|
48
|
+
<div className="flex-1 overflow-y-auto p-2 md:p-4">
|
|
49
|
+
<div className="grid grid-cols-2 md:grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-2 md:gap-4">
|
|
50
|
+
{Array.from({ length: 12 }).map((_, i) => (
|
|
51
|
+
<div key={i} className="bg-white dark:bg-[#1A2438] rounded-xl p-4 min-h-[110px] flex flex-col justify-between animate-pulse border border-gray-200 dark:border-transparent">
|
|
52
|
+
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-3/4 mb-2" />
|
|
53
|
+
<div className="h-3 bg-gray-200 dark:bg-gray-600 rounded w-1/2 mb-4" />
|
|
54
|
+
<div className="flex justify-between">
|
|
55
|
+
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-1/3" />
|
|
56
|
+
<div className="h-3 w-3 bg-gray-200 dark:bg-gray-600 rounded-full" />
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
))}
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className="flex-1 flex flex-col bg-gray-100 dark:bg-[#0B1121] overflow-hidden">
|
|
68
|
+
{/* Category Bar */}
|
|
69
|
+
<div className="flex overflow-x-auto bg-white dark:bg-[#111A2C] border-b border-gray-200 dark:border-gray-800 p-0 m-0 scroll-smooth no-scrollbar">
|
|
70
|
+
{categories.map((cat) => (
|
|
71
|
+
<button
|
|
72
|
+
key={cat}
|
|
73
|
+
onClick={() => setActiveCategory(cat)}
|
|
74
|
+
className={`
|
|
75
|
+
px-6 py-3 text-sm whitespace-nowrap transition-colors border-none outline-none cursor-pointer border-b-2
|
|
76
|
+
${
|
|
77
|
+
activeCategory === cat
|
|
78
|
+
? 'bg-pos-500 text-white dark:bg-blue-500/10 dark:text-blue-400 font-semibold border-pos-500 dark:border-blue-500'
|
|
79
|
+
: 'bg-transparent text-gray-600 dark:text-gray-400 font-medium hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-white/5 border-transparent'
|
|
80
|
+
}
|
|
81
|
+
`}
|
|
82
|
+
>
|
|
83
|
+
{cat}
|
|
84
|
+
</button>
|
|
85
|
+
))}
|
|
86
|
+
{categories.length > 5 && (
|
|
87
|
+
<button className="px-4 py-3 border-l border-gray-200 dark:border-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5 hover:text-gray-700 dark:hover:text-gray-200">
|
|
88
|
+
<MoreHorizontal size={18} />
|
|
89
|
+
</button>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
{/* Product Grid */}
|
|
94
|
+
<div className="flex-1 overflow-y-auto p-2 md:p-4">
|
|
95
|
+
<div className="grid grid-cols-2 md:grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-2 md:gap-4">
|
|
96
|
+
{filteredProducts.map((product) => (
|
|
97
|
+
<div
|
|
98
|
+
key={product.id}
|
|
99
|
+
onClick={() => product.stock > 0 ? onProductClick(product) : null}
|
|
100
|
+
className={`bg-white dark:bg-[#1A2438] hover:bg-gray-50 dark:hover:bg-[#23304a] transition-all duration-200 rounded-xl p-4 min-h-[110px] flex flex-col justify-between border border-gray-200 dark:border-transparent hover:border-pos-500/50 dark:hover:border-blue-500/50 shadow-sm ${
|
|
101
|
+
product.stock === 0 ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'
|
|
102
|
+
}`}
|
|
103
|
+
>
|
|
104
|
+
<div className="flex justify-between items-start">
|
|
105
|
+
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 line-clamp-2 pr-2 leading-snug">
|
|
106
|
+
{product.name}
|
|
107
|
+
</span>
|
|
108
|
+
{product.requires_prescription && (
|
|
109
|
+
<span className="text-[10px] font-bold text-pos-600 bg-pos-100 dark:text-blue-400 dark:bg-blue-500/10 px-1.5 py-0.5 rounded flex-shrink-0">
|
|
110
|
+
Rx
|
|
111
|
+
</span>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<div className="mt-4 flex items-center justify-between w-full">
|
|
116
|
+
<span className="text-sm font-semibold text-pos-600 dark:text-blue-400">
|
|
117
|
+
{formatMoney(product.price, currency)}
|
|
118
|
+
</span>
|
|
119
|
+
{product.stock === 0 ? (
|
|
120
|
+
<span className="text-[10px] font-bold text-red-500 bg-red-100 dark:bg-red-500/20 px-2 py-0.5 rounded">
|
|
121
|
+
OUT OF STOCK
|
|
122
|
+
</span>
|
|
123
|
+
) : product.stock <= 10 ? (
|
|
124
|
+
<span className="text-[10px] font-medium text-orange-500">
|
|
125
|
+
Only {product.stock} left
|
|
126
|
+
</span>
|
|
127
|
+
) : (
|
|
128
|
+
<span className="text-[10px] text-foreground/40 flex items-center gap-1">
|
|
129
|
+
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 inline-block"></span>
|
|
130
|
+
{product.stock}
|
|
131
|
+
</span>
|
|
132
|
+
)}
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
))}
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|