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,23 @@
|
|
|
1
|
+
import './globals.css';
|
|
2
|
+
import { Inter } from 'next/font/google';
|
|
3
|
+
|
|
4
|
+
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' });
|
|
5
|
+
|
|
6
|
+
export const metadata = {
|
|
7
|
+
title: 'Pharma ERP',
|
|
8
|
+
description: 'Modern Pharmacy Management System',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export default function RootLayout({
|
|
12
|
+
children,
|
|
13
|
+
}: {
|
|
14
|
+
children: React.ReactNode;
|
|
15
|
+
}) {
|
|
16
|
+
return (
|
|
17
|
+
<html lang="en" suppressHydrationWarning>
|
|
18
|
+
<body className={`${inter.variable} font-sans`}>
|
|
19
|
+
{children}
|
|
20
|
+
</body>
|
|
21
|
+
</html>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
@media print {
|
|
2
|
+
body * {
|
|
3
|
+
visibility: hidden;
|
|
4
|
+
}
|
|
5
|
+
#receipt-print-area,
|
|
6
|
+
#receipt-print-area * {
|
|
7
|
+
visibility: visible;
|
|
8
|
+
}
|
|
9
|
+
#receipt-print-area {
|
|
10
|
+
position: absolute;
|
|
11
|
+
left: 0;
|
|
12
|
+
top: 0;
|
|
13
|
+
width: 80mm;
|
|
14
|
+
padding: 5mm;
|
|
15
|
+
font-size: 12px;
|
|
16
|
+
font-family: 'Courier New', monospace;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect } from 'react';
|
|
4
|
+
import { Trash2, Keyboard, CreditCard, ShoppingCart, ChevronDown, ChevronUp, Delete, User, Percent, PauseCircle, Play, X, Phone, Check } from 'lucide-react';
|
|
5
|
+
import { usePosStore } from '@/store/pos-store';
|
|
6
|
+
import type { CartItem, HeldSale } from '@/store/pos-store';
|
|
7
|
+
import { useAppStore } from '@/store/app-store';
|
|
8
|
+
import { formatMoney } from '@/lib/utils';
|
|
9
|
+
import { api } from '@/lib/api';
|
|
10
|
+
import toast from 'react-hot-toast';
|
|
11
|
+
|
|
12
|
+
type PanelType = 'customer' | 'discount' | 'hold' | 'keypad' | null;
|
|
13
|
+
|
|
14
|
+
export default function CartPanel({ onPayment, products = [] }: { onPayment: () => void; products?: any[] }) {
|
|
15
|
+
const {
|
|
16
|
+
cart,
|
|
17
|
+
customer,
|
|
18
|
+
subtotal,
|
|
19
|
+
total,
|
|
20
|
+
tax,
|
|
21
|
+
discount,
|
|
22
|
+
selectedItemId,
|
|
23
|
+
keypadMode,
|
|
24
|
+
keypadValue,
|
|
25
|
+
heldSales,
|
|
26
|
+
removeItem,
|
|
27
|
+
updateQuantity,
|
|
28
|
+
selectItem,
|
|
29
|
+
setCustomer,
|
|
30
|
+
applyDiscount,
|
|
31
|
+
clearDiscount,
|
|
32
|
+
holdCurrentSale,
|
|
33
|
+
resumeSale,
|
|
34
|
+
deleteHeldSale,
|
|
35
|
+
setKeypadMode,
|
|
36
|
+
setKeypadValue,
|
|
37
|
+
applyKeypadValue,
|
|
38
|
+
} = usePosStore();
|
|
39
|
+
|
|
40
|
+
const { currency } = useAppStore();
|
|
41
|
+
|
|
42
|
+
// Local UI state for active panel
|
|
43
|
+
const [activePanel, setActivePanel] = useState<PanelType>(null);
|
|
44
|
+
const [discountType, setDiscountType] = useState<'percentage' | 'fixed'>('percentage');
|
|
45
|
+
const [discountInput, setDiscountInput] = useState('');
|
|
46
|
+
const [holdNote, setHoldNote] = useState('');
|
|
47
|
+
const [customers, setCustomers] = useState<any[]>([]);
|
|
48
|
+
const [customerSearch, setCustomerSearch] = useState('');
|
|
49
|
+
const [loadingCustomers, setLoadingCustomers] = useState(false);
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (activePanel === 'customer') {
|
|
53
|
+
setLoadingCustomers(true);
|
|
54
|
+
api.getCustomers()
|
|
55
|
+
.then((data: any) => {
|
|
56
|
+
setCustomers(Array.isArray(data) ? data : []);
|
|
57
|
+
})
|
|
58
|
+
.catch(() => {
|
|
59
|
+
toast.error('Failed to load customers');
|
|
60
|
+
setCustomers([]);
|
|
61
|
+
})
|
|
62
|
+
.finally(() => setLoadingCustomers(false));
|
|
63
|
+
}
|
|
64
|
+
}, [activePanel]);
|
|
65
|
+
|
|
66
|
+
const filteredCustomers = customerSearch
|
|
67
|
+
? customers.filter((c: any) =>
|
|
68
|
+
c.name?.toLowerCase().includes(customerSearch.toLowerCase()) ||
|
|
69
|
+
c.phone?.includes(customerSearch)
|
|
70
|
+
)
|
|
71
|
+
: customers;
|
|
72
|
+
|
|
73
|
+
const togglePanel = (panel: PanelType) => {
|
|
74
|
+
setActivePanel(activePanel === panel ? null : panel);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Keypad handlers
|
|
78
|
+
const handleNumpadPress = (val: string) => {
|
|
79
|
+
setKeypadValue(keypadValue + val);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const handleBackspace = () => {
|
|
83
|
+
if (keypadValue.length > 0) {
|
|
84
|
+
setKeypadValue(keypadValue.slice(0, -1));
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const handleToggleSign = () => {
|
|
89
|
+
if (keypadValue.startsWith('-')) {
|
|
90
|
+
setKeypadValue(keypadValue.slice(1));
|
|
91
|
+
} else if (keypadValue.length > 0) {
|
|
92
|
+
setKeypadValue('-' + keypadValue);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const handleModeChange = (mode: 'qty' | 'price' | 'discount') => {
|
|
97
|
+
setKeypadMode(mode);
|
|
98
|
+
if (keypadValue) {
|
|
99
|
+
applyKeypadValue();
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// Discount handler
|
|
104
|
+
const handleApplyDiscount = () => {
|
|
105
|
+
const val = parseFloat(discountInput);
|
|
106
|
+
if (isNaN(val) || val <= 0) return;
|
|
107
|
+
applyDiscount(discountType, val);
|
|
108
|
+
setDiscountInput('');
|
|
109
|
+
setActivePanel(null);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// Hold handler
|
|
113
|
+
const handleHoldSale = () => {
|
|
114
|
+
holdCurrentSale(holdNote);
|
|
115
|
+
setHoldNote('');
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<div className="flex flex-col bg-background border-r border-border h-full w-full">
|
|
120
|
+
{/* Cart Items List */}
|
|
121
|
+
<div className="flex-1 overflow-y-auto">
|
|
122
|
+
{cart?.length === 0 ? (
|
|
123
|
+
<div className="h-full flex flex-col items-center justify-center text-foreground/40">
|
|
124
|
+
<ShoppingCart size={48} className="mb-4 text-foreground/30" />
|
|
125
|
+
<p>Start adding products</p>
|
|
126
|
+
</div>
|
|
127
|
+
) : (
|
|
128
|
+
<div className="py-2">
|
|
129
|
+
{cart?.map((item: CartItem, index: number) => {
|
|
130
|
+
const isSelected = item.id === selectedItemId;
|
|
131
|
+
// Find the product's stock level
|
|
132
|
+
const productStock = products.find((p: any) => p.medicine_id === item.medicine_id)?.stock;
|
|
133
|
+
const isOverStock = productStock !== undefined && item.quantity > productStock;
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<div
|
|
137
|
+
key={item.id}
|
|
138
|
+
onClick={() => selectItem(item.id)}
|
|
139
|
+
className={`mx-2 mb-2 p-3 rounded-xl border ${isSelected ? 'border-pos-500 bg-pos-50 dark:bg-pos-500/20 shadow-sm' : 'border-border bg-background shadow-sm'} flex justify-between items-center cursor-pointer transition-all`}
|
|
140
|
+
>
|
|
141
|
+
{/* Left: Index, Name, Info */}
|
|
142
|
+
<div className="flex-1 min-w-0 pr-3">
|
|
143
|
+
<div className="flex items-center gap-2 mb-1.5">
|
|
144
|
+
<div className="bg-surface text-foreground/70 dark:bg-gray-700 dark:text-gray-300 text-[10px] w-5 h-5 flex items-center justify-center rounded-full font-bold shrink-0">
|
|
145
|
+
{index + 1}
|
|
146
|
+
</div>
|
|
147
|
+
<span className="font-semibold text-foreground text-[13px] leading-tight truncate">
|
|
148
|
+
{item.name}
|
|
149
|
+
</span>
|
|
150
|
+
</div>
|
|
151
|
+
<div className="text-[11px] text-foreground/50 ml-7">
|
|
152
|
+
Batch: {item.batch || 'N/A'} • {formatMoney(item.unit_price, currency)}/ea
|
|
153
|
+
</div>
|
|
154
|
+
{isOverStock && (
|
|
155
|
+
<div className="text-[10px] text-orange-500 font-medium ml-7 mt-0.5">
|
|
156
|
+
⚠ Only {productStock} in stock
|
|
157
|
+
</div>
|
|
158
|
+
)}
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
{/* Right: Qty, Total, Trash */}
|
|
162
|
+
<div className="flex items-center gap-3 shrink-0">
|
|
163
|
+
<div className="flex items-center bg-background rounded-lg border border-border h-[30px] shadow-sm">
|
|
164
|
+
<button
|
|
165
|
+
onClick={(e) => {
|
|
166
|
+
e.stopPropagation();
|
|
167
|
+
updateQuantity(item.id, item.quantity - 1);
|
|
168
|
+
}}
|
|
169
|
+
className="px-2 h-full flex items-center justify-center text-foreground/60 hover:bg-surface rounded-l-lg transition-colors border-r border-border"
|
|
170
|
+
>
|
|
171
|
+
-
|
|
172
|
+
</button>
|
|
173
|
+
<span className="px-2 font-bold text-foreground min-w-[1.75rem] text-center text-sm">{item.quantity}</span>
|
|
174
|
+
<button
|
|
175
|
+
onClick={(e) => {
|
|
176
|
+
e.stopPropagation();
|
|
177
|
+
updateQuantity(item.id, item.quantity + 1);
|
|
178
|
+
}}
|
|
179
|
+
className="px-2 h-full flex items-center justify-center text-foreground/60 hover:bg-surface rounded-r-lg transition-colors border-l border-border"
|
|
180
|
+
>
|
|
181
|
+
+
|
|
182
|
+
</button>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<div className="font-bold text-foreground text-sm w-[75px] text-right">
|
|
186
|
+
{formatMoney(item.line_total, currency)}
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
<button
|
|
190
|
+
onClick={(e) => {
|
|
191
|
+
e.stopPropagation();
|
|
192
|
+
removeItem(item.id);
|
|
193
|
+
}}
|
|
194
|
+
className="text-foreground/40 hover:text-red-500 transition-colors bg-surface hover:bg-red-50 dark:hover:bg-red-900/20 p-1.5 rounded-md border border-border hover:border-red-200 dark:hover:border-red-800"
|
|
195
|
+
>
|
|
196
|
+
<Trash2 size={14} />
|
|
197
|
+
</button>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
);
|
|
201
|
+
})}
|
|
202
|
+
</div>
|
|
203
|
+
)}
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
{/* Cart Summary */}
|
|
207
|
+
<div className="border-t border-border px-4 py-3 bg-background">
|
|
208
|
+
{/* Customer info if selected */}
|
|
209
|
+
{customer && (
|
|
210
|
+
<div className="flex items-center justify-between mb-2 pb-2 border-b border-border">
|
|
211
|
+
<div className="flex items-center gap-2">
|
|
212
|
+
<User size={14} className="text-pos-500" />
|
|
213
|
+
<span className="text-sm font-medium text-foreground">{customer.name}</span>
|
|
214
|
+
</div>
|
|
215
|
+
<button onClick={() => setCustomer(null)} className="text-foreground/40 hover:text-red-500 transition-colors">
|
|
216
|
+
<X size={14} />
|
|
217
|
+
</button>
|
|
218
|
+
</div>
|
|
219
|
+
)}
|
|
220
|
+
{/* Discount info if applied */}
|
|
221
|
+
{discount.type !== 'none' && (
|
|
222
|
+
<div className="flex items-center justify-between mb-2 pb-2 border-b border-border">
|
|
223
|
+
<div className="flex items-center gap-2">
|
|
224
|
+
<Percent size={14} className="text-green-500" />
|
|
225
|
+
<span className="text-sm text-green-600 dark:text-green-400">
|
|
226
|
+
Discount ({discount.type === 'percentage' ? `${discount.value}%` : formatMoney(discount.value, currency)})
|
|
227
|
+
</span>
|
|
228
|
+
</div>
|
|
229
|
+
<div className="flex items-center gap-2">
|
|
230
|
+
<span className="text-sm font-medium text-green-600 dark:text-green-400">-{formatMoney(discount.amount, currency)}</span>
|
|
231
|
+
<button onClick={clearDiscount} className="text-foreground/40 hover:text-red-500 transition-colors">
|
|
232
|
+
<X size={14} />
|
|
233
|
+
</button>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
)}
|
|
237
|
+
<div className="flex justify-between items-center mb-1">
|
|
238
|
+
<span className="text-sm text-foreground/50">Taxes</span>
|
|
239
|
+
<span className="text-sm text-foreground/50">{formatMoney(tax || 0, currency)}</span>
|
|
240
|
+
</div>
|
|
241
|
+
<div className="flex justify-between items-center mt-1">
|
|
242
|
+
<span className="text-lg font-bold text-foreground">Total</span>
|
|
243
|
+
<span className="text-lg font-bold text-foreground">{formatMoney(total || 0, currency)}</span>
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
{/* ===== 4 ACTION BUTTONS IN ONE ROW ===== */}
|
|
248
|
+
<div className="grid grid-cols-4 border-t border-border bg-surface">
|
|
249
|
+
<button
|
|
250
|
+
onClick={() => togglePanel('customer')}
|
|
251
|
+
className={`flex flex-col items-center justify-center gap-1 py-2.5 text-xs font-medium border-r border-border transition-colors ${
|
|
252
|
+
activePanel === 'customer' ? 'text-pos-500 bg-pos-50 dark:bg-pos-500/15' : 'text-foreground/60 hover:bg-background'
|
|
253
|
+
}`}
|
|
254
|
+
>
|
|
255
|
+
<User size={18} />
|
|
256
|
+
<span>Customer</span>
|
|
257
|
+
</button>
|
|
258
|
+
<button
|
|
259
|
+
onClick={() => togglePanel('discount')}
|
|
260
|
+
className={`flex flex-col items-center justify-center gap-1 py-2.5 text-xs font-medium border-r border-border transition-colors ${
|
|
261
|
+
activePanel === 'discount' ? 'text-pos-500 bg-pos-50 dark:bg-pos-500/15' : 'text-foreground/60 hover:bg-background'
|
|
262
|
+
}`}
|
|
263
|
+
>
|
|
264
|
+
<Percent size={18} />
|
|
265
|
+
<span>Discount</span>
|
|
266
|
+
</button>
|
|
267
|
+
<button
|
|
268
|
+
onClick={() => togglePanel('hold')}
|
|
269
|
+
className={`flex flex-col items-center justify-center gap-1 py-2.5 text-xs font-medium border-r border-border transition-colors relative ${
|
|
270
|
+
activePanel === 'hold' ? 'text-pos-500 bg-pos-50 dark:bg-pos-500/15' : 'text-foreground/60 hover:bg-background'
|
|
271
|
+
}`}
|
|
272
|
+
>
|
|
273
|
+
<PauseCircle size={18} />
|
|
274
|
+
<span>Hold</span>
|
|
275
|
+
{heldSales.length > 0 && (
|
|
276
|
+
<span className="absolute top-1 right-2 bg-orange-500 text-white text-[9px] font-bold rounded-full w-4 h-4 flex items-center justify-center">
|
|
277
|
+
{heldSales.length}
|
|
278
|
+
</span>
|
|
279
|
+
)}
|
|
280
|
+
</button>
|
|
281
|
+
<button
|
|
282
|
+
onClick={() => togglePanel('keypad')}
|
|
283
|
+
className={`flex flex-col items-center justify-center gap-1 py-2.5 text-xs font-medium transition-colors ${
|
|
284
|
+
activePanel === 'keypad' ? 'text-pos-500 bg-pos-50 dark:bg-pos-500/15' : 'text-foreground/60 hover:bg-background'
|
|
285
|
+
}`}
|
|
286
|
+
>
|
|
287
|
+
<Keyboard size={18} />
|
|
288
|
+
<span>Keypad</span>
|
|
289
|
+
</button>
|
|
290
|
+
</div>
|
|
291
|
+
|
|
292
|
+
{/* ===== PANELS (slide down based on active) ===== */}
|
|
293
|
+
|
|
294
|
+
{/* --- CUSTOMER PANEL --- */}
|
|
295
|
+
{activePanel === 'customer' && (
|
|
296
|
+
<div className="border-t border-border bg-background max-h-[250px] overflow-y-auto">
|
|
297
|
+
<div className="p-2">
|
|
298
|
+
<div className="text-[11px] font-semibold text-foreground/40 uppercase tracking-wide px-2 py-1">Select Customer</div>
|
|
299
|
+
{/* Search */}
|
|
300
|
+
<div className="px-2 mb-2">
|
|
301
|
+
<input
|
|
302
|
+
type="text"
|
|
303
|
+
value={customerSearch}
|
|
304
|
+
onChange={(e) => setCustomerSearch(e.target.value)}
|
|
305
|
+
placeholder="Search customers..."
|
|
306
|
+
className="w-full bg-surface border border-border rounded-lg px-3 py-2 text-sm text-foreground placeholder:text-foreground/30 focus:outline-none focus:border-pos-400 focus:ring-1 focus:ring-pos-200"
|
|
307
|
+
/>
|
|
308
|
+
</div>
|
|
309
|
+
{/* Walk-in option */}
|
|
310
|
+
<button
|
|
311
|
+
onClick={() => { setCustomer(null); setActivePanel(null); }}
|
|
312
|
+
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors mb-1 ${
|
|
313
|
+
!customer ? 'bg-pos-50 dark:bg-pos-500/15 border border-pos-300' : 'hover:bg-surface border border-transparent'
|
|
314
|
+
}`}
|
|
315
|
+
>
|
|
316
|
+
<div className="w-8 h-8 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center">
|
|
317
|
+
<User size={16} className="text-white" />
|
|
318
|
+
</div>
|
|
319
|
+
<div className="text-left">
|
|
320
|
+
<div className="text-sm font-medium text-foreground">Walk-in Customer</div>
|
|
321
|
+
<div className="text-[11px] text-foreground/40">No customer selected</div>
|
|
322
|
+
</div>
|
|
323
|
+
{!customer && <Check size={16} className="ml-auto text-pos-500" />}
|
|
324
|
+
</button>
|
|
325
|
+
{/* Customer list */}
|
|
326
|
+
{loadingCustomers ? (
|
|
327
|
+
<div className="text-center py-4 text-sm text-foreground/40">Loading customers...</div>
|
|
328
|
+
) : filteredCustomers.length === 0 ? (
|
|
329
|
+
<div className="text-center py-4 text-sm text-foreground/40">
|
|
330
|
+
{customerSearch ? 'No customers found' : 'No customers yet'}
|
|
331
|
+
</div>
|
|
332
|
+
) : (
|
|
333
|
+
filteredCustomers.map((c) => (
|
|
334
|
+
<button
|
|
335
|
+
key={c.id}
|
|
336
|
+
onClick={() => { setCustomer({ id: c.id, name: c.name, phone: c.phone }); setActivePanel(null); }}
|
|
337
|
+
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors mb-1 ${
|
|
338
|
+
customer?.id === c.id ? 'bg-pos-50 dark:bg-pos-500/15 border border-pos-300' : 'hover:bg-surface border border-transparent'
|
|
339
|
+
}`}
|
|
340
|
+
>
|
|
341
|
+
<div className="w-8 h-8 rounded-full bg-pos-500 flex items-center justify-center text-white text-sm font-bold">
|
|
342
|
+
{c.name?.charAt(0) || '?'}
|
|
343
|
+
</div>
|
|
344
|
+
<div className="text-left flex-1 min-w-0">
|
|
345
|
+
<div className="text-sm font-medium text-foreground truncate">{c.name}</div>
|
|
346
|
+
<div className="text-[11px] text-foreground/40 flex items-center gap-1">
|
|
347
|
+
<Phone size={10} /> {c.phone || 'No phone'}
|
|
348
|
+
</div>
|
|
349
|
+
</div>
|
|
350
|
+
{(c.credit_balance || 0) > 0 && (
|
|
351
|
+
<span className="text-[11px] font-medium text-orange-500">{formatMoney(c.credit_balance, currency)}</span>
|
|
352
|
+
)}
|
|
353
|
+
{customer?.id === c.id && <Check size={16} className="text-pos-500 shrink-0" />}
|
|
354
|
+
</button>
|
|
355
|
+
))
|
|
356
|
+
)}
|
|
357
|
+
</div>
|
|
358
|
+
</div>
|
|
359
|
+
)}
|
|
360
|
+
|
|
361
|
+
{/* --- DISCOUNT PANEL --- */}
|
|
362
|
+
{activePanel === 'discount' && (
|
|
363
|
+
<div className="border-t border-border bg-background p-3">
|
|
364
|
+
<div className="text-[11px] font-semibold text-foreground/40 uppercase tracking-wide mb-2">Apply Discount</div>
|
|
365
|
+
<div className="flex gap-2 mb-3">
|
|
366
|
+
<button
|
|
367
|
+
onClick={() => setDiscountType('percentage')}
|
|
368
|
+
className={`flex-1 py-2 text-sm font-medium rounded-lg border transition-colors ${
|
|
369
|
+
discountType === 'percentage'
|
|
370
|
+
? 'bg-pos-50 dark:bg-pos-500/15 border-pos-300 text-pos-600 dark:text-pos-300'
|
|
371
|
+
: 'bg-surface border-border text-foreground/60 hover:bg-background'
|
|
372
|
+
}`}
|
|
373
|
+
>
|
|
374
|
+
Percentage (%)
|
|
375
|
+
</button>
|
|
376
|
+
<button
|
|
377
|
+
onClick={() => setDiscountType('fixed')}
|
|
378
|
+
className={`flex-1 py-2 text-sm font-medium rounded-lg border transition-colors ${
|
|
379
|
+
discountType === 'fixed'
|
|
380
|
+
? 'bg-pos-50 dark:bg-pos-500/15 border-pos-300 text-pos-600 dark:text-pos-300'
|
|
381
|
+
: 'bg-surface border-border text-foreground/60 hover:bg-background'
|
|
382
|
+
}`}
|
|
383
|
+
>
|
|
384
|
+
Fixed ({currency})
|
|
385
|
+
</button>
|
|
386
|
+
</div>
|
|
387
|
+
<div className="flex gap-2">
|
|
388
|
+
<input
|
|
389
|
+
type="number"
|
|
390
|
+
value={discountInput}
|
|
391
|
+
onChange={(e) => setDiscountInput(e.target.value)}
|
|
392
|
+
placeholder={discountType === 'percentage' ? 'Enter %' : 'Enter amount'}
|
|
393
|
+
className="flex-1 bg-surface border border-border rounded-lg px-3 py-2 text-sm text-foreground placeholder:text-foreground/30 focus:outline-none focus:border-pos-400 focus:ring-1 focus:ring-pos-200"
|
|
394
|
+
/>
|
|
395
|
+
<button
|
|
396
|
+
onClick={handleApplyDiscount}
|
|
397
|
+
className="bg-pos-500 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-pos-600 transition-colors"
|
|
398
|
+
>
|
|
399
|
+
Apply
|
|
400
|
+
</button>
|
|
401
|
+
</div>
|
|
402
|
+
{discount.type !== 'none' && (
|
|
403
|
+
<button
|
|
404
|
+
onClick={() => { clearDiscount(); setActivePanel(null); }}
|
|
405
|
+
className="w-full mt-2 py-1.5 text-xs text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
|
406
|
+
>
|
|
407
|
+
Remove Current Discount
|
|
408
|
+
</button>
|
|
409
|
+
)}
|
|
410
|
+
</div>
|
|
411
|
+
)}
|
|
412
|
+
|
|
413
|
+
{/* --- HOLD PANEL --- */}
|
|
414
|
+
{activePanel === 'hold' && (
|
|
415
|
+
<div className="border-t border-border bg-background max-h-[220px] overflow-y-auto p-3">
|
|
416
|
+
{/* Hold current sale */}
|
|
417
|
+
{cart.length > 0 && (
|
|
418
|
+
<div className="mb-3 pb-3 border-b border-border">
|
|
419
|
+
<div className="text-[11px] font-semibold text-foreground/40 uppercase tracking-wide mb-2">Hold Current Sale</div>
|
|
420
|
+
<div className="flex gap-2">
|
|
421
|
+
<input
|
|
422
|
+
type="text"
|
|
423
|
+
value={holdNote}
|
|
424
|
+
onChange={(e) => setHoldNote(e.target.value)}
|
|
425
|
+
placeholder="Add a note (optional)"
|
|
426
|
+
className="flex-1 bg-surface border border-border rounded-lg px-3 py-2 text-sm text-foreground placeholder:text-foreground/30 focus:outline-none focus:border-pos-400"
|
|
427
|
+
/>
|
|
428
|
+
<button
|
|
429
|
+
onClick={handleHoldSale}
|
|
430
|
+
className="bg-orange-500 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-orange-600 transition-colors flex items-center gap-1.5"
|
|
431
|
+
>
|
|
432
|
+
<PauseCircle size={16} /> Hold
|
|
433
|
+
</button>
|
|
434
|
+
</div>
|
|
435
|
+
</div>
|
|
436
|
+
)}
|
|
437
|
+
{/* Held sales list */}
|
|
438
|
+
<div className="text-[11px] font-semibold text-foreground/40 uppercase tracking-wide mb-2">
|
|
439
|
+
Held Sales ({heldSales.length})
|
|
440
|
+
</div>
|
|
441
|
+
{heldSales.length === 0 ? (
|
|
442
|
+
<p className="text-sm text-foreground/30 text-center py-3">No held sales</p>
|
|
443
|
+
) : (
|
|
444
|
+
heldSales.map((sale: HeldSale) => (
|
|
445
|
+
<div key={sale.id} className="flex items-center justify-between bg-surface rounded-lg p-2.5 mb-2 border border-border">
|
|
446
|
+
<div className="flex-1 min-w-0">
|
|
447
|
+
<div className="text-sm font-medium text-foreground truncate">
|
|
448
|
+
{sale.note || sale.id}
|
|
449
|
+
</div>
|
|
450
|
+
<div className="text-[11px] text-foreground/40">
|
|
451
|
+
{sale.cart.length} items • {formatMoney(sale.total, currency)}
|
|
452
|
+
</div>
|
|
453
|
+
</div>
|
|
454
|
+
<div className="flex items-center gap-1.5 shrink-0 ml-2">
|
|
455
|
+
<button
|
|
456
|
+
onClick={() => { resumeSale(sale.id); setActivePanel(null); }}
|
|
457
|
+
className="p-1.5 rounded-md bg-pos-500 text-white hover:bg-pos-600 transition-colors"
|
|
458
|
+
title="Resume"
|
|
459
|
+
>
|
|
460
|
+
<Play size={14} />
|
|
461
|
+
</button>
|
|
462
|
+
<button
|
|
463
|
+
onClick={() => deleteHeldSale(sale.id)}
|
|
464
|
+
className="p-1.5 rounded-md bg-red-500 text-white hover:bg-red-600 transition-colors"
|
|
465
|
+
title="Delete"
|
|
466
|
+
>
|
|
467
|
+
<Trash2 size={14} />
|
|
468
|
+
</button>
|
|
469
|
+
</div>
|
|
470
|
+
</div>
|
|
471
|
+
))
|
|
472
|
+
)}
|
|
473
|
+
</div>
|
|
474
|
+
)}
|
|
475
|
+
|
|
476
|
+
{/* --- KEYPAD PANEL --- */}
|
|
477
|
+
{activePanel === 'keypad' && (
|
|
478
|
+
<div className="bg-background border-t border-border p-2">
|
|
479
|
+
<div className="text-center text-xs font-medium text-foreground/50 mb-2">
|
|
480
|
+
Editing: {keypadMode === 'qty' ? 'Quantity' : keypadMode === 'price' ? 'Price' : 'Discount'}
|
|
481
|
+
</div>
|
|
482
|
+
<div className="grid grid-cols-4 gap-1.5">
|
|
483
|
+
<button onClick={() => handleNumpadPress('1')} className="h-[48px] bg-background border border-border rounded-lg text-[18px] font-semibold text-foreground hover:bg-surface active:bg-border">1</button>
|
|
484
|
+
<button onClick={() => handleNumpadPress('2')} className="h-[48px] bg-background border border-border rounded-lg text-[18px] font-semibold text-foreground hover:bg-surface active:bg-border">2</button>
|
|
485
|
+
<button onClick={() => handleNumpadPress('3')} className="h-[48px] bg-background border border-border rounded-lg text-[18px] font-semibold text-foreground hover:bg-surface active:bg-border">3</button>
|
|
486
|
+
<button onClick={() => handleModeChange('qty')} className={`h-[48px] rounded-lg text-[18px] font-semibold border ${keypadMode === 'qty' ? 'bg-pos-50 dark:bg-pos-500/15 border-pos-300 text-pos-700 dark:text-pos-300 font-bold' : 'bg-background border-border text-foreground hover:bg-surface active:bg-border'}`}>Qty</button>
|
|
487
|
+
|
|
488
|
+
<button onClick={() => handleNumpadPress('4')} className="h-[48px] bg-background border border-border rounded-lg text-[18px] font-semibold text-foreground hover:bg-surface active:bg-border">4</button>
|
|
489
|
+
<button onClick={() => handleNumpadPress('5')} className="h-[48px] bg-background border border-border rounded-lg text-[18px] font-semibold text-foreground hover:bg-surface active:bg-border">5</button>
|
|
490
|
+
<button onClick={() => handleNumpadPress('6')} className="h-[48px] bg-background border border-border rounded-lg text-[18px] font-semibold text-foreground hover:bg-surface active:bg-border">6</button>
|
|
491
|
+
<button onClick={() => handleModeChange('discount')} className={`h-[48px] rounded-lg text-[18px] font-semibold border ${keypadMode === 'discount' ? 'bg-pos-50 dark:bg-pos-500/15 border-pos-300 text-pos-700 dark:text-pos-300 font-bold' : 'bg-background border-border text-foreground hover:bg-surface active:bg-border'}`}>%</button>
|
|
492
|
+
|
|
493
|
+
<button onClick={() => handleNumpadPress('7')} className="h-[48px] bg-background border border-border rounded-lg text-[18px] font-semibold text-foreground hover:bg-surface active:bg-border">7</button>
|
|
494
|
+
<button onClick={() => handleNumpadPress('8')} className="h-[48px] bg-background border border-border rounded-lg text-[18px] font-semibold text-foreground hover:bg-surface active:bg-border">8</button>
|
|
495
|
+
<button onClick={() => handleNumpadPress('9')} className="h-[48px] bg-background border border-border rounded-lg text-[18px] font-semibold text-foreground hover:bg-surface active:bg-border">9</button>
|
|
496
|
+
<button onClick={() => handleModeChange('price')} className={`h-[48px] rounded-lg text-[18px] font-semibold border ${keypadMode === 'price' ? 'bg-pos-50 dark:bg-pos-500/15 border-pos-300 text-pos-700 dark:text-pos-300 font-bold' : 'bg-background border-border text-foreground hover:bg-surface active:bg-border'}`}>Price</button>
|
|
497
|
+
|
|
498
|
+
<button onClick={handleToggleSign} className="h-[48px] bg-[#21B573] rounded-lg text-[18px] font-bold text-white hover:bg-[#1DA065] active:bg-[#198E59]">+/-</button>
|
|
499
|
+
<button onClick={() => handleNumpadPress('0')} className="h-[48px] bg-background border border-border rounded-lg text-[18px] font-semibold text-foreground hover:bg-surface active:bg-border">0</button>
|
|
500
|
+
<button onClick={() => handleNumpadPress('.')} className="h-[48px] bg-background border border-border rounded-lg text-[18px] font-semibold text-foreground hover:bg-surface active:bg-border">.</button>
|
|
501
|
+
<button onClick={handleBackspace} className="h-[48px] bg-[#E8913A] rounded-lg text-[18px] font-bold text-white hover:bg-[#D68130] active:bg-[#C27328] flex items-center justify-center">
|
|
502
|
+
<Delete size={20} />
|
|
503
|
+
</button>
|
|
504
|
+
</div>
|
|
505
|
+
</div>
|
|
506
|
+
)}
|
|
507
|
+
|
|
508
|
+
{/* Payment Button */}
|
|
509
|
+
<div className="p-2 md:p-3 bg-surface border-t border-border">
|
|
510
|
+
<button
|
|
511
|
+
onClick={onPayment}
|
|
512
|
+
disabled={!cart || cart.length === 0}
|
|
513
|
+
className={`w-full py-4 rounded-lg flex items-center justify-center gap-2 text-lg font-bold transition-colors ${
|
|
514
|
+
!cart || cart.length === 0
|
|
515
|
+
? 'bg-border text-foreground/40 cursor-not-allowed'
|
|
516
|
+
: 'bg-pos-500 text-white hover:bg-pos-600'
|
|
517
|
+
}`}
|
|
518
|
+
>
|
|
519
|
+
<CreditCard size={24} />
|
|
520
|
+
Payment
|
|
521
|
+
</button>
|
|
522
|
+
</div>
|
|
523
|
+
</div>
|
|
524
|
+
);
|
|
525
|
+
}
|