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,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
|
+
}
|