449 lines
26 KiB
JavaScript
449 lines
26 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
|
import {
|
|
Search,
|
|
ShoppingCart,
|
|
Plus,
|
|
Minus,
|
|
Trash2,
|
|
CreditCard,
|
|
Banknote,
|
|
Globe,
|
|
Calendar,
|
|
CheckCircle2,
|
|
MapPin
|
|
} from 'lucide-react';
|
|
import Toast from '../Owner/Components/Toast';
|
|
|
|
export default function POS() {
|
|
const [products, setProducts] = useState([]);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [cart, setCart] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [paymentMethod, setPaymentMethod] = useState('');
|
|
const [paymentMethods, setPaymentMethods] = useState([]);
|
|
const [processing, setProcessing] = useState(false);
|
|
const [success, setSuccess] = useState(false);
|
|
const [adjustedTotal, setAdjustedTotal] = useState('');
|
|
const [adjustmentRemarks, setAdjustmentRemarks] = useState('');
|
|
const [branches, setBranches] = useState([]);
|
|
const [selectedBranch, setSelectedBranch] = useState(window.__APP_DATA__?.user?.branch_id || '');
|
|
const [toast, setToast] = useState(null);
|
|
const [showSuccessModal, setShowSuccessModal] = useState(false);
|
|
|
|
const showToast = (message, type = 'success') => {
|
|
setToast({ message, type });
|
|
setTimeout(() => setToast(null), 3000);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (window.__APP_DATA__?.role === 'owner') {
|
|
const fetchBranches = async () => {
|
|
try {
|
|
const response = await fetch('/api/branches?status=Active');
|
|
const data = await response.json();
|
|
setBranches(data || []);
|
|
if (data?.length > 0 && !selectedBranch) {
|
|
setSelectedBranch(data[0].id);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching branches:', error);
|
|
}
|
|
};
|
|
fetchBranches();
|
|
}
|
|
|
|
const fetchPaymentMethods = async () => {
|
|
try {
|
|
const res = await fetch('/api/masters/payment_method');
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
const activeOnes = data.filter(m => m.status === 'Active');
|
|
setPaymentMethods(activeOnes);
|
|
if (activeOnes.length > 0 && !paymentMethod) {
|
|
setPaymentMethod(activeOnes[0].name);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching payment methods:', error);
|
|
}
|
|
};
|
|
fetchPaymentMethods();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const fetchProducts = async () => {
|
|
if (!selectedBranch && window.__APP_DATA__?.role !== 'owner') return;
|
|
setLoading(true);
|
|
try {
|
|
const url = `/api/inventory/products?branch_id=${selectedBranch}`;
|
|
const response = await fetch(url);
|
|
const data = await response.json();
|
|
setProducts(data || []);
|
|
} catch (error) {
|
|
console.error('Error fetching products:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchProducts();
|
|
}, [selectedBranch]);
|
|
|
|
const subtotal = cart.reduce((sum, item) => sum + (item.selling_price * item.quantity), 0);
|
|
const vatAmount = subtotal * 0.05;
|
|
const totalWithVat = subtotal + vatAmount;
|
|
|
|
// Sync adjusted total when base total changes, if not manually edited
|
|
useEffect(() => {
|
|
setAdjustedTotal(totalWithVat.toFixed(2));
|
|
}, [totalWithVat]);
|
|
|
|
const filteredProducts = products.filter(p =>
|
|
p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
(p.category?.name || '').toLowerCase().includes(searchQuery.toLowerCase())
|
|
);
|
|
|
|
const addToCart = (product) => {
|
|
setCart(prev => {
|
|
const existing = prev.find(item => item.id === product.id);
|
|
if (existing) {
|
|
if (existing.quantity >= product.current_stock) {
|
|
showToast(`Only ${product.current_stock} units available in stock.`, 'error');
|
|
return prev;
|
|
}
|
|
return prev.map(item =>
|
|
item.id === product.id ? { ...item, quantity: item.quantity + 1 } : item
|
|
);
|
|
}
|
|
if (product.current_stock <= 0) {
|
|
showToast("Product is out of stock.", 'error');
|
|
return prev;
|
|
}
|
|
return [...prev, { ...product, quantity: 1 }];
|
|
});
|
|
};
|
|
|
|
const updateQuantity = (item, delta) => {
|
|
setCart(prev => prev.map(cartItem => {
|
|
if (cartItem.id === item.id) {
|
|
const newQty = cartItem.quantity + delta;
|
|
if (newQty > item.current_stock) {
|
|
showToast(`Only ${item.current_stock} units available in stock.`, 'error');
|
|
return cartItem;
|
|
}
|
|
return { ...cartItem, quantity: Math.max(1, newQty) };
|
|
}
|
|
return cartItem;
|
|
}));
|
|
};
|
|
|
|
const removeFromCart = (id) => {
|
|
setCart(prev => prev.filter(item => item.id !== id));
|
|
};
|
|
|
|
const handleProcessPayment = async () => {
|
|
if (cart.length === 0) return;
|
|
|
|
if (!paymentMethod) {
|
|
showToast("Please select a mode of payment before processing.", 'error');
|
|
return;
|
|
}
|
|
|
|
setProcessing(true);
|
|
try {
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
|
const response = await fetch('/api/inventory/sales', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': csrfToken
|
|
},
|
|
body: JSON.stringify({
|
|
date: new Date().toISOString().split('T')[0],
|
|
payment_method: paymentMethod,
|
|
branch_id: selectedBranch,
|
|
total_amount: parseFloat(adjustedTotal),
|
|
remarks: parseFloat(adjustedTotal) !== totalWithVat ? adjustmentRemarks : '',
|
|
items: cart.map(item => ({
|
|
product_id: item.id,
|
|
quantity: item.quantity,
|
|
unit_price: item.selling_price
|
|
}))
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
setSuccess(true);
|
|
setShowSuccessModal(true);
|
|
setCart([]);
|
|
setAdjustmentRemarks('');
|
|
setTimeout(() => setSuccess(false), 3000);
|
|
}
|
|
} catch (error) {
|
|
console.error('Payment failed:', error);
|
|
} finally {
|
|
setProcessing(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{toast && <Toast message={toast.message} type={toast.type} onClose={() => setToast(null)} />}
|
|
<main className="px-6 py-6 max-w-[1600px] mx-auto grid grid-cols-1 lg:grid-cols-12 gap-6 animate-in fade-in duration-500">
|
|
{/* Product Catalog Column */}
|
|
<div className="lg:col-span-8 space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<h1 className="text-xl font-black text-gray-900 tracking-tight">Product Catalog</h1>
|
|
{window.__APP_DATA__?.role === 'owner' && (
|
|
<select
|
|
className="ml-4 px-4 py-2 bg-white border border-gray-100 rounded-xl outline-none font-black text-[10px] uppercase tracking-widest cursor-pointer shadow-sm focus:ring-2 focus:ring-red-500/10"
|
|
value={selectedBranch}
|
|
onChange={e => {
|
|
setSelectedBranch(e.target.value);
|
|
setCart([]);
|
|
}}
|
|
>
|
|
{branches.map(b => <option key={b.id} value={b.id}>{b.name}</option>)}
|
|
</select>
|
|
)}
|
|
</div>
|
|
<div className="px-3 py-1.5 bg-gray-50 border border-gray-100 rounded-full text-[9px] font-black uppercase tracking-widest text-gray-400 flex items-center gap-2">
|
|
<MapPin size={10} className="text-red-400" /> {branches.find(b => b.id.toString() === selectedBranch.toString())?.name || window.__APP_DATA__?.branch?.name || 'Loading...'}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Styled Search Bar */}
|
|
<div className="relative group">
|
|
<Search className="absolute left-5 top-1/2 -translate-y-1/2 text-gray-400 group-focus-within:text-red-500 transition-colors" size={18} />
|
|
<input
|
|
type="text"
|
|
placeholder="Search products..."
|
|
className="w-full bg-white border border-gray-100 py-3.5 pl-14 pr-8 rounded-[1rem] shadow-sm focus:outline-none focus:ring-4 focus:ring-red-500/5 focus:border-red-500/30 transition-all font-medium text-xs placeholder:text-gray-300"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Products Grid */}
|
|
{loading ? (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 animate-pulse">
|
|
{[1, 2, 3, 4, 5, 6, 7, 8].map(i => (
|
|
<div key={i} className="h-[180px] bg-gray-50 rounded-[1.5rem]"></div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3 content-start max-h-[78vh] overflow-y-auto pr-2 no-scrollbar pb-10">
|
|
{filteredProducts.map(product => (
|
|
<div key={product.id} className="bg-white p-3 rounded-[20px] border border-gray-100 shadow-sm hover:shadow-md hover:border-red-100 transition-all group flex flex-col h-full">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex-1 min-w-0">
|
|
<h4 className="font-black text-gray-900 text-xs leading-snug truncate" title={product.name}>{product.name}</h4>
|
|
<p className="text-[9px] text-gray-400 font-bold uppercase tracking-wider mt-0.5 truncate">{product.category?.name}</p>
|
|
</div>
|
|
<span className={`px-1.5 py-0.5 rounded-md text-[8px] font-black whitespace-nowrap ${product.current_stock > 0 ? 'bg-emerald-50 text-emerald-600' : 'bg-red-50 text-red-600'}`}>
|
|
{product.current_stock} left
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center justify-between mt-4">
|
|
<span className="text-sm font-black text-[#FF4D4D]">{parseFloat(product.selling_price).toFixed(2)}</span>
|
|
<button
|
|
disabled={product.current_stock <= 0}
|
|
onClick={() => addToCart(product)}
|
|
className="px-3 py-1.5 bg-gray-50 text-gray-900 border border-transparent rounded-lg font-black text-[9px] uppercase tracking-widest hover:bg-[#FF4D4D] hover:text-white transition-all disabled:opacity-30 disabled:hover:bg-gray-50 disabled:hover:text-gray-900"
|
|
>
|
|
Add
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right Sidebar: Current Order */}
|
|
<div className="lg:col-span-4 h-full">
|
|
<div className="bg-white rounded-[1.5rem] border border-gray-100 shadow-2xl overflow-hidden flex flex-col h-[calc(100vh-8rem)] sticky top-6">
|
|
{/* Header */}
|
|
<div className="p-5 border-b border-gray-50 bg-gray-50/10 flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<ShoppingCart size={18} className="text-gray-900" />
|
|
<h2 className="text-lg font-black text-gray-900 tracking-tight">Current Order</h2>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Billing Date Section */}
|
|
<div className="p-5 border-b border-gray-50 bg-white">
|
|
<div className="space-y-2">
|
|
<label className="text-[8px] font-black text-gray-400 uppercase tracking-[0.2rem] block ml-1">Billing Date</label>
|
|
<div className="relative">
|
|
<input
|
|
type="text"
|
|
value={new Date().toLocaleDateString('en-GB').replace(/\//g, '-')}
|
|
readOnly
|
|
className="w-full bg-white border border-gray-100 py-3 pl-5 pr-10 rounded-xl text-xs font-bold text-gray-900 focus:outline-none cursor-default"
|
|
/>
|
|
<Calendar className="absolute right-5 top-1/2 -translate-y-1/2 text-gray-400" size={16} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Cart Content */}
|
|
<div className="flex-1 overflow-y-auto p-5 space-y-3 pr-2 no-scrollbar min-h-0">
|
|
{cart.length === 0 ? (
|
|
<div className="h-full flex flex-col items-center justify-center text-center space-y-3 animate-in fade-in duration-500">
|
|
<div className="w-16 h-16 bg-gray-50 rounded-full flex items-center justify-center text-gray-200">
|
|
<ShoppingCart size={32} />
|
|
</div>
|
|
<p className="text-[10px] font-bold text-gray-300 uppercase tracking-widest">Cart is empty</p>
|
|
</div>
|
|
) : (
|
|
cart.map(item => (
|
|
<div key={item.id} className="p-3 bg-gray-50/50 rounded-xl border border-gray-100 group">
|
|
<div className="flex items-start justify-between">
|
|
<p className="font-bold text-gray-900 text-xs flex-1 leading-tight">{item.name}</p>
|
|
<button onClick={() => removeFromCart(item.id)} className="text-gray-300 hover:text-red-500 transition-colors ml-2">
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</div>
|
|
<div className="flex items-center justify-between mt-3">
|
|
<div className="flex items-center gap-2 bg-white border border-gray-100 rounded-lg p-0.5">
|
|
<button onClick={() => updateQuantity(item, -1)} className="p-1 hover:bg-gray-50 rounded"><Minus size={12} /></button>
|
|
<span className="text-[10px] font-black min-w-[16px] text-center">{item.quantity}</span>
|
|
<button onClick={() => updateQuantity(item, 1)} className="p-1 hover:bg-gray-50 rounded"><Plus size={12} /></button>
|
|
</div>
|
|
<p className="font-black text-gray-900 text-xs">{parseFloat(item.selling_price * item.quantity).toFixed(2)}</p>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{/* Summary & Footer */}
|
|
<div className="p-5 border-t border-gray-50 bg-white space-y-4 shadow-[0_-15px_30px_-15px_rgba(0,0,0,0.05)]">
|
|
<div className="space-y-2.5">
|
|
<div className="flex items-center justify-between px-1">
|
|
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Sub-Total</p>
|
|
<p className="text-xs font-black text-gray-900">{subtotal.toFixed(2)} AED</p>
|
|
</div>
|
|
<div className="flex items-center justify-between px-1">
|
|
<div className="flex items-center gap-1">
|
|
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">VAT</p>
|
|
<span className="px-1.5 py-0.5 bg-red-50 text-red-500 rounded text-[9px] font-black">5%</span>
|
|
</div>
|
|
<p className="text-xs font-black text-gray-900">{vatAmount.toFixed(2)} AED</p>
|
|
</div>
|
|
<div className="flex items-center justify-between pt-2 pb-1 px-1 border-t border-dashed border-gray-200">
|
|
<p className="text-[11px] font-black text-emerald-600 uppercase tracking-widest">Total Amount</p>
|
|
<p className="text-sm font-black text-emerald-600">{totalWithVat.toFixed(2)} AED</p>
|
|
</div>
|
|
<div className="space-y-1 pt-1">
|
|
<label className="text-[9px] font-black text-red-500 uppercase tracking-[0.15rem] block ml-1">Adjusted Total</label>
|
|
<div className="relative group">
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
value={adjustedTotal}
|
|
onChange={(e) => setAdjustedTotal(e.target.value)}
|
|
className="w-full bg-white border-2 border-red-50 py-1.5 px-3 rounded-lg text-sm font-black text-gray-900 focus:outline-none focus:border-red-200 transition-all shadow-inner"
|
|
/>
|
|
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-[9px] font-black text-gray-400 uppercase tracking-widest">AED</span>
|
|
</div>
|
|
</div>
|
|
|
|
{parseFloat(adjustedTotal) !== totalWithVat && (
|
|
<div className="space-y-1 animate-in slide-in-from-top-2 duration-300">
|
|
<label className="text-[8px] font-black text-amber-500 uppercase tracking-widest block ml-1">Adjustment Remarks *</label>
|
|
<textarea
|
|
rows="1"
|
|
placeholder="Why is adjusted?"
|
|
value={adjustmentRemarks}
|
|
onChange={(e) => setAdjustmentRemarks(e.target.value)}
|
|
maxLength={120}
|
|
className="w-full bg-amber-50/30 border border-amber-100 py-1.5 px-3 rounded-lg text-xs font-bold text-gray-900 focus:outline-none focus:border-amber-300 transition-all resize-none shadow-sm min-h-[40px]"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-3 gap-1.5 pt-1">
|
|
{paymentMethods.map(method => (
|
|
<button
|
|
key={method.id}
|
|
onClick={() => setPaymentMethod(method.name)}
|
|
className={`py-1.5 px-1 rounded-lg flex flex-col items-center gap-1 border transition-all ${
|
|
paymentMethod === method.name
|
|
? 'bg-red-500 border-red-500 text-white shadow-md shadow-red-100'
|
|
: 'bg-white border-gray-100 text-gray-400 hover:border-red-200'
|
|
}`}
|
|
>
|
|
<div className="scale-75">
|
|
{method.name.toLowerCase().includes('cash') && <Banknote size={14} />}
|
|
{method.name.toLowerCase().includes('card') && <CreditCard size={14} />}
|
|
{!method.name.toLowerCase().includes('cash') && !method.name.toLowerCase().includes('card') && <Globe size={14} />}
|
|
</div>
|
|
<span className="text-[8px] font-black uppercase tracking-tight leading-none">{method.name}</span>
|
|
</button>
|
|
))}
|
|
{paymentMethods.length === 0 && (
|
|
['Cash', 'Card', 'Online'].map(method => (
|
|
<button
|
|
key={method}
|
|
onClick={() => setPaymentMethod(method)}
|
|
className={`py-1.5 px-1 rounded-lg flex flex-col items-center gap-1 border transition-all ${
|
|
paymentMethod === method
|
|
? 'bg-red-500 border-red-500 text-white shadow-md shadow-red-100'
|
|
: 'bg-white border-gray-100 text-gray-400 hover:border-red-200'
|
|
}`}
|
|
>
|
|
<span className="text-[8px] font-black uppercase tracking-tight leading-none">{method}</span>
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
<button
|
|
disabled={cart.length === 0 || processing || (parseFloat(adjustedTotal) !== totalWithVat && !adjustmentRemarks.trim())}
|
|
onClick={handleProcessPayment}
|
|
className={`w-full py-2.5 mt-2 rounded-lg font-black uppercase tracking-[0.15em] text-[10px] text-white shadow-lg transition-all flex items-center justify-center gap-2 active:scale-95 ${
|
|
success ? 'bg-emerald-500 shadow-emerald-200' : 'bg-[#EF4444] hover:bg-red-600 shadow-red-200'
|
|
} disabled:opacity-50 disabled:scale-100 disabled:shadow-none`}
|
|
>
|
|
{processing ? (
|
|
<div className="animate-spin rounded-full h-5 w-5 border-2 border-white/20 border-t-white"></div>
|
|
) : success ? (
|
|
<>
|
|
<CheckCircle2 size={20} />
|
|
Success
|
|
</>
|
|
) : (
|
|
'Process Payment'
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
{/* Success Modal */}
|
|
{showSuccessModal && (
|
|
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-300">
|
|
<div className="bg-white rounded-[2.5rem] p-10 max-w-md w-full mx-4 shadow-2xl border border-gray-100 animate-in zoom-in-95 duration-300 text-center">
|
|
<div className="w-20 h-20 bg-emerald-50 rounded-full flex items-center justify-center mx-auto mb-6 text-emerald-500 shadow-inner">
|
|
<CheckCircle2 size={40} />
|
|
</div>
|
|
<h3 className="text-2xl font-black text-gray-900 mb-2">Sale Successful!</h3>
|
|
<p className="text-gray-500 font-bold mb-8">The transaction has been processed and recorded successfully.</p>
|
|
<button
|
|
onClick={() => setShowSuccessModal(false)}
|
|
className="w-full py-4 bg-gray-900 text-white rounded-2xl font-black uppercase tracking-widest text-xs hover:bg-gray-800 transition-all active:scale-95 shadow-lg shadow-gray-200"
|
|
>
|
|
Got it, thanks!
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|