Spaces:
Running
Running
| import React, { useState, useEffect } from 'react'; | |
| import { useAuth } from '../context/AuthContext'; | |
| import { useNavigate } from 'react-router-dom'; | |
| import { db } from '../firebase/config'; | |
| import { ref, onValue, push, set } from 'firebase/database'; | |
| import { LogOut, Coffee, Send, Trash2, ShoppingBag, Truck, Receipt, CheckCircle, XCircle, CreditCard, Banknote, QrCode } from 'lucide-react'; | |
| export default function WaiterPOS() { | |
| const { logout, currentUser } = useAuth(); | |
| const navigate = useNavigate(); | |
| const [menu, setMenu] = useState([]); | |
| const [activeTable, setActiveTable] = useState(null); | |
| const [cart, setCart] = useState([]); | |
| const [orderType, setOrderType] = useState('Mesa'); // Mesa, Llevar, Delivery | |
| const [discount, setDiscount] = useState(0); | |
| const [tip, setTip] = useState(0); | |
| const [paymentMethod, setPaymentMethod] = useState('Efectivo'); | |
| const [isCheckoutOpen, setIsCheckoutOpen] = useState(false); | |
| const [tableStatuses, setTableStatuses] = useState({}); | |
| const [selectedCategory, setSelectedCategory] = useState('Todos'); | |
| // Fetch Menu | |
| useEffect(() => { | |
| onValue(ref(db, 'menu'), (snapshot) => { | |
| const data = snapshot.val(); | |
| if (data) { | |
| setMenu(Object.keys(data).map(k => ({ id: k, ...data[k] }))); | |
| } | |
| }); | |
| onValue(ref(db, 'tables'), (snapshot) => { | |
| const data = snapshot.val(); | |
| if (data) setTableStatuses(data); | |
| }); | |
| }, []); | |
| const handleLogout = async () => { | |
| await logout(); | |
| navigate('/login'); | |
| }; | |
| const addToCart = async (product) => { | |
| if (!activeTable && orderType === 'Mesa') { | |
| alert("Selecciona una mesa primero."); | |
| return; | |
| } | |
| // Mark table as occupied | |
| if (orderType === 'Mesa' && activeTable) { | |
| await set(ref(db, `tables/${activeTable}`), 'occupied'); | |
| } | |
| const existing = cart.find(item => item.id === product.id); | |
| if (existing) { | |
| setCart(cart.map(item => item.id === product.id ? { ...item, qty: item.qty + 1 } : item)); | |
| } else { | |
| setCart([...cart, { ...product, qty: 1, note: '' }]); | |
| } | |
| }; | |
| const updateCartItemNote = (id, note) => { | |
| setCart(cart.map(item => item.id === id ? { ...item, note } : item)); | |
| }; | |
| const removeFromCart = (id) => { | |
| setCart(cart.filter(item => item.id !== id)); | |
| }; | |
| const subtotal = cart.reduce((acc, item) => acc + (item.price * item.qty), 0); | |
| const discountAmount = (subtotal * discount) / 100; | |
| const totalCart = subtotal - discountAmount + Number(tip || 0); | |
| const sendOrder = async () => { | |
| if ((orderType === 'Mesa' && !activeTable) || cart.length === 0) return; | |
| const orderRef = push(ref(db, 'orders')); | |
| const orderData = { | |
| table: orderType === 'Mesa' ? activeTable : orderType, | |
| type: orderType, | |
| waiter: currentUser?.email, | |
| items: cart, | |
| subtotal, | |
| discount, | |
| tip, | |
| total: totalCart, | |
| paymentMethod: paymentMethod, | |
| status: 'pending', | |
| timestamp: Date.now() | |
| }; | |
| await set(orderRef, orderData); | |
| if (!isCheckoutOpen) { | |
| setCart([]); | |
| setActiveTable(null); | |
| alert('¡Comanda enviada a cocina exitosamente!'); | |
| } else { | |
| // Finalizar y descontar stock | |
| await set(ref(db, `orders/${orderRef.key}/status`), 'completed'); | |
| // Mark table as free | |
| if (orderType === 'Mesa' && activeTable) { | |
| await set(ref(db, `tables/${activeTable}`), 'free'); | |
| } | |
| // Lógica de Descuento de Inventario | |
| // ... (existing logic continues) | |
| for (const item of cart) { | |
| // Buscamos el producto en el menú para ver su receta (ingredients) | |
| // Nota: En un entorno real, esto se haría en el backend para evitar race conditions y asegurar integridad. | |
| const productRef = ref(db, `menu/${item.id}`); | |
| onValue(productRef, async (snapshot) => { | |
| const product = snapshot.val(); | |
| if (product && product.ingredients) { | |
| for (const ing of product.ingredients) { | |
| const invRef = ref(db, `inventory/${ing.id}/quantity`); | |
| // Obtenemos cantidad actual y restamos (qty_producto * qty_ingrediente) | |
| onValue(ref(db, `inventory/${ing.id}`), async (invSnap) => { | |
| const invData = invSnap.val(); | |
| if (invData) { | |
| const newQty = invData.quantity - (item.qty * ing.qty); | |
| await set(invRef, newQty); | |
| } | |
| }, { onlyOnce: true }); | |
| } | |
| } | |
| }, { onlyOnce: true }); | |
| } | |
| setIsCheckoutOpen(false); | |
| setCart([]); | |
| setActiveTable(null); | |
| alert('¡Venta realizada y stock actualizado!'); | |
| } | |
| }; | |
| const printTicket = () => { | |
| const printWindow = window.open('', '_blank'); | |
| printWindow.document.write(` | |
| <html> | |
| <head><title>Ticket de Venta</title></head> | |
| <body style="font-family: monospace; width: 300px; padding: 20px;"> | |
| <h2 style="text-align: center;">RESTAURANT OS</h2> | |
| <hr/> | |
| <p>Mesa: ${activeTable || orderType}</p> | |
| <p>Fecha: ${new Date().toLocaleString()}</p> | |
| <hr/> | |
| ${cart.map(item => `<p>${item.qty}x ${item.name.padEnd(20)} $${(item.price * item.qty).toFixed(2)}</p>`).join('')} | |
| <hr/> | |
| <p>Subtotal: $${subtotal.toFixed(2)}</p> | |
| <p>Descuento (${discount}%): -$${discountAmount.toFixed(2)}</p> | |
| <p>Propina: $${Number(tip || 0).toFixed(2)}</p> | |
| <h3 style="text-align: right;">Total: $${totalCart.toFixed(2)}</h3> | |
| <p style="text-align: center;">¡Gracias por su visita!</p> | |
| </body> | |
| </html> | |
| `); | |
| printWindow.document.close(); | |
| printWindow.print(); | |
| }; | |
| return ( | |
| <div className="app-container" style={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden' }}> | |
| {/* Navbar */} | |
| <header className="glass-panel" style={{ padding: '1rem 2rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderRadius: '0', borderBottom: '1px solid var(--border-subtle)', background: 'var(--bg-base)' }}> | |
| <h1 className="text-gradient" style={{ fontSize: '1.5rem', fontWeight: '800' }}>Terminal POS</h1> | |
| <div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}> | |
| <span style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>{currentUser?.email}</span> | |
| <button className="btn-glass" onClick={() => window.open('/menu', '_blank')} style={{ padding: '0.5rem 1rem' }}>Carta Digital</button> | |
| <button className="btn-glass" onClick={() => window.open('/kitchen', '_blank')} style={{ padding: '0.5rem 1rem' }}>Cocina</button> | |
| <button className="btn-glass" onClick={handleLogout} style={{ padding: '0.5rem 1rem', display: 'flex', gap: '0.5rem', alignItems: 'center' }}> | |
| <LogOut size={16} /> Salir | |
| </button> | |
| </div> | |
| </header> | |
| <div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}> | |
| {/* Left Column: Tables & Menu */} | |
| <div style={{ flex: '65%', padding: '1.5rem', overflowY: 'auto', background: 'rgba(255,255,255,0.01)' }}> | |
| <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}> | |
| <h2 className="text-gradient" style={{ fontSize: '1.25rem' }}>Tipo de Pedido</h2> | |
| <div style={{ display: 'flex', gap: '0.5rem' }}> | |
| {[ | |
| { id: 'Mesa', icon: <Coffee size={18} /> }, | |
| { id: 'Llevar', icon: <ShoppingBag size={18} /> }, | |
| { id: 'Delivery', icon: <Truck size={18} /> } | |
| ].map(type => ( | |
| <button | |
| key={type.id} | |
| onClick={() => { setOrderType(type.id); if(type.id !== 'Mesa') setActiveTable(null); }} | |
| className={orderType === type.id ? 'btn-primary' : 'btn-glass'} | |
| style={{ padding: '0.5rem 1rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }} | |
| > | |
| {type.icon} {type.id} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| {orderType === 'Mesa' && ( | |
| <div style={{ display: 'flex', gap: '1rem', overflowX: 'auto', paddingBottom: '1rem', marginBottom: '1.5rem' }}> | |
| {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].map(table => ( | |
| <button | |
| key={table} | |
| onClick={() => setActiveTable(table)} | |
| style={{ | |
| minWidth: '70px', height: '70px', borderRadius: 'var(--radius-lg)', | |
| background: activeTable === table ? 'var(--primary)' : (tableStatuses[table] === 'occupied' ? 'rgba(255,90,95,0.2)' : 'rgba(255,255,255,0.05)'), | |
| border: activeTable === table ? '2px solid transparent' : (tableStatuses[table] === 'occupied' ? '1px solid var(--primary)' : '1px solid var(--border-subtle)'), | |
| color: activeTable === table ? '#fff' : (tableStatuses[table] === 'occupied' ? 'var(--primary)' : 'var(--text-main)'), | |
| display: 'flex', alignItems: 'center', justifyContent: 'center', | |
| fontWeight: '600', transition: 'all 0.2s', cursor: 'pointer' | |
| }} | |
| > | |
| {table} | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| <h2 className="text-gradient" style={{ fontSize: '1.25rem', marginBottom: '1rem' }}>Catálogo de Productos</h2> | |
| <div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem', overflowX: 'auto', paddingBottom: '0.5rem' }}> | |
| {['Todos', ...new Set(menu.map(p => p.category))].map(cat => ( | |
| <button | |
| key={cat} | |
| onClick={() => setSelectedCategory(cat)} | |
| className={selectedCategory === cat ? 'btn-primary' : 'btn-glass'} | |
| style={{ padding: '0.4rem 1rem', fontSize: '0.85rem', whiteSpace: 'nowrap' }} | |
| > | |
| {cat} | |
| </button> | |
| ))} | |
| </div> | |
| <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: '1rem' }}> | |
| {menu | |
| .filter(p => selectedCategory === 'Todos' || p.category === selectedCategory) | |
| .map(product => ( | |
| <button | |
| key={product.id} | |
| onClick={() => addToCart(product)} | |
| className="glass-card" | |
| style={{ textAlign: 'left', padding: '1rem', borderColor: 'var(--border-subtle)', position: 'relative' }} | |
| > | |
| <p style={{ fontWeight: '600', marginBottom: '0.5rem', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}> | |
| {product.name} | |
| </p> | |
| <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> | |
| <span style={{ color: 'var(--success)', fontWeight: '700' }}>${Number(product.price).toFixed(2)}</span> | |
| <span style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>{product.category}</span> | |
| </div> | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| {/* Right Column: Order Details */} | |
| <div style={{ flex: '35%', background: 'var(--bg-card)', borderLeft: '1px solid var(--border-subtle)', display: 'flex', flexDirection: 'column' }}> | |
| <div style={{ padding: '1.5rem', borderBottom: '1px solid var(--border-subtle)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> | |
| <h2 className="text-gradient" style={{ fontSize: '1.25rem' }}> | |
| {orderType === 'Mesa' ? `Mesa ${activeTable || '?'}` : orderType} | |
| </h2> | |
| <button onClick={() => setCart([])} style={{ color: 'var(--danger)', fontSize: '0.8rem', background: 'none', border: 'none', cursor: 'not-allowed' }} disabled={cart.length === 0}> | |
| Limpiar | |
| </button> | |
| </div> | |
| <div style={{ flex: 1, overflowY: 'auto', padding: '1rem', display: 'flex', flexDirection: 'column', gap: '0.75rem' }}> | |
| {cart.map((item) => ( | |
| <div key={item.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.75rem', background: 'rgba(255,255,255,0.03)', borderRadius: 'var(--radius-md)' }}> | |
| <div style={{ flex: 1 }}> | |
| <div style={{ fontWeight: '600', fontSize: '0.95rem' }}>{item.name}</div> | |
| <div style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>{item.qty} x ${item.price}</div> | |
| <input | |
| type="text" | |
| placeholder="Nota / Extra..." | |
| value={item.note} | |
| onChange={(e) => updateCartItemNote(item.id, e.target.value)} | |
| style={{ width: '100%', marginTop: '0.5rem', padding: '4px 8px', fontSize: '0.75rem', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '4px', color: '#fff' }} | |
| /> | |
| </div> | |
| <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}> | |
| <span style={{ fontWeight: '700' }}>${(item.qty * item.price).toFixed(2)}</span> | |
| <button onClick={() => removeFromCart(item.id)} style={{ color: 'var(--danger)', background: 'none', border: 'none', cursor: 'pointer' }}> | |
| <Trash2 size={16} /> | |
| </button> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| <div style={{ padding: '1.5rem', borderTop: '1px solid var(--border-subtle)', background: 'rgba(0,0,0,0.2)' }}> | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '1rem', fontSize: '0.9rem', color: 'var(--text-muted)' }}> | |
| <div style={{ display: 'flex', justifyContent: 'space-between' }}> | |
| <span>Subtotal:</span> | |
| <span>${subtotal.toFixed(2)}</span> | |
| </div> | |
| <div style={{ display: 'flex', justifyContent: 'space-between', color: 'var(--primary)' }}> | |
| <span>Descuento ({discount}%):</span> | |
| <span>-${discountAmount.toFixed(2)}</span> | |
| </div> | |
| {tip > 0 && ( | |
| <div style={{ display: 'flex', justifyContent: 'space-between', color: 'var(--success)' }}> | |
| <span>Propina:</span> | |
| <span>+${Number(tip).toFixed(2)}</span> | |
| </div> | |
| )} | |
| </div> | |
| <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '1.4rem', fontWeight: '900', marginBottom: '1.5rem' }}> | |
| <span>Total:</span> | |
| <span className="text-gradient-primary">${totalCart.toFixed(2)}</span> | |
| </div> | |
| <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}> | |
| <button | |
| className="btn-glass" | |
| onClick={sendOrder} | |
| disabled={cart.length === 0 || (orderType === 'Mesa' && !activeTable)} | |
| style={{ opacity: (cart.length === 0) ? 0.5 : 1, padding: '0.8rem' }} | |
| > | |
| <Send size={18} /> Comanda | |
| </button> | |
| <button | |
| className="btn-primary" | |
| onClick={() => setIsCheckoutOpen(true)} | |
| disabled={cart.length === 0} | |
| style={{ opacity: (cart.length === 0) ? 0.5 : 1, padding: '0.8rem' }} | |
| > | |
| <Receipt size={18} /> Pagar | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Checkout Modal */} | |
| {isCheckoutOpen && ( | |
| <div style={{ | |
| position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, | |
| background: 'rgba(0,0,0,0.85)', backdropFilter: 'blur(10px)', | |
| display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 | |
| }}> | |
| <div className="glass-panel animate-scale-in" style={{ width: '100%', maxWidth: '500px', padding: '2.5rem' }}> | |
| <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}> | |
| <h2 className="text-gradient" style={{ fontSize: '1.75rem' }}>Finalizar Venta</h2> | |
| <button onClick={() => setIsCheckoutOpen(false)} style={{ background: 'none', border: 'none', color: 'var(--text-muted)' }}> | |
| <XCircle size={28} /> | |
| </button> | |
| </div> | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}> | |
| <div> | |
| <label style={{ display: 'block', fontSize: '0.9rem', marginBottom: '0.5rem', color: 'var(--text-muted)' }}>Descuento (%)</label> | |
| <select | |
| value={discount} | |
| onChange={(e) => setDiscount(Number(e.target.value))} | |
| style={{ width: '100%', padding: '0.8rem', background: 'rgba(255,255,255,0.05)', border: '1px solid var(--border-subtle)', borderRadius: 'var(--radius-md)', color: '#fff' }} | |
| > | |
| <option value="0">Sin Descuento</option> | |
| <option value="5">5% Promo</option> | |
| <option value="10">10% Cortesía</option> | |
| <option value="15">15% Empleado</option> | |
| <option value="50">50% VIP</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label style={{ display: 'block', fontSize: '0.9rem', marginBottom: '0.5rem', color: 'var(--text-muted)' }}>Propina (Efectivo/Sugerida)</label> | |
| <input | |
| type="number" | |
| placeholder="$ 0.00" | |
| value={tip} | |
| onChange={(e) => setTip(e.target.value)} | |
| style={{ width: '100%', padding: '0.8rem', background: 'rgba(255,255,255,0.05)', border: '1px solid var(--border-subtle)', borderRadius: 'var(--radius-md)', color: '#fff' }} | |
| /> | |
| </div> | |
| <div> | |
| <label style={{ display: 'block', fontSize: '0.9rem', marginBottom: '0.5rem', color: 'var(--text-muted)' }}>Método de Pago</label> | |
| <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '0.75rem' }}> | |
| {[ | |
| { id: 'Efectivo', icon: <Banknote size={20} /> }, | |
| { id: 'Tarjeta', icon: <CreditCard size={20} /> }, | |
| { id: 'QR', icon: <QrCode size={20} /> } | |
| ].map(m => ( | |
| <button | |
| key={m.id} | |
| onClick={() => setPaymentMethod(m.id)} | |
| className={paymentMethod === m.id ? 'btn-primary' : 'btn-glass'} | |
| style={{ padding: '1rem 0.5rem', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem', fontSize: '0.8rem' }} | |
| > | |
| {m.icon} {m.id} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| <div style={{ background: 'rgba(255,255,255,0.02)', padding: '1.5rem', borderRadius: 'var(--radius-lg)', marginTop: '1rem', border: '1px dashed var(--border-subtle)' }}> | |
| <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '1.5rem', fontWeight: '900' }}> | |
| <span>Total a Pagar:</span> | |
| <span className="text-gradient">${totalCart.toFixed(2)}</span> | |
| </div> | |
| </div> | |
| <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', marginTop: '1rem' }}> | |
| <button className="btn-glass" onClick={printTicket} style={{ padding: '1rem' }}> | |
| <Receipt size={20} /> Preview Ticket | |
| </button> | |
| <button className="btn-primary" onClick={sendOrder} style={{ padding: '1rem' }}> | |
| <CheckCircle size={20} /> Confirmar Pago | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| <style>{` | |
| .btn-glass { | |
| background: rgba(255,255,255,0.05); | |
| border: 1px solid var(--border-subtle); | |
| color: var(--text-main); | |
| border-radius: var(--radius-md); | |
| cursor: pointer; | |
| font-weight: 600; | |
| transition: all 0.2s; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 0.5rem; | |
| } | |
| .btn-glass:hover { | |
| background: rgba(255,255,255,0.1); | |
| transform: translateY(-2px); | |
| } | |
| .btn-primary { | |
| background: var(--primary); | |
| color: white; | |
| border: none; | |
| border-radius: var(--radius-md); | |
| cursor: pointer; | |
| font-weight: 600; | |
| transition: all 0.2s; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 0.5rem; | |
| } | |
| .btn-primary:hover { | |
| opacity: 0.9; | |
| transform: translateY(-2px); | |
| } | |
| .animate-scale-in { | |
| animation: scaleIn 0.3s ease-out; | |
| } | |
| @keyframes scaleIn { | |
| from { transform: scale(0.9); opacity: 0; } | |
| to { transform: scale(1); opacity: 1; } | |
| } | |
| `}</style> | |
| </div> | |
| ); | |
| } | |