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,538 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useEffect, useMemo } from 'react';
4
+ import { useParams } from 'next/navigation';
5
+ import Link from 'next/link';
6
+ import { Search, Plus, MoreVertical, Edit, Trash2, Eye, CreditCard, Ban, Users, UserPlus, FileText, DollarSign, Activity } from 'lucide-react';
7
+ import { Button } from '@/components/ui/button';
8
+ import { Input } from '@/components/ui/input';
9
+ import { Select } from '@/components/ui/select';
10
+ import { Badge } from '@/components/ui/badge';
11
+ import { SlideOver } from '@/components/ui/slide-over';
12
+ import { ConfirmDialog } from '@/components/ui/confirm-dialog';
13
+ import { StatCard } from '@/components/ui/stat-card';
14
+ import { Switch } from '@/components/ui/switch';
15
+ import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
16
+ import { useAppStore } from '@/store/app-store';
17
+ import { api } from '@/lib/api';
18
+ import { toast } from 'react-hot-toast';
19
+ import { cn } from '@/lib/utils';
20
+
21
+ interface Customer {
22
+ id: string;
23
+ name: string;
24
+ arabic_name?: string;
25
+ phone?: string;
26
+ email?: string;
27
+ address?: string;
28
+ credit_limit?: number;
29
+ opening_balance?: number;
30
+ balance?: number;
31
+ status?: 'ACTIVE' | 'INACTIVE';
32
+ is_active?: boolean;
33
+ notes?: string;
34
+ total_purchases?: number;
35
+ created_at?: string;
36
+ }
37
+
38
+ const DEFAULT_FORM_DATA = {
39
+ name: '',
40
+ arabic_name: '',
41
+ phone: '',
42
+ email: '',
43
+ address: '',
44
+ credit_limit: 0,
45
+ opening_balance: 0,
46
+ notes: '',
47
+ is_active: true,
48
+ };
49
+
50
+ export default function CustomersPage() {
51
+ const { locale } = useParams();
52
+ const { currency } = useAppStore();
53
+
54
+ const [customers, setCustomers] = useState<Customer[]>([]);
55
+ const [isLoading, setIsLoading] = useState(true);
56
+ const [searchQuery, setSearchQuery] = useState('');
57
+ const [statusFilter, setStatusFilter] = useState('ALL');
58
+
59
+ const [isSlideOverOpen, setIsSlideOverOpen] = useState(false);
60
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
61
+ const [customerToDelete, setCustomerToDelete] = useState<string | null>(null);
62
+
63
+ const [formData, setFormData] = useState(DEFAULT_FORM_DATA);
64
+ const [editingId, setEditingId] = useState<string | null>(null);
65
+ const [isSaving, setIsSaving] = useState(false);
66
+
67
+ useEffect(() => {
68
+ fetchCustomers();
69
+ }, []);
70
+
71
+ const fetchCustomers = async () => {
72
+ try {
73
+ setIsLoading(true);
74
+ const data = await api.getCustomers();
75
+ setCustomers((data as any[]) || []);
76
+ } catch (error: any) {
77
+ toast.error(error.message || 'Failed to fetch customers');
78
+ } finally {
79
+ setIsLoading(false);
80
+ }
81
+ };
82
+
83
+ const handleOpenAdd = () => {
84
+ setFormData(DEFAULT_FORM_DATA);
85
+ setEditingId(null);
86
+ setIsSlideOverOpen(true);
87
+ };
88
+
89
+ const handleOpenEdit = (customer: Customer) => {
90
+ setFormData({
91
+ name: customer.name,
92
+ arabic_name: customer.arabic_name || '',
93
+ phone: customer.phone || '',
94
+ email: customer.email || '',
95
+ address: customer.address || '',
96
+ credit_limit: customer.credit_limit || 0,
97
+ opening_balance: customer.opening_balance || 0,
98
+ notes: customer.notes || '',
99
+ is_active: customer.status === 'ACTIVE' || customer.is_active !== false,
100
+ });
101
+ setEditingId(customer.id);
102
+ setIsSlideOverOpen(true);
103
+ };
104
+
105
+ const handleSave = async () => {
106
+ if (!formData.name) {
107
+ toast.error('Name is required');
108
+ return;
109
+ }
110
+
111
+ try {
112
+ setIsSaving(true);
113
+ const payload = {
114
+ ...formData,
115
+ status: formData.is_active ? 'ACTIVE' : 'INACTIVE',
116
+ credit_limit: Number(formData.credit_limit),
117
+ opening_balance: Number(formData.opening_balance)
118
+ };
119
+
120
+ if (editingId) {
121
+ await api.updateCustomer(editingId, payload);
122
+ toast.success('Customer updated successfully');
123
+ } else {
124
+ await api.createCustomer(payload);
125
+ toast.success('Customer created successfully');
126
+ }
127
+
128
+ setIsSlideOverOpen(false);
129
+ fetchCustomers();
130
+ } catch (error: any) {
131
+ toast.error(error.message || 'Failed to save customer');
132
+ } finally {
133
+ setIsSaving(false);
134
+ }
135
+ };
136
+
137
+ const handleDelete = async () => {
138
+ if (!customerToDelete) return;
139
+
140
+ try {
141
+ await api.deleteCustomer(customerToDelete);
142
+ toast.success('Customer deleted successfully');
143
+ fetchCustomers();
144
+ } catch (error: any) {
145
+ toast.error(error.message || 'Failed to delete customer');
146
+ } finally {
147
+ setIsDeleteDialogOpen(false);
148
+ setCustomerToDelete(null);
149
+ }
150
+ };
151
+
152
+ const handleStatusChange = async (id: string, newStatus: string) => {
153
+ try {
154
+ await api.updateCustomer(id, { status: newStatus });
155
+ toast.success(`Customer ${newStatus.toLowerCase()}`);
156
+ fetchCustomers();
157
+ } catch (error: any) {
158
+ toast.error(error.message || 'Failed to update status');
159
+ }
160
+ };
161
+
162
+ const handleAddPayment = () => {
163
+ toast.error('Payment module not implemented yet');
164
+ };
165
+
166
+ const filteredCustomers = useMemo(() => {
167
+ return customers.filter(c => {
168
+ const matchesSearch = c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
169
+ (c.arabic_name && c.arabic_name.toLowerCase().includes(searchQuery.toLowerCase())) ||
170
+ (c.phone && c.phone.includes(searchQuery));
171
+
172
+ const isActive = c.status === 'ACTIVE' || c.is_active !== false;
173
+ const hasBalance = (c.balance || 0) > 0;
174
+
175
+ let matchesFilter = true;
176
+ if (statusFilter === 'ACTIVE') matchesFilter = isActive;
177
+ if (statusFilter === 'INACTIVE') matchesFilter = !isActive;
178
+ if (statusFilter === 'HAS_BALANCE') matchesFilter = hasBalance;
179
+
180
+ return matchesSearch && matchesFilter;
181
+ });
182
+ }, [customers, searchQuery, statusFilter]);
183
+
184
+ const summary = useMemo(() => {
185
+ const total = customers.length;
186
+ const currentMonth = new Date().getMonth();
187
+ const currentYear = new Date().getFullYear();
188
+
189
+ const newThisMonth = customers.filter(c => {
190
+ if (!c.created_at) return false;
191
+ const d = new Date(c.created_at);
192
+ return d.getMonth() === currentMonth && d.getFullYear() === currentYear;
193
+ }).length;
194
+
195
+ const creditCustomers = customers.filter(c => (c.balance || 0) > 0).length;
196
+ const outstandingBalance = customers.reduce((acc, c) => acc + (c.balance || 0), 0);
197
+
198
+ return { total, newThisMonth, creditCustomers, outstandingBalance };
199
+ }, [customers]);
200
+
201
+ const formatCurrency = (amount: number) => {
202
+ return new Intl.NumberFormat(locale as string, {
203
+ style: 'currency',
204
+ currency: currency || 'USD',
205
+ }).format(amount);
206
+ };
207
+
208
+ return (
209
+ <div className="flex flex-col h-full space-y-6">
210
+ {/* Header */}
211
+ <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
212
+ <div>
213
+ <h1 className="text-2xl font-bold text-white tracking-tight">Customers</h1>
214
+ <p className="text-sm text-gray-400 mt-1">Manage your customer database and credit accounts</p>
215
+ </div>
216
+ <Button variant="primary" onClick={handleOpenAdd} className="gap-2 w-full sm:w-auto">
217
+ <Plus className="h-4 w-4" />
218
+ Add Customer
219
+ </Button>
220
+ </div>
221
+
222
+ {/* Stats */}
223
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
224
+ <StatCard
225
+ title="Total Customers"
226
+ value={summary.total.toString()}
227
+ icon={<Users className="h-5 w-5" />}
228
+ />
229
+ <StatCard
230
+ title="New This Month"
231
+ value={summary.newThisMonth.toString()}
232
+ icon={<UserPlus className="h-5 w-5" />}
233
+ />
234
+ <StatCard
235
+ title="Credit Accounts"
236
+ value={summary.creditCustomers.toString()}
237
+ icon={<FileText className="h-5 w-5" />}
238
+ />
239
+ <StatCard
240
+ title="Outstanding Balance"
241
+ value={formatCurrency(summary.outstandingBalance)}
242
+ icon={<DollarSign className="h-5 w-5" />}
243
+ />
244
+ </div>
245
+
246
+ {/* Filters & Actions */}
247
+ <div className="flex flex-col sm:flex-row gap-4 items-center justify-between bg-[#0B1220] p-4 rounded-xl border border-gray-800 shadow-sm">
248
+ <div className="flex flex-col sm:flex-row flex-1 w-full gap-4 items-center">
249
+ <div className="w-full sm:max-w-sm relative">
250
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
251
+ <Input
252
+ placeholder="Search customers..."
253
+ className="pl-10 bg-gray-900/50 w-full"
254
+ value={searchQuery}
255
+ onChange={(e) => setSearchQuery(e.target.value)}
256
+ />
257
+ </div>
258
+ <div className="w-full sm:w-[180px]">
259
+ <Select
260
+ options={[
261
+ { value: 'ALL', label: 'All Customers' },
262
+ { value: 'ACTIVE', label: 'Active Only' },
263
+ { value: 'INACTIVE', label: 'Inactive Only' },
264
+ { value: 'HAS_BALANCE', label: 'Has Balance' },
265
+ ]}
266
+ value={statusFilter}
267
+ onChange={setStatusFilter}
268
+ />
269
+ </div>
270
+ </div>
271
+ </div>
272
+
273
+ {/* Table */}
274
+ <div className="flex-1 bg-[#0B1220] border border-gray-800 rounded-xl overflow-hidden shadow-sm flex flex-col">
275
+ <div className="overflow-x-auto flex-1">
276
+ <table className="w-full text-sm text-left">
277
+ <thead className="text-xs text-gray-400 bg-gray-900/50 uppercase border-b border-gray-800 sticky top-0 z-10">
278
+ <tr>
279
+ <th className="px-6 py-4 font-medium">Customer</th>
280
+ <th className="px-6 py-4 font-medium hidden md:table-cell">Contact</th>
281
+ <th className="px-6 py-4 font-medium text-right hidden lg:table-cell">Total Purchases</th>
282
+ <th className="px-6 py-4 font-medium text-right">Balance</th>
283
+ <th className="px-6 py-4 font-medium text-center">Status</th>
284
+ <th className="px-6 py-4 font-medium text-right">Actions</th>
285
+ </tr>
286
+ </thead>
287
+ <tbody className="divide-y divide-gray-800">
288
+ {isLoading ? (
289
+ <tr>
290
+ <td colSpan={6} className="px-6 py-12 text-center text-gray-500">
291
+ <div className="flex flex-col items-center justify-center">
292
+ <Activity className="h-8 w-8 animate-pulse mb-2 text-brand-500" />
293
+ <p>Loading customers...</p>
294
+ </div>
295
+ </td>
296
+ </tr>
297
+ ) : filteredCustomers.length === 0 ? (
298
+ <tr>
299
+ <td colSpan={6} className="px-6 py-12 text-center text-gray-500">
300
+ No customers found matching your criteria.
301
+ </td>
302
+ </tr>
303
+ ) : (
304
+ filteredCustomers.map((customer) => {
305
+ const isActive = customer.status === 'ACTIVE' || customer.is_active !== false;
306
+ return (
307
+ <tr key={customer.id} className="hover:bg-gray-800/30 transition-colors group">
308
+ <td className="px-6 py-4">
309
+ <div className="flex flex-col">
310
+ <span className="font-medium text-white">{customer.name}</span>
311
+ {customer.arabic_name && (
312
+ <span className="text-xs text-gray-400 mt-0.5">{customer.arabic_name}</span>
313
+ )}
314
+ </div>
315
+ </td>
316
+ <td className="px-6 py-4 hidden md:table-cell">
317
+ <div className="flex flex-col">
318
+ <span className="text-gray-300">{customer.phone || '-'}</span>
319
+ {customer.email && (
320
+ <span className="text-xs text-gray-500">{customer.email}</span>
321
+ )}
322
+ </div>
323
+ </td>
324
+ <td className="px-6 py-4 text-right text-gray-300 hidden lg:table-cell">
325
+ {customer.total_purchases !== undefined ? formatCurrency(customer.total_purchases) : formatCurrency(0)}
326
+ </td>
327
+ <td className="px-6 py-4 text-right">
328
+ <span className={cn(
329
+ "font-medium",
330
+ (customer.balance || 0) > 0 ? "text-danger" : "text-gray-300"
331
+ )}>
332
+ {formatCurrency(customer.balance || 0)}
333
+ </span>
334
+ </td>
335
+ <td className="px-6 py-4 text-center">
336
+ <Badge variant={isActive ? 'success' : 'default'}>
337
+ {isActive ? 'Active' : 'Inactive'}
338
+ </Badge>
339
+ </td>
340
+ <td className="px-6 py-4 text-right">
341
+ <DropdownMenu.Root>
342
+ <DropdownMenu.Trigger asChild>
343
+ <Button variant="ghost" size="icon" className="h-8 w-8 text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity">
344
+ <MoreVertical className="h-4 w-4" />
345
+ </Button>
346
+ </DropdownMenu.Trigger>
347
+ <DropdownMenu.Portal>
348
+ <DropdownMenu.Content align="end" className="z-50 min-w-[160px] overflow-hidden rounded-lg border border-gray-800 bg-[#0B1220] p-1 shadow-xl animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2">
349
+ <DropdownMenu.Item asChild>
350
+ <Link
351
+ href={`/${locale}/customers/${customer.id}`}
352
+ className="flex items-center gap-2 px-2 py-2 text-sm text-gray-300 hover:bg-gray-800 hover:text-white rounded-md cursor-pointer outline-none transition-colors"
353
+ >
354
+ <Eye className="h-4 w-4" /> View Profile
355
+ </Link>
356
+ </DropdownMenu.Item>
357
+ <DropdownMenu.Item
358
+ onClick={() => handleOpenEdit(customer)}
359
+ className="flex items-center gap-2 px-2 py-2 text-sm text-gray-300 hover:bg-gray-800 hover:text-white rounded-md cursor-pointer outline-none transition-colors"
360
+ >
361
+ <Edit className="h-4 w-4" /> Edit Details
362
+ </DropdownMenu.Item>
363
+ <DropdownMenu.Item asChild>
364
+ <Link
365
+ href={`/${locale}/sales?customerId=${customer.id}`}
366
+ className="flex items-center gap-2 px-2 py-2 text-sm text-gray-300 hover:bg-gray-800 hover:text-white rounded-md cursor-pointer outline-none transition-colors"
367
+ >
368
+ <Activity className="h-4 w-4" /> New Sale
369
+ </Link>
370
+ </DropdownMenu.Item>
371
+ <DropdownMenu.Item
372
+ onClick={handleAddPayment}
373
+ className="flex items-center gap-2 px-2 py-2 text-sm text-gray-300 hover:bg-gray-800 hover:text-white rounded-md cursor-pointer outline-none transition-colors"
374
+ >
375
+ <CreditCard className="h-4 w-4" /> Add Payment
376
+ </DropdownMenu.Item>
377
+ <DropdownMenu.Separator className="h-px bg-gray-800 my-1" />
378
+ {isActive ? (
379
+ <DropdownMenu.Item
380
+ onClick={() => handleStatusChange(customer.id, 'INACTIVE')}
381
+ className="flex items-center gap-2 px-2 py-2 text-sm text-amber-400 hover:bg-gray-800 hover:text-amber-300 rounded-md cursor-pointer outline-none transition-colors"
382
+ >
383
+ <Ban className="h-4 w-4" /> Deactivate
384
+ </DropdownMenu.Item>
385
+ ) : (
386
+ <DropdownMenu.Item
387
+ onClick={() => handleStatusChange(customer.id, 'ACTIVE')}
388
+ className="flex items-center gap-2 px-2 py-2 text-sm text-success hover:bg-gray-800 rounded-md cursor-pointer outline-none transition-colors"
389
+ >
390
+ <Activity className="h-4 w-4" /> Activate
391
+ </DropdownMenu.Item>
392
+ )}
393
+ <DropdownMenu.Item
394
+ onClick={() => {
395
+ setCustomerToDelete(customer.id);
396
+ setIsDeleteDialogOpen(true);
397
+ }}
398
+ className="flex items-center gap-2 px-2 py-2 text-sm text-danger hover:bg-red-950/50 hover:text-red-400 rounded-md cursor-pointer outline-none transition-colors"
399
+ >
400
+ <Trash2 className="h-4 w-4" /> Delete
401
+ </DropdownMenu.Item>
402
+ </DropdownMenu.Content>
403
+ </DropdownMenu.Portal>
404
+ </DropdownMenu.Root>
405
+ </td>
406
+ </tr>
407
+ );
408
+ })
409
+ )}
410
+ </tbody>
411
+ </table>
412
+ </div>
413
+ </div>
414
+
415
+ {/* Add/Edit SlideOver */}
416
+ <SlideOver
417
+ open={isSlideOverOpen}
418
+ onClose={() => setIsSlideOverOpen(false)}
419
+ title={editingId ? 'Edit Customer' : 'Add New Customer'}
420
+ >
421
+ <div className="space-y-5">
422
+ <div className="space-y-2">
423
+ <label className="text-sm font-medium text-gray-300">Full Name <span className="text-danger">*</span></label>
424
+ <Input
425
+ placeholder="e.g. Ahmed Ali"
426
+ value={formData.name}
427
+ onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
428
+ />
429
+ </div>
430
+ <div className="space-y-2">
431
+ <label className="text-sm font-medium text-gray-300">Arabic Name</label>
432
+ <Input
433
+ placeholder="أحمد علي"
434
+ dir="auto"
435
+ value={formData.arabic_name}
436
+ onChange={(e) => setFormData(prev => ({ ...prev, arabic_name: e.target.value }))}
437
+ />
438
+ </div>
439
+ <div className="grid grid-cols-2 gap-4">
440
+ <div className="space-y-2">
441
+ <label className="text-sm font-medium text-gray-300">Phone</label>
442
+ <Input
443
+ placeholder="+252 XXXXXXX"
444
+ value={formData.phone}
445
+ onChange={(e) => setFormData(prev => ({ ...prev, phone: e.target.value }))}
446
+ />
447
+ </div>
448
+ <div className="space-y-2">
449
+ <label className="text-sm font-medium text-gray-300">Email</label>
450
+ <Input
451
+ type="email"
452
+ placeholder="email@example.com"
453
+ value={formData.email}
454
+ onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
455
+ />
456
+ </div>
457
+ </div>
458
+ <div className="space-y-2">
459
+ <label className="text-sm font-medium text-gray-300">Address</label>
460
+ <Input
461
+ placeholder="Street, District, City"
462
+ value={formData.address}
463
+ onChange={(e) => setFormData(prev => ({ ...prev, address: e.target.value }))}
464
+ />
465
+ </div>
466
+ <div className="grid grid-cols-2 gap-4">
467
+ <div className="space-y-2">
468
+ <label className="text-sm font-medium text-gray-300">Credit Limit ({currency})</label>
469
+ <Input
470
+ type="number"
471
+ min="0"
472
+ placeholder="0.00"
473
+ value={formData.credit_limit || ''}
474
+ onChange={(e) => setFormData(prev => ({ ...prev, credit_limit: Number(e.target.value) }))}
475
+ />
476
+ </div>
477
+ <div className="space-y-2">
478
+ <label className="text-sm font-medium text-gray-300">Opening Balance ({currency})</label>
479
+ <Input
480
+ type="number"
481
+ min="0"
482
+ placeholder="0.00"
483
+ value={formData.opening_balance || ''}
484
+ onChange={(e) => setFormData(prev => ({ ...prev, opening_balance: Number(e.target.value) }))}
485
+ disabled={!!editingId} // Usually shouldn't change after creation
486
+ />
487
+ {!!editingId && <p className="text-xs text-gray-500">Cannot edit after creation</p>}
488
+ </div>
489
+ </div>
490
+ <div className="space-y-2">
491
+ <label className="text-sm font-medium text-gray-300">Notes</label>
492
+ <textarea
493
+ className="flex min-h-[100px] w-full rounded-lg border border-border bg-background px-3 py-2 text-sm placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-brand-500 disabled:cursor-not-allowed disabled:opacity-50"
494
+ placeholder="Any additional information..."
495
+ value={formData.notes}
496
+ onChange={(e) => setFormData(prev => ({ ...prev, notes: e.target.value }))}
497
+ />
498
+ </div>
499
+
500
+ <div className="pt-2">
501
+ <div className="flex items-center justify-between border border-gray-800 p-4 rounded-lg bg-gray-900/30">
502
+ <div className="space-y-0.5">
503
+ <label className="text-sm font-medium text-white">Active Status</label>
504
+ <p className="text-xs text-gray-400">Can this customer make new purchases?</p>
505
+ </div>
506
+ <Switch
507
+ checked={formData.is_active}
508
+ onCheckedChange={(checked) => setFormData(prev => ({ ...prev, is_active: checked }))}
509
+ />
510
+ </div>
511
+ </div>
512
+
513
+ <div className="pt-6 flex justify-end gap-3 border-t border-gray-800 mt-6">
514
+ <Button variant="secondary" onClick={() => setIsSlideOverOpen(false)}>
515
+ Cancel
516
+ </Button>
517
+ <Button variant="primary" onClick={handleSave} disabled={isSaving}>
518
+ {isSaving ? 'Saving...' : 'Save Customer'}
519
+ </Button>
520
+ </div>
521
+ </div>
522
+ </SlideOver>
523
+
524
+ {/* Delete Confirmation */}
525
+ <ConfirmDialog
526
+ open={isDeleteDialogOpen}
527
+ onCancel={() => {
528
+ setIsDeleteDialogOpen(false);
529
+ setCustomerToDelete(null);
530
+ }}
531
+ onConfirm={handleDelete}
532
+ title="Delete Customer"
533
+ message="Are you sure you want to delete this customer? This action cannot be undone and will fail if the customer has existing transactions or outstanding balance."
534
+ confirmText="Delete Customer"
535
+ />
536
+ </div>
537
+ );
538
+ }