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,255 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect } from 'react';
|
|
4
|
+
import { DollarSign, Store, Loader2, AlertCircle, RefreshCw, Calculator } from 'lucide-react';
|
|
5
|
+
import { toast } from 'react-hot-toast';
|
|
6
|
+
import { api } from '@/lib/api';
|
|
7
|
+
import { usePaymentMethodsStore } from '@/store/payment-methods-store';
|
|
8
|
+
|
|
9
|
+
interface SessionData {
|
|
10
|
+
id: string;
|
|
11
|
+
opening_amount: number;
|
|
12
|
+
opened_at: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface CashSessionGuardProps {
|
|
16
|
+
children: (session: SessionData) => React.ReactNode;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface Branch {
|
|
20
|
+
id: string;
|
|
21
|
+
name: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function CashSessionGuard({ children }: CashSessionGuardProps) {
|
|
25
|
+
const { requireCashSession } = usePaymentMethodsStore();
|
|
26
|
+
const [session, setSession] = useState<SessionData | null>(null);
|
|
27
|
+
const [loadingStatus, setLoadingStatus] = useState<boolean>(true);
|
|
28
|
+
const [hasError, setHasError] = useState<boolean>(false);
|
|
29
|
+
|
|
30
|
+
// Open Register state
|
|
31
|
+
const [openingAmount, setOpeningAmount] = useState<string>('0');
|
|
32
|
+
const [terminalName, setTerminalName] = useState<string>('POS Terminal 1');
|
|
33
|
+
const [branches, setBranches] = useState<Branch[]>([]);
|
|
34
|
+
const [selectedBranchId, setSelectedBranchId] = useState<string>('');
|
|
35
|
+
const [isOpening, setIsOpening] = useState<boolean>(false);
|
|
36
|
+
|
|
37
|
+
const fetchBranches = async () => {
|
|
38
|
+
try {
|
|
39
|
+
const branchesData: any = await api.getBranches();
|
|
40
|
+
const list = Array.isArray(branchesData) ? branchesData : [];
|
|
41
|
+
setBranches(list);
|
|
42
|
+
if (list.length > 0) {
|
|
43
|
+
setSelectedBranchId(list[0].id);
|
|
44
|
+
}
|
|
45
|
+
} catch (error) {
|
|
46
|
+
console.error('Failed to fetch branches', error);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const checkSession = async () => {
|
|
51
|
+
setLoadingStatus(true);
|
|
52
|
+
setHasError(false);
|
|
53
|
+
try {
|
|
54
|
+
const activeSession: any = await api.getActiveSession();
|
|
55
|
+
if (activeSession && activeSession.id) {
|
|
56
|
+
setSession({
|
|
57
|
+
id: activeSession.id,
|
|
58
|
+
opening_amount: Number(activeSession.opening_amount) || 0,
|
|
59
|
+
opened_at: activeSession.opened_at,
|
|
60
|
+
});
|
|
61
|
+
} else {
|
|
62
|
+
await fetchBranches();
|
|
63
|
+
}
|
|
64
|
+
} catch (err: any) {
|
|
65
|
+
// 404 or any "no active session" error is expected when register is closed
|
|
66
|
+
if (
|
|
67
|
+
err?.status === 404 ||
|
|
68
|
+
err?.response?.status === 404 ||
|
|
69
|
+
err?.message?.toLowerCase().includes('not found') ||
|
|
70
|
+
err?.message?.toLowerCase().includes('no active session') ||
|
|
71
|
+
err?.message?.includes('404')
|
|
72
|
+
) {
|
|
73
|
+
await fetchBranches();
|
|
74
|
+
} else {
|
|
75
|
+
console.error('Unexpected error checking cash session:', err);
|
|
76
|
+
// Only show hard error if cash session is strictly required
|
|
77
|
+
if (requireCashSession) {
|
|
78
|
+
setHasError(true);
|
|
79
|
+
} else {
|
|
80
|
+
await fetchBranches();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} finally {
|
|
84
|
+
setLoadingStatus(false);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
checkSession();
|
|
90
|
+
}, []);
|
|
91
|
+
|
|
92
|
+
const handleOpenRegister = async (e: React.FormEvent) => {
|
|
93
|
+
e.preventDefault();
|
|
94
|
+
|
|
95
|
+
const branchId = selectedBranchId || (branches.length > 0 ? branches[0].id : '');
|
|
96
|
+
if (!branchId) {
|
|
97
|
+
toast.error('Please select a branch');
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
setIsOpening(true);
|
|
102
|
+
try {
|
|
103
|
+
const amount = parseFloat(openingAmount) || 0;
|
|
104
|
+
if (isNaN(amount) || amount < 0) {
|
|
105
|
+
throw new Error('Invalid opening amount');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const newSession: any = await api.openSession({
|
|
109
|
+
opening_amount: amount,
|
|
110
|
+
terminal_name: terminalName.trim() || 'POS Terminal 1',
|
|
111
|
+
branch_id: branchId,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
toast.success('Cash register opened successfully');
|
|
115
|
+
setSession({
|
|
116
|
+
id: newSession.id,
|
|
117
|
+
opening_amount: Number(newSession.opening_amount) || amount,
|
|
118
|
+
opened_at: newSession.opened_at || new Date().toISOString(),
|
|
119
|
+
});
|
|
120
|
+
} catch (error: any) {
|
|
121
|
+
console.error('Failed to open register', error);
|
|
122
|
+
toast.error(error?.message || 'Failed to open cash register');
|
|
123
|
+
} finally {
|
|
124
|
+
setIsOpening(false);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// If cash session is NOT required by settings, bypass the guard screen and render POS directly!
|
|
129
|
+
if (!requireCashSession) {
|
|
130
|
+
return <>{children(session || { id: '', opening_amount: 0, opened_at: '' })}</>;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (loadingStatus) {
|
|
134
|
+
return (
|
|
135
|
+
<div className="flex flex-col items-center justify-center h-full min-h-[500px] w-full bg-background">
|
|
136
|
+
<Loader2 className="w-10 h-10 animate-spin text-pos-500 mb-4" />
|
|
137
|
+
<p className="text-foreground/70">Checking register status...</p>
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (hasError) {
|
|
143
|
+
return (
|
|
144
|
+
<div className="flex flex-col items-center justify-center h-full min-h-[500px] w-full bg-background p-4">
|
|
145
|
+
<AlertCircle className="w-12 h-12 text-red-500 mb-4" />
|
|
146
|
+
<h2 className="text-xl font-semibold text-foreground mb-2">Connection Error</h2>
|
|
147
|
+
<p className="text-foreground/70 mb-6 text-center max-w-md text-sm">
|
|
148
|
+
Unable to verify cash register status from the server.
|
|
149
|
+
</p>
|
|
150
|
+
<div className="flex gap-3">
|
|
151
|
+
<button
|
|
152
|
+
onClick={checkSession}
|
|
153
|
+
className="flex items-center gap-2 px-4 py-2 bg-pos-500 hover:bg-pos-600 text-white rounded-lg transition-colors text-sm font-medium"
|
|
154
|
+
>
|
|
155
|
+
<RefreshCw className="w-4 h-4" />
|
|
156
|
+
Retry
|
|
157
|
+
</button>
|
|
158
|
+
<button
|
|
159
|
+
onClick={() => {
|
|
160
|
+
setHasError(false);
|
|
161
|
+
fetchBranches();
|
|
162
|
+
}}
|
|
163
|
+
className="px-4 py-2 bg-surface border border-border hover:bg-background text-foreground rounded-lg transition-colors text-sm font-medium"
|
|
164
|
+
>
|
|
165
|
+
Open Register Manually
|
|
166
|
+
</button>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (session) {
|
|
173
|
+
return <>{children(session)}</>;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
|
178
|
+
<div className="bg-surface border border-border rounded-2xl shadow-2xl w-full max-w-md overflow-hidden">
|
|
179
|
+
<div className="p-6 bg-pos-500/10 border-b border-border text-center">
|
|
180
|
+
<div className="w-14 h-14 bg-pos-500 text-white rounded-2xl flex items-center justify-center mx-auto mb-3 shadow-lg shadow-pos-500/20">
|
|
181
|
+
<Calculator className="w-7 h-7" />
|
|
182
|
+
</div>
|
|
183
|
+
<h2 className="text-2xl font-bold text-foreground">Open Cash Register</h2>
|
|
184
|
+
<p className="text-sm text-foreground/60 mt-1">Start your shift by entering the opening float</p>
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
<form onSubmit={handleOpenRegister} className="p-6 space-y-4">
|
|
188
|
+
<div className="space-y-1.5">
|
|
189
|
+
<label className="text-xs font-semibold uppercase tracking-wider text-foreground/60 block">Branch</label>
|
|
190
|
+
<div className="relative">
|
|
191
|
+
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
192
|
+
<Store className="h-4 w-4 text-foreground/40" />
|
|
193
|
+
</div>
|
|
194
|
+
<select
|
|
195
|
+
value={selectedBranchId}
|
|
196
|
+
onChange={(e) => setSelectedBranchId(e.target.value)}
|
|
197
|
+
required
|
|
198
|
+
className="w-full pl-9 pr-4 py-2.5 bg-background border border-border rounded-xl text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-pos-500"
|
|
199
|
+
>
|
|
200
|
+
{branches.length === 0 ? (
|
|
201
|
+
<option value="" disabled>Loading branches...</option>
|
|
202
|
+
) : (
|
|
203
|
+
branches.map(b => (
|
|
204
|
+
<option key={b.id} value={b.id}>{b.name}</option>
|
|
205
|
+
))
|
|
206
|
+
)}
|
|
207
|
+
</select>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
<div className="space-y-1.5">
|
|
212
|
+
<label className="text-xs font-semibold uppercase tracking-wider text-foreground/60 block">Terminal Name</label>
|
|
213
|
+
<input
|
|
214
|
+
type="text"
|
|
215
|
+
value={terminalName}
|
|
216
|
+
onChange={(e) => setTerminalName(e.target.value)}
|
|
217
|
+
required
|
|
218
|
+
className="w-full px-3.5 py-2.5 bg-background border border-border rounded-xl text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-pos-500"
|
|
219
|
+
/>
|
|
220
|
+
</div>
|
|
221
|
+
|
|
222
|
+
<div className="space-y-1.5">
|
|
223
|
+
<label className="text-xs font-semibold uppercase tracking-wider text-foreground/60 block">Opening Cash Amount</label>
|
|
224
|
+
<div className="relative">
|
|
225
|
+
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
226
|
+
<DollarSign className="h-4 w-4 text-foreground/40" />
|
|
227
|
+
</div>
|
|
228
|
+
<input
|
|
229
|
+
type="number"
|
|
230
|
+
min="0"
|
|
231
|
+
step="0.01"
|
|
232
|
+
value={openingAmount}
|
|
233
|
+
onChange={(e) => setOpeningAmount(e.target.value)}
|
|
234
|
+
required
|
|
235
|
+
className="w-full pl-9 pr-4 py-2.5 bg-background border border-border rounded-xl text-foreground text-sm font-semibold focus:outline-none focus:ring-2 focus:ring-pos-500"
|
|
236
|
+
placeholder="0.00"
|
|
237
|
+
/>
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
<div className="pt-3">
|
|
242
|
+
<button
|
|
243
|
+
type="submit"
|
|
244
|
+
disabled={isOpening}
|
|
245
|
+
className="w-full flex items-center justify-center gap-2 py-3 px-4 bg-pos-500 hover:bg-pos-600 text-white rounded-xl font-bold transition-all shadow-lg shadow-pos-500/20 disabled:opacity-60"
|
|
246
|
+
>
|
|
247
|
+
{isOpening ? <Loader2 className="w-5 h-5 animate-spin" /> : null}
|
|
248
|
+
Open Register & Start Shift
|
|
249
|
+
</button>
|
|
250
|
+
</div>
|
|
251
|
+
</form>
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState } from 'react';
|
|
4
|
+
import { X, Loader2, TrendingUp, TrendingDown, Minus, DollarSign } from 'lucide-react';
|
|
5
|
+
import { toast } from 'react-hot-toast';
|
|
6
|
+
import { api } from '@/lib/api';
|
|
7
|
+
import { useAppStore } from '@/store/app-store';
|
|
8
|
+
import { formatMoney } from '@/lib/utils';
|
|
9
|
+
|
|
10
|
+
interface CloseSessionModalProps {
|
|
11
|
+
open: boolean;
|
|
12
|
+
onClose: () => void;
|
|
13
|
+
sessionId: string;
|
|
14
|
+
openingAmount: number;
|
|
15
|
+
onSessionClosed: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function CloseSessionModal({
|
|
19
|
+
open,
|
|
20
|
+
onClose,
|
|
21
|
+
sessionId,
|
|
22
|
+
openingAmount,
|
|
23
|
+
onSessionClosed,
|
|
24
|
+
}: CloseSessionModalProps) {
|
|
25
|
+
const [closingAmount, setClosingAmount] = useState<string>('');
|
|
26
|
+
const [notes, setNotes] = useState<string>('');
|
|
27
|
+
const [isClosing, setIsClosing] = useState<boolean>(false);
|
|
28
|
+
|
|
29
|
+
// Safe extraction to avoid issues if store doesn't have currency
|
|
30
|
+
const currency = useAppStore((state) => (state as any).currency || 'USD');
|
|
31
|
+
|
|
32
|
+
if (!open) return null;
|
|
33
|
+
|
|
34
|
+
const parsedClosing = parseFloat(closingAmount) || 0;
|
|
35
|
+
const variance = parsedClosing - openingAmount;
|
|
36
|
+
|
|
37
|
+
let varianceColor = 'text-gray-500';
|
|
38
|
+
let VarianceIcon = Minus;
|
|
39
|
+
let varianceBg = 'bg-gray-500/10 border-gray-500/20';
|
|
40
|
+
|
|
41
|
+
if (variance > 0) {
|
|
42
|
+
varianceColor = 'text-green-500';
|
|
43
|
+
VarianceIcon = TrendingUp;
|
|
44
|
+
varianceBg = 'bg-green-500/10 border-green-500/20';
|
|
45
|
+
} else if (variance < 0) {
|
|
46
|
+
varianceColor = 'text-red-500';
|
|
47
|
+
VarianceIcon = TrendingDown;
|
|
48
|
+
varianceBg = 'bg-red-500/10 border-red-500/20';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const handleClose = async (e: React.FormEvent) => {
|
|
52
|
+
e.preventDefault();
|
|
53
|
+
if (!closingAmount) {
|
|
54
|
+
toast.error('Closing amount is required');
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
setIsClosing(true);
|
|
59
|
+
try {
|
|
60
|
+
await api.closeSession(sessionId, {
|
|
61
|
+
closing_amount: parsedClosing,
|
|
62
|
+
notes: notes.trim() ? notes : undefined,
|
|
63
|
+
});
|
|
64
|
+
toast.success('Session closed successfully');
|
|
65
|
+
onSessionClosed();
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.error('Failed to close session', error);
|
|
68
|
+
toast.error('Failed to close register');
|
|
69
|
+
} finally {
|
|
70
|
+
setIsClosing(false);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
|
76
|
+
<div className="bg-surface border border-border rounded-xl shadow-xl w-full max-w-md overflow-hidden relative">
|
|
77
|
+
<button
|
|
78
|
+
onClick={onClose}
|
|
79
|
+
className="absolute top-4 right-4 text-foreground/50 hover:text-foreground p-1 rounded-full hover:bg-background transition-colors"
|
|
80
|
+
disabled={isClosing}
|
|
81
|
+
>
|
|
82
|
+
<X className="w-5 h-5" />
|
|
83
|
+
</button>
|
|
84
|
+
|
|
85
|
+
<div className="p-6 border-b border-border">
|
|
86
|
+
<h2 className="text-2xl font-bold text-foreground">Close Cash Register</h2>
|
|
87
|
+
<p className="text-sm text-foreground/70 mt-1">End your shift and close out the register</p>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<form onSubmit={handleClose} className="p-6 space-y-4">
|
|
91
|
+
<div className="flex justify-between items-center p-3 bg-background rounded-lg border border-border">
|
|
92
|
+
<span className="text-sm font-medium text-foreground/70">Opening Amount</span>
|
|
93
|
+
<span className="font-semibold text-foreground">{formatMoney(openingAmount, currency)}</span>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<div className="space-y-2">
|
|
97
|
+
<label className="text-sm font-medium text-foreground block">Closing Amount</label>
|
|
98
|
+
<div className="relative">
|
|
99
|
+
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
100
|
+
<DollarSign className="h-5 w-5 text-foreground/40" />
|
|
101
|
+
</div>
|
|
102
|
+
<input
|
|
103
|
+
type="number"
|
|
104
|
+
min="0"
|
|
105
|
+
step="0.01"
|
|
106
|
+
value={closingAmount}
|
|
107
|
+
onChange={(e) => setClosingAmount(e.target.value)}
|
|
108
|
+
required
|
|
109
|
+
className="w-full pl-10 pr-4 py-2 bg-background border border-border rounded-md text-foreground focus:outline-none focus:ring-2 focus:ring-pos-500"
|
|
110
|
+
placeholder="0.00"
|
|
111
|
+
/>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
{closingAmount !== '' && (
|
|
116
|
+
<div className={`flex items-center gap-2 p-3 rounded-lg border ${varianceBg}`}>
|
|
117
|
+
<VarianceIcon className={`w-5 h-5 ${varianceColor}`} />
|
|
118
|
+
<div className="flex-1">
|
|
119
|
+
<span className="text-sm font-medium text-foreground block">Variance</span>
|
|
120
|
+
<span className={`font-semibold ${varianceColor}`}>
|
|
121
|
+
{variance > 0 ? '+' : ''}{formatMoney(variance, currency)}
|
|
122
|
+
</span>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
)}
|
|
126
|
+
|
|
127
|
+
<div className="space-y-2">
|
|
128
|
+
<label className="text-sm font-medium text-foreground block">Notes (Optional)</label>
|
|
129
|
+
<textarea
|
|
130
|
+
value={notes}
|
|
131
|
+
onChange={(e) => setNotes(e.target.value)}
|
|
132
|
+
rows={3}
|
|
133
|
+
className="w-full px-3 py-2 bg-background border border-border rounded-md text-foreground focus:outline-none focus:ring-2 focus:ring-pos-500 resize-none"
|
|
134
|
+
placeholder="Any comments about variance or issues..."
|
|
135
|
+
/>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<div className="pt-4 flex gap-3">
|
|
139
|
+
<button
|
|
140
|
+
type="button"
|
|
141
|
+
onClick={onClose}
|
|
142
|
+
disabled={isClosing}
|
|
143
|
+
className="flex-1 py-2 px-4 bg-background border border-border hover:bg-surface text-foreground rounded-lg font-medium transition-colors"
|
|
144
|
+
>
|
|
145
|
+
Cancel
|
|
146
|
+
</button>
|
|
147
|
+
<button
|
|
148
|
+
type="submit"
|
|
149
|
+
disabled={isClosing}
|
|
150
|
+
className="flex-1 flex items-center justify-center gap-2 py-2 px-4 bg-pos-500 hover:bg-pos-500/90 text-white rounded-lg font-medium transition-colors disabled:opacity-70"
|
|
151
|
+
>
|
|
152
|
+
{isClosing ? <Loader2 className="w-5 h-5 animate-spin" /> : null}
|
|
153
|
+
Close Register
|
|
154
|
+
</button>
|
|
155
|
+
</div>
|
|
156
|
+
</form>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState } from 'react';
|
|
4
|
+
import { X, Settings, Check, Loader2 } from 'lucide-react';
|
|
5
|
+
import { useAppStore } from '@/store/app-store';
|
|
6
|
+
import { usePaymentMethodsStore } from '@/store/payment-methods-store';
|
|
7
|
+
import { usePosStore } from '@/store/pos-store';
|
|
8
|
+
import { formatMoney } from '@/lib/utils';
|
|
9
|
+
import { api } from '@/lib/api';
|
|
10
|
+
import toast from 'react-hot-toast';
|
|
11
|
+
|
|
12
|
+
interface PaymentModalProps {
|
|
13
|
+
open: boolean;
|
|
14
|
+
onClose: () => void;
|
|
15
|
+
onSettingsOpen: () => void;
|
|
16
|
+
onSaleComplete?: () => void; // callback to refresh products after sale
|
|
17
|
+
cashSessionId?: string;
|
|
18
|
+
onSaleSuccess?: (saleData: any) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default function PaymentModal({ open, onClose, onSettingsOpen, onSaleComplete, cashSessionId, onSaleSuccess }: PaymentModalProps) {
|
|
22
|
+
const { currency } = useAppStore();
|
|
23
|
+
const { cart, total, subtotal, discount, customer, completeSale } = usePosStore();
|
|
24
|
+
const { methods, recordSale } = usePaymentMethodsStore();
|
|
25
|
+
|
|
26
|
+
const [selectedMethodId, setSelectedMethodId] = useState<string | null>(null);
|
|
27
|
+
const [isProcessing, setIsProcessing] = useState(false);
|
|
28
|
+
|
|
29
|
+
const enabledMethods = methods.filter((m) => m.enabled);
|
|
30
|
+
|
|
31
|
+
const handleCompleteSale = async () => {
|
|
32
|
+
if (!selectedMethodId || isProcessing) return;
|
|
33
|
+
|
|
34
|
+
const method = methods.find((m) => m.id === selectedMethodId);
|
|
35
|
+
if (!method) return;
|
|
36
|
+
|
|
37
|
+
setIsProcessing(true);
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
// 1. Call the backend API to create the sale and deduct stock
|
|
41
|
+
const salePayload: any = {
|
|
42
|
+
payment_method: method.name.toLowerCase(),
|
|
43
|
+
items: cart.map((item) => ({
|
|
44
|
+
medicine_id: item.medicine_id,
|
|
45
|
+
quantity: Number(item.quantity) || 1,
|
|
46
|
+
unit_price: Number(item.unit_price) || 0,
|
|
47
|
+
})),
|
|
48
|
+
customer_id: customer?.id || null,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
if (cashSessionId && typeof cashSessionId === 'string' && cashSessionId.trim().length > 0) {
|
|
52
|
+
salePayload.cash_session_id = cashSessionId;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const res: any = await api.processSale(salePayload);
|
|
56
|
+
|
|
57
|
+
// Construct receipt data for the receipt modal
|
|
58
|
+
const receiptData = {
|
|
59
|
+
invoice_number: res?.invoice_number || `INV-${Date.now().toString().slice(-8).toUpperCase()}`,
|
|
60
|
+
created_at: res?.created_at || new Date().toISOString(),
|
|
61
|
+
items: cart.map((item) => ({
|
|
62
|
+
medicine_id: item.medicine_id,
|
|
63
|
+
name: item.name,
|
|
64
|
+
quantity: item.quantity,
|
|
65
|
+
unit_price: item.unit_price,
|
|
66
|
+
line_total: item.line_total,
|
|
67
|
+
})),
|
|
68
|
+
total_amount: total,
|
|
69
|
+
payment_method: method.name,
|
|
70
|
+
customer: customer ? { id: customer.id, name: customer.name } : null,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// 2. Record in local payment tracking store (for POS-level summary)
|
|
74
|
+
recordSale({
|
|
75
|
+
items: cart.map((item) => ({
|
|
76
|
+
name: item.name,
|
|
77
|
+
quantity: item.quantity,
|
|
78
|
+
unit_price: item.unit_price,
|
|
79
|
+
line_total: item.line_total,
|
|
80
|
+
})),
|
|
81
|
+
subtotal,
|
|
82
|
+
discount: discount.amount,
|
|
83
|
+
total,
|
|
84
|
+
paymentMethodId: method.id,
|
|
85
|
+
paymentMethodName: method.name,
|
|
86
|
+
paymentType: method.type,
|
|
87
|
+
customerName: customer?.name || null,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// 3. Clear the POS cart
|
|
91
|
+
completeSale();
|
|
92
|
+
|
|
93
|
+
// 4. Notify success
|
|
94
|
+
toast.success(`Sale completed via ${method.name}!`);
|
|
95
|
+
|
|
96
|
+
// 5. Refresh product data (stock quantities changed)
|
|
97
|
+
if (onSaleComplete) {
|
|
98
|
+
onSaleComplete();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 6. Trigger receipt if callback provided
|
|
102
|
+
if (onSaleSuccess) {
|
|
103
|
+
onSaleSuccess(receiptData);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
setSelectedMethodId(null);
|
|
107
|
+
onClose();
|
|
108
|
+
} catch (error: any) {
|
|
109
|
+
console.error('Sale failed:', error);
|
|
110
|
+
toast.error(error.message || 'Failed to process sale. Please try again.');
|
|
111
|
+
} finally {
|
|
112
|
+
setIsProcessing(false);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const handleClose = () => {
|
|
117
|
+
if (isProcessing) return; // Don't close while processing
|
|
118
|
+
setSelectedMethodId(null);
|
|
119
|
+
onClose();
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
if (!open) return null;
|
|
123
|
+
|
|
124
|
+
// Separate cash and mobile methods
|
|
125
|
+
const cashMethods = enabledMethods.filter((m) => m.type === 'cash');
|
|
126
|
+
const mobileMethods = enabledMethods.filter((m) => m.type === 'mobile');
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
|
130
|
+
<div className="bg-background rounded-2xl max-w-[560px] w-[95%] shadow-[0_25px_50px_rgba(0,0,0,0.25)] flex flex-col overflow-hidden">
|
|
131
|
+
{/* Header */}
|
|
132
|
+
<div className="flex items-center justify-between px-6 pt-5 pb-1">
|
|
133
|
+
<h2 className="text-2xl font-bold text-foreground">Payment</h2>
|
|
134
|
+
<div className="flex items-center gap-1">
|
|
135
|
+
<button
|
|
136
|
+
onClick={() => { handleClose(); onSettingsOpen(); }}
|
|
137
|
+
className="p-2 hover:bg-surface rounded-full text-foreground/40 transition-colors"
|
|
138
|
+
title="Payment Settings"
|
|
139
|
+
>
|
|
140
|
+
<Settings size={18} />
|
|
141
|
+
</button>
|
|
142
|
+
<button onClick={handleClose} className="p-2 hover:bg-surface rounded-full text-foreground/50 transition-colors">
|
|
143
|
+
<X size={22} />
|
|
144
|
+
</button>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
{/* Total */}
|
|
149
|
+
<div className="px-6 pb-4 pt-1">
|
|
150
|
+
<p className="text-sm text-foreground/40 text-center mb-0.5">Total Due</p>
|
|
151
|
+
<div className="text-4xl font-black text-foreground text-center">
|
|
152
|
+
{formatMoney(total, currency)}
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
{/* All Payment Options */}
|
|
157
|
+
<div className="px-6 pb-2">
|
|
158
|
+
{/* Cash */}
|
|
159
|
+
{cashMethods.length > 0 && (
|
|
160
|
+
<div className="mb-3">
|
|
161
|
+
<div className="text-xs font-semibold text-foreground/40 uppercase tracking-wider mb-2 px-1">Cash</div>
|
|
162
|
+
<div className="grid grid-cols-1 gap-2">
|
|
163
|
+
{cashMethods.map((method) => (
|
|
164
|
+
<button
|
|
165
|
+
key={method.id}
|
|
166
|
+
onClick={() => setSelectedMethodId(method.id)}
|
|
167
|
+
disabled={isProcessing}
|
|
168
|
+
className={`flex items-center gap-3 p-3.5 rounded-xl border-2 transition-all ${
|
|
169
|
+
selectedMethodId === method.id
|
|
170
|
+
? 'border-green-500 bg-green-50 dark:bg-green-500/10 shadow-sm'
|
|
171
|
+
: 'border-transparent bg-surface hover:border-green-300 dark:hover:border-green-500/40'
|
|
172
|
+
}`}
|
|
173
|
+
>
|
|
174
|
+
<div
|
|
175
|
+
className="w-10 h-10 rounded-xl flex items-center justify-center text-xl"
|
|
176
|
+
style={{ backgroundColor: method.color + '18' }}
|
|
177
|
+
>
|
|
178
|
+
{method.logo}
|
|
179
|
+
</div>
|
|
180
|
+
<span className={`text-base font-semibold flex-1 text-left ${
|
|
181
|
+
selectedMethodId === method.id ? 'text-green-700 dark:text-green-300' : 'text-foreground'
|
|
182
|
+
}`}>
|
|
183
|
+
{method.name}
|
|
184
|
+
</span>
|
|
185
|
+
{selectedMethodId === method.id && (
|
|
186
|
+
<div className="w-6 h-6 rounded-full bg-green-500 flex items-center justify-center">
|
|
187
|
+
<Check size={14} className="text-white" />
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
</button>
|
|
191
|
+
))}
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
)}
|
|
195
|
+
|
|
196
|
+
{/* Mobile Banks */}
|
|
197
|
+
{mobileMethods.length > 0 && (
|
|
198
|
+
<div className="mb-3">
|
|
199
|
+
<div className="text-xs font-semibold text-foreground/40 uppercase tracking-wider mb-2 px-1">Mobile Bank</div>
|
|
200
|
+
<div className="grid grid-cols-3 gap-2">
|
|
201
|
+
{mobileMethods.map((method) => (
|
|
202
|
+
<button
|
|
203
|
+
key={method.id}
|
|
204
|
+
onClick={() => setSelectedMethodId(method.id)}
|
|
205
|
+
disabled={isProcessing}
|
|
206
|
+
className={`flex flex-col items-center gap-2.5 p-4 rounded-xl border-2 transition-all relative ${
|
|
207
|
+
selectedMethodId === method.id
|
|
208
|
+
? 'border-pos-500 bg-pos-50 dark:bg-pos-500/10 shadow-sm'
|
|
209
|
+
: 'border-transparent bg-surface hover:border-pos-300 dark:hover:border-pos-500/40'
|
|
210
|
+
}`}
|
|
211
|
+
>
|
|
212
|
+
{selectedMethodId === method.id && (
|
|
213
|
+
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-pos-500 flex items-center justify-center">
|
|
214
|
+
<Check size={12} className="text-white" />
|
|
215
|
+
</div>
|
|
216
|
+
)}
|
|
217
|
+
<div
|
|
218
|
+
className="w-11 h-11 rounded-xl flex items-center justify-center text-2xl"
|
|
219
|
+
style={{ backgroundColor: method.color + '18' }}
|
|
220
|
+
>
|
|
221
|
+
{method.logo}
|
|
222
|
+
</div>
|
|
223
|
+
<span className={`text-sm font-semibold ${
|
|
224
|
+
selectedMethodId === method.id ? 'text-pos-600 dark:text-pos-300' : 'text-foreground'
|
|
225
|
+
}`}>
|
|
226
|
+
{method.name}
|
|
227
|
+
</span>
|
|
228
|
+
</button>
|
|
229
|
+
))}
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
)}
|
|
233
|
+
</div>
|
|
234
|
+
|
|
235
|
+
{/* Action Buttons */}
|
|
236
|
+
<div className="px-6 pb-5 pt-2 flex gap-3">
|
|
237
|
+
<button
|
|
238
|
+
onClick={handleClose}
|
|
239
|
+
disabled={isProcessing}
|
|
240
|
+
className="flex-1 bg-surface hover:bg-border text-foreground/60 font-semibold py-4 rounded-xl transition-colors text-base disabled:opacity-50"
|
|
241
|
+
>
|
|
242
|
+
Cancel
|
|
243
|
+
</button>
|
|
244
|
+
<button
|
|
245
|
+
onClick={handleCompleteSale}
|
|
246
|
+
disabled={!selectedMethodId || isProcessing}
|
|
247
|
+
className={`flex-[2] font-bold py-4 rounded-xl transition-all text-base flex items-center justify-center gap-2 ${
|
|
248
|
+
selectedMethodId && !isProcessing
|
|
249
|
+
? 'bg-pos-500 hover:bg-pos-600 text-white shadow-lg shadow-pos-500/20'
|
|
250
|
+
: 'bg-border text-foreground/25 cursor-not-allowed'
|
|
251
|
+
}`}
|
|
252
|
+
>
|
|
253
|
+
{isProcessing ? (
|
|
254
|
+
<>
|
|
255
|
+
<Loader2 size={20} className="animate-spin" />
|
|
256
|
+
Processing...
|
|
257
|
+
</>
|
|
258
|
+
) : (
|
|
259
|
+
'Complete Sale'
|
|
260
|
+
)}
|
|
261
|
+
</button>
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
);
|
|
266
|
+
}
|