416 lines
23 KiB
JavaScript
416 lines
23 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
|
import { X, Save, Plus, Search, Trash2, Calendar, Building, CreditCard, Tag, ShoppingBag } from 'lucide-react';
|
|
|
|
export default function AddCollectionModal({ isOpen, onClose, onSave, branches, types }) {
|
|
const [formData, setFormData] = useState({
|
|
date: new Date().toISOString().split('T')[0],
|
|
branch_id: '',
|
|
collection_type_id: '',
|
|
payment_method: 'Cash',
|
|
amount: '',
|
|
remarks: '',
|
|
items: []
|
|
});
|
|
|
|
const isReceptionist = window.__APP_DATA__?.role === 'receptionist';
|
|
const receptionistBranchId = window.__APP_DATA__?.branch?.id;
|
|
|
|
const [products, setProducts] = useState([]);
|
|
const [loadingProducts, setLoadingProducts] = useState(false);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [showProductSearch, setShowProductSearch] = useState(false);
|
|
const [paymentMethods, setPaymentMethods] = useState([]);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (isReceptionist && receptionistBranchId) {
|
|
setFormData(prev => ({ ...prev, branch_id: receptionistBranchId }));
|
|
} else if (branches.length > 0 && !formData.branch_id) {
|
|
setFormData(prev => ({ ...prev, branch_id: branches[0].id }));
|
|
}
|
|
}, [branches, isReceptionist, receptionistBranchId]);
|
|
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
fetchPaymentMethods();
|
|
if (formData.branch_id) {
|
|
fetchProducts();
|
|
}
|
|
}
|
|
}, [isOpen, formData.branch_id]);
|
|
|
|
const fetchPaymentMethods = async () => {
|
|
try {
|
|
const res = await fetch('/api/masters/payment_method');
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setPaymentMethods(data.filter(m => m.status === 'Active'));
|
|
// Set default if current is not in active methods or if it's the default 'Cash'
|
|
if (data.length > 0 && !data.find(m => m.name === formData.payment_method)) {
|
|
setFormData(prev => ({ ...prev, payment_method: data[0].name }));
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching payment methods:', error);
|
|
}
|
|
};
|
|
|
|
const fetchProducts = async () => {
|
|
setLoadingProducts(true);
|
|
try {
|
|
const res = await fetch(`/api/inventory/products?branch_id=${formData.branch_id}`);
|
|
if (res.ok) {
|
|
setProducts(await res.json());
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching products:', error);
|
|
} finally {
|
|
setLoadingProducts(false);
|
|
}
|
|
};
|
|
|
|
if (!isOpen) return null;
|
|
|
|
const selectedType = types.find(t => t.id.toString() === formData.collection_type_id.toString());
|
|
const isProductSale = selectedType && (
|
|
selectedType.name.toLowerCase().includes('product sale') ||
|
|
selectedType.name.toLowerCase().includes('product saled')
|
|
);
|
|
|
|
const addItem = (product) => {
|
|
if (product.current_stock <= 0) {
|
|
alert('This product is out of stock in the selected branch.');
|
|
return;
|
|
}
|
|
|
|
const existing = formData.items.find(i => i.product_id === product.id);
|
|
let newItems;
|
|
if (existing) {
|
|
if (existing.quantity + 1 > product.current_stock) {
|
|
alert(`Only ${product.current_stock} units available in stock.`);
|
|
return;
|
|
}
|
|
newItems = formData.items.map(i =>
|
|
i.product_id === product.id ? { ...i, quantity: i.quantity + 1 } : i
|
|
);
|
|
} else {
|
|
newItems = [...formData.items, {
|
|
product_id: product.id,
|
|
name: product.name,
|
|
unit_price: product.selling_price,
|
|
quantity: 1,
|
|
max_stock: product.current_stock // Store for easy validation
|
|
}];
|
|
}
|
|
|
|
const newAmount = newItems.reduce((sum, i) => sum + (i.quantity * i.unit_price), 0);
|
|
setFormData({ ...formData, items: newItems, amount: newAmount.toString() });
|
|
setShowProductSearch(false);
|
|
};
|
|
|
|
const updateItemQty = (productId, delta) => {
|
|
const item = formData.items.find(i => i.product_id === productId);
|
|
if (delta > 0 && item.quantity + delta > item.max_stock) {
|
|
alert(`Only ${item.max_stock} units available in stock.`);
|
|
return;
|
|
}
|
|
|
|
const newItems = formData.items.map(i => {
|
|
if (i.product_id === productId) {
|
|
return { ...i, quantity: Math.max(1, i.quantity + delta) };
|
|
}
|
|
return i;
|
|
});
|
|
const newAmount = newItems.reduce((sum, i) => sum + (i.quantity * i.unit_price), 0);
|
|
setFormData({ ...formData, items: newItems, amount: newAmount.toString() });
|
|
};
|
|
|
|
const removeItem = (productId) => {
|
|
const newItems = formData.items.filter(i => i.product_id !== productId);
|
|
const newAmount = newItems.reduce((sum, i) => sum + (i.quantity * i.unit_price), 0);
|
|
setFormData({ ...formData, items: newItems, amount: newAmount.toString() });
|
|
};
|
|
|
|
const handleSubmit = async (e) => {
|
|
if (e) e.preventDefault();
|
|
|
|
if (isProductSale && formData.items.length === 0) {
|
|
alert('Please add at least one product for a Product Sale entry.');
|
|
return;
|
|
}
|
|
|
|
if (!formData.amount || parseFloat(formData.amount) <= 0) {
|
|
alert('Please enter a valid amount.');
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
try {
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
|
const res = await fetch('/api/collections', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': csrfToken
|
|
},
|
|
body: JSON.stringify(formData)
|
|
});
|
|
if (res.ok) {
|
|
const newCollection = await res.json();
|
|
onSave(newCollection);
|
|
setFormData({
|
|
date: new Date().toISOString().split('T')[0],
|
|
branch_id: window.__APP_DATA__?.role === 'receptionist' ? window.__APP_DATA__?.branch?.id : (branches[0]?.id || ''),
|
|
collection_type_id: '',
|
|
payment_method: 'Cash',
|
|
amount: '',
|
|
remarks: '',
|
|
items: []
|
|
});
|
|
onClose();
|
|
}
|
|
} catch (error) {
|
|
console.error('Error adding collection:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const filteredProducts = products.filter(p =>
|
|
p.name.toLowerCase().includes(searchTerm.toLowerCase())
|
|
);
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-[#00171F]/40 backdrop-blur-sm z-[999] flex items-center justify-center p-4">
|
|
<div className="bg-white rounded-[32px] w-full max-w-2xl overflow-hidden shadow-2xl animate-in zoom-in-95 duration-300 flex flex-col max-h-[90vh]">
|
|
<div className="p-8 border-b border-gray-100 flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<div className="w-12 h-12 bg-red-50 rounded-2xl flex items-center justify-center text-red-600">
|
|
<Plus size={24} />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-2xl font-black text-gray-900 tracking-tight">New Collection Entry</h2>
|
|
<p className="text-xs text-gray-500 font-bold uppercase tracking-widest mt-0.5">Record incoming revenue.</p>
|
|
</div>
|
|
</div>
|
|
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl transition-colors">
|
|
<X size={24} className="text-gray-400" />
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="p-8 space-y-6 overflow-y-auto">
|
|
<div className="grid grid-cols-2 gap-6">
|
|
<div>
|
|
<label className="block text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">Date</label>
|
|
<input
|
|
required type="date"
|
|
className="w-full px-6 py-4 bg-gray-50/50 border border-gray-100 rounded-2xl outline-none focus:ring-2 focus:ring-red-500/10 focus:border-red-500 transition-all font-bold text-gray-900"
|
|
value={formData.date}
|
|
onChange={e => setFormData({...formData, date: e.target.value})}
|
|
/>
|
|
</div>
|
|
|
|
{!isReceptionist ? (
|
|
<div>
|
|
<label className="block text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">Branch</label>
|
|
<select
|
|
required
|
|
className="w-full px-6 py-4 bg-gray-50/50 border border-gray-100 rounded-2xl outline-none focus:ring-2 focus:ring-red-500/10 focus:border-red-500 transition-all font-bold text-gray-900 appearance-none"
|
|
value={formData.branch_id}
|
|
onChange={e => setFormData({...formData, branch_id: e.target.value, items: [], amount: ''})}
|
|
>
|
|
<option value="">Select Branch</option>
|
|
{branches.map(b => <option key={b.id} value={b.id}>{b.name}</option>)}
|
|
</select>
|
|
</div>
|
|
) : (
|
|
<div className="opacity-60 cursor-not-allowed">
|
|
<label className="block text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">Branch</label>
|
|
<div className="w-full px-6 py-4 bg-gray-100 border border-gray-100 rounded-2xl font-bold text-gray-500">
|
|
{window.__APP_DATA__?.branch?.name || 'Assigned Branch'}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label className="block text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">Collection Type</label>
|
|
<select
|
|
required
|
|
className="w-full px-6 py-4 bg-gray-50/50 border border-gray-100 rounded-2xl outline-none focus:ring-2 focus:ring-red-500/10 focus:border-red-500 transition-all font-bold text-gray-900 appearance-none"
|
|
value={formData.collection_type_id}
|
|
onChange={e => {
|
|
const typeId = e.target.value;
|
|
const type = types.find(t => t.id.toString() === typeId.toString());
|
|
const isProd = type && (
|
|
type.name.toLowerCase().includes('product sale') ||
|
|
type.name.toLowerCase().includes('product saled')
|
|
);
|
|
setFormData({...formData, collection_type_id: typeId, items: [], amount: ''});
|
|
if (isProd) setShowProductSearch(true);
|
|
}}
|
|
>
|
|
<option value="">Select Type</option>
|
|
{types.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">Payment Method</label>
|
|
<select
|
|
required
|
|
className="w-full px-6 py-4 bg-gray-50/50 border border-gray-100 rounded-2xl outline-none focus:ring-2 focus:ring-red-500/10 focus:border-red-500 transition-all font-bold text-gray-900 appearance-none"
|
|
value={formData.payment_method}
|
|
onChange={e => setFormData({...formData, payment_method: e.target.value})}
|
|
>
|
|
{paymentMethods.map(m => (
|
|
<option key={m.id} value={m.name}>{m.name}</option>
|
|
))}
|
|
{paymentMethods.length === 0 && (
|
|
<>
|
|
<option value="Cash">Cash</option>
|
|
<option value="Card">Card</option>
|
|
<option value="Online">Online</option>
|
|
</>
|
|
)}
|
|
</select>
|
|
</div>
|
|
|
|
{isProductSale ? (
|
|
<div className="col-span-2 space-y-4">
|
|
<div className="p-6 bg-gray-50/50 border border-gray-100 rounded-[24px]">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-2">
|
|
<ShoppingBag size={16} className="text-gray-400" />
|
|
<span className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Products</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowProductSearch(true)}
|
|
className="text-[10px] font-black text-red-500 uppercase tracking-widest hover:text-red-600 flex items-center gap-1"
|
|
>
|
|
<Plus size={14} /> Add Product
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
{formData.items.map(item => (
|
|
<div key={item.product_id} className="flex items-center justify-between bg-white p-3 rounded-xl border border-gray-50">
|
|
<div className="flex-1">
|
|
<p className="text-sm font-bold text-gray-900">{item.name}</p>
|
|
<p className="text-[10px] text-gray-400 font-bold">{item.unit_price} AED x {item.quantity}</p>
|
|
</div>
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex items-center gap-2 bg-gray-50 rounded-lg p-1">
|
|
<button type="button" onClick={() => updateItemQty(item.product_id, -1)} className="p-1 hover:bg-white rounded shadow-sm text-gray-400">
|
|
{item.quantity <= 1 ? <Trash2 size={12} className="text-red-400" /> : <div className="w-3 h-3 flex items-center justify-center font-bold">-</div>}
|
|
</button>
|
|
<span className="text-xs font-black w-4 text-center">{item.quantity}</span>
|
|
<button type="button" onClick={() => updateItemQty(item.product_id, 1)} className="p-1 hover:bg-white rounded shadow-sm text-gray-400">
|
|
<Plus size={12} />
|
|
</button>
|
|
</div>
|
|
<button type="button" onClick={() => removeItem(item.product_id)} className="text-gray-300 hover:text-red-500"><Trash2 size={16} /></button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{formData.items.length === 0 && (
|
|
<p className="text-center py-6 text-xs text-gray-400 font-bold italic">No products added yet.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="col-span-2 text-red-500">
|
|
<label className="block text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">Amount (AED) </label>
|
|
<input
|
|
required type="number" step="0.01"
|
|
className="w-full px-6 py-4 bg-gray-50/50 border border-gray-100 rounded-2xl outline-none focus:ring-2 focus:ring-red-500/10 focus:border-red-500 transition-all font-bold text-gray-900"
|
|
placeholder="0.00"
|
|
value={formData.amount}
|
|
onChange={e => setFormData({...formData, amount: e.target.value})}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="col-span-2">
|
|
<label className="block text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2 ml-1">Remarks</label>
|
|
<textarea
|
|
className="w-full px-6 py-4 bg-gray-50/50 border border-gray-100 rounded-2xl outline-none focus:ring-2 focus:ring-red-500/10 focus:border-red-500 transition-all font-bold text-gray-900 min-h-[100px]"
|
|
placeholder="Details about this collection..."
|
|
value={formData.remarks}
|
|
onChange={e => setFormData({...formData, remarks: e.target.value})}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pt-6 border-t border-gray-100 flex gap-4">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="flex-1 py-4 bg-gray-50 text-gray-400 rounded-2xl font-black text-xs uppercase tracking-widest hover:bg-gray-100 transition-all"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
disabled={loading || !formData.amount || parseFloat(formData.amount) <= 0}
|
|
type="submit"
|
|
className="flex-1 py-4 bg-[#10B981] text-white rounded-2xl font-black text-xs uppercase tracking-widest hover:bg-[#059669] transition-all shadow-lg shadow-emerald-100 disabled:opacity-50"
|
|
>
|
|
{loading ? 'Saving...' : 'Save Entry'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
|
|
{/* Product Search Overlay */}
|
|
{showProductSearch && (
|
|
<div className="absolute inset-0 bg-white/95 backdrop-blur-md z-[1000] p-8 flex flex-col animate-in slide-in-from-bottom-4 duration-300">
|
|
<div className="flex items-center justify-between mb-8">
|
|
<h3 className="text-xl font-black text-gray-900">Select Product</h3>
|
|
<button onClick={() => setShowProductSearch(false)} className="p-2 hover:bg-gray-100 rounded-xl transition-colors text-gray-400">
|
|
<X size={24} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="relative mb-6">
|
|
<Search className="absolute left-6 top-1/2 -translate-y-1/2 text-gray-400" size={20} />
|
|
<input
|
|
autoFocus
|
|
className="w-full pl-16 pr-6 py-4 bg-gray-50 border border-gray-100 rounded-2xl outline-none focus:ring-2 focus:ring-red-500/10 focus:border-red-500 transition-all font-bold text-gray-900"
|
|
placeholder="Search products..."
|
|
value={searchTerm}
|
|
onChange={e => setSearchTerm(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto space-y-2">
|
|
{filteredProducts.map(p => (
|
|
<button
|
|
key={p.id}
|
|
type="button"
|
|
onClick={() => addItem(p)}
|
|
className="w-full p-4 rounded-2xl border border-gray-50 hover:border-red-100 hover:bg-red-50/30 transition-all text-left flex items-center justify-between group"
|
|
>
|
|
<div>
|
|
<p className="font-bold text-gray-900">{p.name}</p>
|
|
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-widest">{p.category?.name} • Stock: {p.current_stock}</p>
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="font-black text-red-500">{p.selling_price} AED</p>
|
|
<p className="text-[10px] text-green-500 font-bold group-hover:block hidden">Click to add</p>
|
|
</div>
|
|
</button>
|
|
))}
|
|
{filteredProducts.length === 0 && (
|
|
<div className="text-center py-20 opacity-20">
|
|
<Search size={48} className="mx-auto mb-4" />
|
|
<p className="font-black uppercase tracking-widest">No products found</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|