pharmacy-erp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/.env.example +34 -0
  2. package/LICENSE +21 -0
  3. package/README.md +479 -0
  4. package/backend/Dockerfile +44 -0
  5. package/backend/app/__init__.py +1 -0
  6. package/backend/app/core/__init__.py +1 -0
  7. package/backend/app/core/audit.py +33 -0
  8. package/backend/app/core/config.py +16 -0
  9. package/backend/app/core/database.py +26 -0
  10. package/backend/app/core/deps.py +49 -0
  11. package/backend/app/core/exceptions.py +43 -0
  12. package/backend/app/core/security.py +32 -0
  13. package/backend/app/main.py +54 -0
  14. package/backend/app/modules/__init__.py +0 -0
  15. package/backend/app/modules/admin/__init__.py +1 -0
  16. package/backend/app/modules/admin/models.py +15 -0
  17. package/backend/app/modules/admin/router.py +353 -0
  18. package/backend/app/modules/auth/__init__.py +0 -0
  19. package/backend/app/modules/auth/models.py +16 -0
  20. package/backend/app/modules/auth/router.py +73 -0
  21. package/backend/app/modules/auth/schemas.py +29 -0
  22. package/backend/app/modules/cash_sessions/__init__.py +0 -0
  23. package/backend/app/modules/cash_sessions/models.py +19 -0
  24. package/backend/app/modules/cash_sessions/router.py +85 -0
  25. package/backend/app/modules/cash_sessions/schemas.py +31 -0
  26. package/backend/app/modules/customers/__init__.py +0 -0
  27. package/backend/app/modules/customers/models.py +37 -0
  28. package/backend/app/modules/customers/router.py +296 -0
  29. package/backend/app/modules/customers/schemas.py +72 -0
  30. package/backend/app/modules/inventory/__init__.py +0 -0
  31. package/backend/app/modules/inventory/models.py +16 -0
  32. package/backend/app/modules/inventory/router.py +150 -0
  33. package/backend/app/modules/inventory/schemas.py +24 -0
  34. package/backend/app/modules/medicines/__init__.py +0 -0
  35. package/backend/app/modules/medicines/models.py +54 -0
  36. package/backend/app/modules/medicines/router.py +631 -0
  37. package/backend/app/modules/medicines/schemas.py +153 -0
  38. package/backend/app/modules/medicines/template_generator.py +118 -0
  39. package/backend/app/modules/notifications/__init__.py +1 -0
  40. package/backend/app/modules/notifications/models.py +15 -0
  41. package/backend/app/modules/notifications/router.py +89 -0
  42. package/backend/app/modules/notifications/schemas.py +22 -0
  43. package/backend/app/modules/organizations/__init__.py +0 -0
  44. package/backend/app/modules/organizations/models.py +16 -0
  45. package/backend/app/modules/organizations/router.py +49 -0
  46. package/backend/app/modules/organizations/schemas.py +31 -0
  47. package/backend/app/modules/reports/__init__.py +0 -0
  48. package/backend/app/modules/reports/router.py +409 -0
  49. package/backend/app/modules/roles/__init__.py +1 -0
  50. package/backend/app/modules/roles/models.py +43 -0
  51. package/backend/app/modules/roles/router.py +89 -0
  52. package/backend/app/modules/roles/schemas.py +55 -0
  53. package/backend/app/modules/sales/__init__.py +0 -0
  54. package/backend/app/modules/sales/models.py +44 -0
  55. package/backend/app/modules/sales/router.py +63 -0
  56. package/backend/app/modules/sales/schemas.py +42 -0
  57. package/backend/app/modules/sales/service.py +128 -0
  58. package/backend/app/modules/suppliers/__init__.py +0 -0
  59. package/backend/app/modules/suppliers/models.py +14 -0
  60. package/backend/app/modules/suppliers/router.py +56 -0
  61. package/backend/app/modules/suppliers/schemas.py +27 -0
  62. package/backend/app/modules/users/__init__.py +0 -0
  63. package/backend/app/modules/users/router.py +74 -0
  64. package/backend/app/seed.py +275 -0
  65. package/backend/data/.gitkeep +1 -0
  66. package/backend/requirements.txt +17 -0
  67. package/bin/cli.js +288 -0
  68. package/docker-compose.dev.yml +42 -0
  69. package/docker-compose.yml +59 -0
  70. package/frontend/Dockerfile +56 -0
  71. package/frontend/next.config.ts +19 -0
  72. package/frontend/package.json +60 -0
  73. package/frontend/pnpm-lock.yaml +5678 -0
  74. package/frontend/pnpm-workspace.yaml +6 -0
  75. package/frontend/postcss.config.mjs +6 -0
  76. package/frontend/public/logo.png +0 -0
  77. package/frontend/src/app/[locale]/(app)/admin/page.tsx +1426 -0
  78. package/frontend/src/app/[locale]/(app)/catalog/products/[id]/page.tsx +505 -0
  79. package/frontend/src/app/[locale]/(app)/catalog/products/page.tsx +753 -0
  80. package/frontend/src/app/[locale]/(app)/customers/[id]/page.tsx +500 -0
  81. package/frontend/src/app/[locale]/(app)/customers/page.tsx +538 -0
  82. package/frontend/src/app/[locale]/(app)/dashboard/page.tsx +175 -0
  83. package/frontend/src/app/[locale]/(app)/inventory/page.tsx +765 -0
  84. package/frontend/src/app/[locale]/(app)/layout.tsx +60 -0
  85. package/frontend/src/app/[locale]/(app)/purchasing/page.tsx +1 -0
  86. package/frontend/src/app/[locale]/(app)/reports/page.tsx +794 -0
  87. package/frontend/src/app/[locale]/(app)/sales/page.tsx +296 -0
  88. package/frontend/src/app/[locale]/(auth)/login/page.tsx +100 -0
  89. package/frontend/src/app/[locale]/(pos)/layout.tsx +33 -0
  90. package/frontend/src/app/[locale]/(pos)/pos/page.tsx +463 -0
  91. package/frontend/src/app/[locale]/(pos)/pos/pos-data.ts +89 -0
  92. package/frontend/src/app/[locale]/layout.tsx +30 -0
  93. package/frontend/src/app/[locale]/page.tsx +6 -0
  94. package/frontend/src/app/globals.css +77 -0
  95. package/frontend/src/app/layout.tsx +23 -0
  96. package/frontend/src/app/print.css +18 -0
  97. package/frontend/src/components/pos/CartPanel.tsx +525 -0
  98. package/frontend/src/components/pos/CashSessionGuard.tsx +255 -0
  99. package/frontend/src/components/pos/CloseSessionModal.tsx +160 -0
  100. package/frontend/src/components/pos/PaymentModal.tsx +266 -0
  101. package/frontend/src/components/pos/PaymentSettingsModal.tsx +472 -0
  102. package/frontend/src/components/pos/ProductCatalog.tsx +140 -0
  103. package/frontend/src/components/pos/SaleReceipt.tsx +133 -0
  104. package/frontend/src/components/ui/badge.tsx +28 -0
  105. package/frontend/src/components/ui/bulk-import-modal.tsx +400 -0
  106. package/frontend/src/components/ui/button.tsx +60 -0
  107. package/frontend/src/components/ui/card.tsx +13 -0
  108. package/frontend/src/components/ui/confirm-dialog.tsx +50 -0
  109. package/frontend/src/components/ui/input.tsx +32 -0
  110. package/frontend/src/components/ui/notification-center.tsx +228 -0
  111. package/frontend/src/components/ui/select.tsx +59 -0
  112. package/frontend/src/components/ui/sidebar.tsx +91 -0
  113. package/frontend/src/components/ui/slide-over.tsx +43 -0
  114. package/frontend/src/components/ui/stat-card.tsx +40 -0
  115. package/frontend/src/components/ui/switch.tsx +38 -0
  116. package/frontend/src/components/ui/topbar.tsx +97 -0
  117. package/frontend/src/i18n/request.ts +13 -0
  118. package/frontend/src/i18n/routing.ts +9 -0
  119. package/frontend/src/lib/api.ts +240 -0
  120. package/frontend/src/lib/constants.ts +30 -0
  121. package/frontend/src/lib/utils.ts +32 -0
  122. package/frontend/src/messages/ar.json +106 -0
  123. package/frontend/src/messages/en.json +106 -0
  124. package/frontend/src/middleware.ts +8 -0
  125. package/frontend/src/store/app-store.ts +37 -0
  126. package/frontend/src/store/auth-store.ts +52 -0
  127. package/frontend/src/store/payment-methods-store.ts +144 -0
  128. package/frontend/src/store/pos-store.ts +344 -0
  129. package/frontend/tailwind.config.ts +85 -0
  130. package/frontend/tsconfig.json +27 -0
  131. package/img/backgraund-logo.png +0 -0
  132. package/img/full-logo.png +0 -0
  133. package/img/logo.png +0 -0
  134. package/nginx/nginx.conf +67 -0
  135. package/package.json +88 -0
  136. package/start.bat +90 -0
  137. package/start.sh +64 -0
@@ -0,0 +1,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
+ }