Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect } from 'react'; | |
| import { db, storage } from '../../firebase/config'; | |
| import { ref, onValue, push, set, remove, update } from 'firebase/database'; | |
| import { ref as sRef, uploadBytes, getDownloadURL } from 'firebase/storage'; | |
| import { Plus, Trash2, Edit3, X, Image as ImageIcon, Save, Palette, Upload, Loader2, ListTree, ChefHat } from 'lucide-react'; | |
| export default function MenuEditor() { | |
| const [products, setProducts] = useState([]); | |
| const [inventory, setInventory] = useState([]); | |
| const [formData, setFormData] = useState({ | |
| name: '', price: '', category: 'Principales', image: '', | |
| variants: [], ingredients: [], extras: [] | |
| }); | |
| const [editingItem, setEditingItem] = useState(null); | |
| const [isModalOpen, setIsModalOpen] = useState(false); | |
| const [menuTheme, setMenuTheme] = useState('dark'); | |
| const [uploading, setUploading] = useState(false); | |
| useEffect(() => { | |
| onValue(ref(db, 'menu'), (snapshot) => { | |
| const data = snapshot.val(); | |
| setProducts(data ? Object.keys(data).map(id => ({ id, ...data[id] })) : []); | |
| }); | |
| onValue(ref(db, 'inventory'), (snapshot) => { | |
| const data = snapshot.val(); | |
| setInventory(data ? Object.keys(data).map(id => ({ id, ...data[id] })) : []); | |
| }); | |
| onValue(ref(db, 'config/menuTheme'), (snapshot) => { | |
| if (snapshot.exists()) setMenuTheme(snapshot.val()); | |
| }); | |
| }, []); | |
| const handleToggleTheme = async () => { | |
| await set(ref(db, 'config/menuTheme'), menuTheme === 'dark' ? 'light' : 'dark'); | |
| }; | |
| const handleImageUpload = async (e, isEditing = false) => { | |
| const file = e.target.files[0]; | |
| if (!file || !file.type.startsWith('image/')) return; | |
| setUploading(true); | |
| try { | |
| const storagePath = `menu/${Date.now()}_${file.name}`; | |
| const fileRef = sRef(storage, storagePath); | |
| await uploadBytes(fileRef, file); | |
| const url = await getDownloadURL(fileRef); | |
| if (isEditing) setEditingItem(prev => ({ ...prev, image: url })); | |
| else setFormData(prev => ({ ...prev, image: url })); | |
| } catch (error) { console.error(error); } finally { setUploading(false); } | |
| }; | |
| const handleAddProduct = async (e) => { | |
| e.preventDefault(); | |
| if (!formData.name || !formData.price) return; | |
| const newRef = push(ref(db, 'menu')); | |
| await set(newRef, { ...formData, price: parseFloat(formData.price), active: true }); | |
| setFormData({ name: '', price: '', category: 'Principales', image: '', variants: [], ingredients: [], extras: [] }); | |
| }; | |
| const handleDelete = async (id) => { | |
| if(window.confirm('¿Eliminar producto?')) await remove(ref(db, `menu/${id}`)); | |
| }; | |
| const addIngredientToProduct = (isEditing) => { | |
| const newIng = { id: '', qty: 1 }; | |
| if (isEditing) setEditingItem({ ...editingItem, ingredients: [...(editingItem.ingredients || []), newIng] }); | |
| else setFormData({ ...formData, ingredients: [...formData.ingredients, newIng] }); | |
| }; | |
| const addExtraToProduct = (isEditing) => { | |
| const newExtra = { name: '', price: 0 }; | |
| if (isEditing) setEditingItem({ ...editingItem, extras: [...(editingItem.extras || []), newExtra] }); | |
| else setFormData({ ...formData, extras: [...(formData.extras || []), newExtra] }); | |
| }; | |
| const handleSaveEdit = async () => { | |
| const { id, ...updates } = editingItem; | |
| await update(ref(db, `menu/${id}`), { ...updates, price: parseFloat(updates.price) }); | |
| setIsModalOpen(false); | |
| }; | |
| const seedMenu = async () => { | |
| if (!window.confirm('¿Deseas cargar el menú de ejemplo de hamburguesas? Esto no borrará tus platos actuales.')) return; | |
| const examples = [ | |
| { | |
| name: 'Hamburgesa Clásica', | |
| price: 12.50, | |
| category: 'Fuertes', | |
| image: '/images/burger_classic.png', | |
| active: true, | |
| ingredients: [], | |
| extras: [{ name: 'Queso Extra', price: 1.50 }, { name: 'Tocineta', price: 2.00 }] | |
| }, | |
| { | |
| name: 'Pollo Crispy Burger', | |
| price: 11.00, | |
| category: 'Fuertes', | |
| image: '/images/burger_chicken.png', | |
| active: true, | |
| ingredients: [], | |
| extras: [{ name: 'Salsa Picante', price: 0.50 }] | |
| }, | |
| { | |
| name: 'Vegan Delight', | |
| price: 13.00, | |
| category: 'Fuertes', | |
| image: '/images/burger_vegan.png', | |
| active: true, | |
| ingredients: [], | |
| extras: [{ name: 'Aguacate', price: 1.50 }] | |
| }, | |
| { | |
| name: 'Papas Supremas', | |
| price: 8.50, | |
| category: 'Entradas', | |
| image: 'https://images.unsplash.com/photo-1573080496219-bb080dd4f877?auto=format&fit=crop&q=80&w=1000', | |
| active: true, | |
| ingredients: [], | |
| extras: [] | |
| } | |
| ]; | |
| for (const item of examples) { | |
| const newRef = push(ref(db, 'menu')); | |
| await set(newRef, item); | |
| } | |
| alert('Menú de ejemplo cargado con éxito'); | |
| }; | |
| return ( | |
| <div className="animate-fade-in" style={{ padding: '0 1rem' }}> | |
| <header style={{ marginBottom: '2.5rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> | |
| <div> | |
| <h2 className="text-gradient" style={{ fontSize: '2rem', fontWeight: '800', display: 'flex', alignItems: 'center', gap: '0.75rem' }}> | |
| <ChefHat size={28} /> Carta & Recetas | |
| </h2> | |
| <p style={{ color: 'var(--text-muted)' }}>Gestión de productos y consumo de insumos</p> | |
| </div> | |
| <div style={{ display: 'flex', gap: '10px' }}> | |
| <button onClick={seedMenu} className="btn-glass" style={{ padding: '0.8rem 1.25rem', borderColor: 'var(--success)', color: 'var(--success)' }}> | |
| Restaurar Ejemplos | |
| </button> | |
| <button onClick={handleToggleTheme} className="btn-glass" style={{ padding: '0.8rem 1.25rem' }}> | |
| Tema QR: {menuTheme.toUpperCase()} | |
| </button> | |
| </div> | |
| </header> | |
| <section className="glass-card" style={{ marginBottom: '3rem', padding: '1.5rem' }}> | |
| <h3 style={{ marginBottom: '1.5rem', fontSize: '1.1rem' }}>Agregar Platillo</h3> | |
| <form onSubmit={handleAddProduct} style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1.25rem', alignItems: 'end' }}> | |
| <div> | |
| <label style={labelStyle}>Nombre</label> | |
| <input type="text" value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} style={inputStyle} /> | |
| </div> | |
| <div> | |
| <label style={labelStyle}>Precio Base ($)</label> | |
| <input type="number" value={formData.price} onChange={e => setFormData({...formData, price: e.target.value})} style={inputStyle} /> | |
| </div> | |
| <div> | |
| <label style={labelStyle}>Categoría</label> | |
| <select value={formData.category} onChange={e => setFormData({...formData, category: e.target.value})} style={inputStyle}> | |
| {['Entradas', 'Bebidas', 'Fuertes', 'Postres', 'Pizzas', 'Combos'].map(c => <option key={c} value={c}>{c}</option>)} | |
| </select> | |
| </div> | |
| <div> | |
| <label style={labelStyle}>Imagen del Platillo</label> | |
| <label style={{ ...inputStyle, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem', cursor: 'pointer', background: formData.image ? 'rgba(76,217,100,0.1)' : 'rgba(255,255,255,0.05)', color: formData.image ? 'var(--success)' : '#fff', border: formData.image ? '1px solid var(--success)' : '1px solid var(--border-subtle)' }}> | |
| <Upload size={18} /> {uploading ? 'Subiendo...' : (formData.image ? '¡Imagen Lista!' : 'Subir Imagen')} | |
| <input type="file" hidden onChange={e => handleImageUpload(e, false)} accept="image/*" /> | |
| </label> | |
| </div> | |
| <div style={{ display: 'flex', gap: '0.5rem' }}> | |
| <button type="button" onClick={() => addIngredientToProduct(false)} className="btn-glass" style={{ flex: 1, padding: '0.8rem' }}> | |
| <ListTree size={18} /> Insumos | |
| </button> | |
| <button type="submit" className="btn-primary" style={{ padding: '0.8rem 1.5rem', opacity: uploading ? 0.5 : 1 }}> | |
| <Plus size={20} /> | |
| </button> | |
| </div> | |
| </form> | |
| {formData.ingredients.length > 0 && ( | |
| <div style={{ marginTop: '1.5rem', display: 'flex', flexWrap: 'wrap', gap: '1rem' }}> | |
| {formData.ingredients.map((ing, idx) => ( | |
| <div key={idx} className="glass-panel" style={{ padding: '0.5rem 1rem', display: 'flex', gap: '0.5rem', alignItems: 'center' }}> | |
| <select | |
| value={ing.id} | |
| onChange={e => { | |
| const newIngs = [...formData.ingredients]; | |
| newIngs[idx].id = e.target.value; | |
| setFormData({...formData, ingredients: newIngs}); | |
| }} | |
| style={{...inputStyle, padding: '0.4rem', width: 'auto'}} | |
| > | |
| <option value="">Seleccionar Insumo</option> | |
| {inventory.map(i => <option key={i.id} value={i.id}>{i.name}</option>)} | |
| </select> | |
| <input | |
| type="number" | |
| value={ing.qty} | |
| onChange={e => { | |
| const newIngs = [...formData.ingredients]; | |
| newIngs[idx].qty = parseFloat(e.target.value); | |
| setFormData({...formData, ingredients: newIngs}); | |
| }} | |
| style={{...inputStyle, padding: '0.4rem', width: '60px'}} | |
| /> | |
| <button type="button" onClick={() => setFormData({...formData, ingredients: formData.ingredients.filter((_, i) => i !== idx)})} style={{color: 'var(--primary)', background: 'none', border: 'none'}}><X size={16}/></button> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| <div style={{ marginTop: '1.5rem' }}> | |
| <button type="button" onClick={() => addExtraToProduct(false)} className="btn-glass" style={{ padding: '0.5rem 1rem', fontSize: '0.85rem' }}> | |
| <Plus size={16} /> Agregar Extra (Ej. Queso Extra) | |
| </button> | |
| <div style={{ display: 'flex', flexWrap: 'wrap', gap: '1rem', marginTop: '1rem' }}> | |
| {(formData.extras || []).map((ext, idx) => ( | |
| <div key={idx} className="glass-panel" style={{ padding: '0.5rem', display: 'flex', gap: '0.5rem', alignItems: 'center' }}> | |
| <input type="text" placeholder="Extra" value={ext.name} onChange={e => { | |
| const newExts = [...formData.extras]; | |
| newExts[idx].name = e.target.value; | |
| setFormData({...formData, extras: newExts}); | |
| }} style={{...inputStyle, padding: '0.4rem', width: '120px'}} /> | |
| <input type="number" placeholder="$" value={ext.price} onChange={e => { | |
| const newExts = [...formData.extras]; | |
| newExts[idx].price = parseFloat(e.target.value); | |
| setFormData({...formData, extras: newExts}); | |
| }} style={{...inputStyle, padding: '0.4rem', width: '60px'}} /> | |
| <button type="button" onClick={() => setFormData({...formData, extras: formData.extras.filter((_, i) => i !== idx)})} style={{color: 'var(--primary)', background: 'none', border: 'none'}}><X size={16}/></button> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| </section> | |
| <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '1.5rem' }}> | |
| {products.map(item => ( | |
| <div key={item.id} className="glass-card" style={{ padding: '0', overflow: 'hidden' }}> | |
| <div style={{ position: 'relative', height: '160px', background: 'rgba(255,255,255,0.05)' }}> | |
| {item.image && <img src={item.image} alt={item.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />} | |
| <div style={{ position: 'absolute', top: '10px', right: '10px', display: 'flex', gap: '5px' }}> | |
| <button onClick={() => { setEditingItem({...item}); setIsModalOpen(true); }} style={actionBtnStyle}><Edit3 size={16} /></button> | |
| <button onClick={() => handleDelete(item.id)} style={{...actionBtnStyle, color: 'var(--primary)'}}><Trash2 size={16} /></button> | |
| </div> | |
| </div> | |
| <div style={{ padding: '1.25rem' }}> | |
| <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem' }}> | |
| <h4 style={{ fontWeight: '700' }}>{item.name}</h4> | |
| <span style={{ color: 'var(--success)', fontWeight: '800' }}>${item.price}</span> | |
| </div> | |
| <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> | |
| <span style={{ fontSize: '0.7rem', color: 'var(--text-muted)', background: 'rgba(255,255,255,0.05)', padding: '2px 8px', borderRadius: '4px' }}>{item.category}</span> | |
| {item.ingredients?.length > 0 && <span title="Tiene receta vinculada" style={{ color: 'var(--primary)' }}><ChefHat size={14} /></span>} | |
| </div> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| {isModalOpen && editingItem && ( | |
| <div style={{ position: 'fixed', top: 0, left: 0, width: '100%', height: '100%', background: 'rgba(0,0,0,0.85)', display: 'flex', justifyContent: 'center', alignItems: 'center', zIndex: 1000, padding: '20px' }}> | |
| <div className="glass-panel animate-scale-in" style={{ maxWidth: '600px', width: '100%', padding: '2rem', maxHeight: '90vh', overflowY: 'auto' }}> | |
| <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '2rem' }}> | |
| <h2 className="text-gradient">Editar: {editingItem.name}</h2> | |
| <button onClick={() => setIsModalOpen(false)} style={{ background: 'none', border: 'none', color: '#fff' }}><X size={24} /></button> | |
| </div> | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}> | |
| <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}> | |
| <div><label style={labelStyle}>Nombre</label><input type="text" value={editingItem.name} onChange={e => setEditingItem({...editingItem, name: e.target.value})} style={inputStyle} /></div> | |
| <div><label style={labelStyle}>Precio</label><input type="number" value={editingItem.price} onChange={e => setEditingItem({...editingItem, price: e.target.value})} style={inputStyle} /></div> | |
| </div> | |
| <div> | |
| <label style={labelStyle}>Receta (Consumo de insumos por venta)</label> | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}> | |
| {(editingItem.ingredients || []).map((ing, i) => ( | |
| <div key={i} style={{ display: 'flex', gap: '0.5rem' }}> | |
| <select value={ing.id} onChange={e => { | |
| const ings = [...editingItem.ingredients]; | |
| ings[i].id = e.target.value; | |
| setEditingItem({...editingItem, ingredients: ings}); | |
| }} style={{...inputStyle, flex: 2}}> | |
| <option value="">Seleccionar Insumo</option> | |
| {inventory.map(inv => <option key={inv.id} value={inv.id}>{inv.name}</option>)} | |
| </select> | |
| <input type="number" value={ing.qty} onChange={e => { | |
| const ings = [...editingItem.ingredients]; | |
| ings[i].qty = parseFloat(e.target.value); | |
| setEditingItem({...editingItem, ingredients: ings}); | |
| }} style={{...inputStyle, flex: 1}} /> | |
| <button onClick={() => setEditingItem({...editingItem, ingredients: editingItem.ingredients.filter((_, idx) => idx !== i)})} style={{color: 'var(--primary)'}}><Trash2 size={18} /></button> | |
| </div> | |
| ))} | |
| <button onClick={() => addIngredientToProduct(true)} className="btn-glass" style={{ padding: '0.5rem', fontSize: '0.8rem' }}><Plus size={14} /> Añadir Insumo</button> | |
| </div> | |
| </div> | |
| <div> | |
| <label style={labelStyle}>Extras Sugeridos (Opcional)</label> | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}> | |
| {(editingItem.extras || []).map((ext, i) => ( | |
| <div key={i} style={{ display: 'flex', gap: '0.5rem' }}> | |
| <input type="text" placeholder="Extra" value={ext.name} onChange={e => { | |
| const exts = [...(editingItem.extras || [])]; | |
| exts[i].name = e.target.value; | |
| setEditingItem({...editingItem, extras: exts}); | |
| }} style={{...inputStyle, flex: 2}} /> | |
| <input type="number" placeholder="$" value={ext.price} onChange={e => { | |
| const exts = [...(editingItem.extras || [])]; | |
| exts[i].price = parseFloat(e.target.value); | |
| setEditingItem({...editingItem, extras: exts}); | |
| }} style={{...inputStyle, flex: 1}} /> | |
| <button onClick={() => setEditingItem({...editingItem, extras: editingItem.extras.filter((_, idx) => idx !== i)})} style={{color: 'var(--primary)'}}><Trash2 size={18} /></button> | |
| </div> | |
| ))} | |
| <button onClick={() => addExtraToProduct(true)} className="btn-glass" style={{ padding: '0.5rem', fontSize: '0.8rem' }}><Plus size={14} /> Añadir Extra</button> | |
| </div> | |
| </div> | |
| <div style={{ display: 'flex', gap: '1rem', marginTop: '1rem' }}> | |
| <label style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '0.5rem', background: 'var(--primary)', padding: '0.8rem', borderRadius: '8px', cursor: 'pointer', justifyContent: 'center' }}> | |
| <Upload size={18} /> {uploading ? 'Subiendo...' : 'Cambiar Imagen'} | |
| <input type="file" hidden onChange={e => handleImageUpload(e, true)} accept="image/*" /> | |
| </label> | |
| <button onClick={handleSaveEdit} className="btn-primary" style={{ flex: 1, padding: '0.8rem' }}><Save size={18} /> Guardar</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| const inputStyle = { width: '100%', padding: '0.8rem', borderRadius: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid var(--border-subtle)', color: '#fff', outline: 'none' }; | |
| const labelStyle = { display: 'block', fontSize: '0.8rem', color: 'var(--text-muted)', marginBottom: '0.4rem' }; | |
| const actionBtnStyle = { width: '32px', height: '32px', borderRadius: '8px', border: 'none', background: 'rgba(0,0,0,0.6)', color: '#fff', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }; | |