/* ============================================================ VIVEROAPP — lógica de front-end Por ahora los datos viven en localStorage. Cuando agregues PHP + base de datos, sustituye las funciones marcadas con "TODO BACKEND" por llamadas $.ajax a tus endpoints (.php), que devuelvan/reciban JSON con la misma forma de objetos que se usa aquí. NOTA SOBRE FOTOS: se guardan como base64 dentro del objeto de cada planta (campo "foto"). Esto es práctico para probar, pero ocupa mucho espacio en localStorage (límite ~5-10MB). Cuando migres a backend, cambia esto por subir el archivo a un endpoint (ej. api/upload.php) que devuelva una URL, y guarda esa URL en el campo "foto" en vez del base64 completo. ============================================================ */ $(function () { /* ---------------------------------------------------------- 1. CONFIGURACIÓN Y DATOS DE EJEMPLO ---------------------------------------------------------- */ const STORAGE_PLANTS = 'vivero_plants_v2'; const STORAGE_SALES = 'vivero_sales_v2'; const STORAGE_CATEGORIES = 'vivero_categories_v2'; const LIGHT_ICONS = { 'Sol directo': 'bi-sun-fill', 'Luz indirecta brillante': 'bi-brightness-high', 'Sombra parcial': 'bi-cloud-sun', 'Poca luz': 'bi-moon-stars' }; // Categorías iniciales: el usuario puede agregar, editar y eliminar // las que quiera desde "Gestionar categorías" en Inventario. const seedCategories = [ { key: 'Suculentas', label: 'Suculentas y cactus' }, { key: 'Interior', label: 'Interior' }, { key: 'Exterior', label: 'Exterior' }, { key: 'Aromaticas', label: 'Aromáticas' }, { key: 'Flores', label: 'Flores' }, { key: 'Tropicales', label: 'Tropicales y exóticas' }, { key: 'Bambu', label: 'Bambú y pastos ornamentales' }, { key: 'Acuaticas', label: 'Acuáticas y semiacuáticas' }, { key: 'Colgantes', label: 'Colgantes y rastreras' }, { key: 'Frutales', label: 'Árboles y arbustos frutales' }, { key: 'Bonsai', label: 'Bonsái' } ]; // Datos iniciales: se cargan solo la primera vez (si no hay nada guardado) const seedPlants = [ { id: 'p1', nombre: 'Echeveria Elegans', categoria: 'Suculentas', precio: 80, costo: 35, stock: 12, stockMin: 5, luz: 'Sol directo', riego: 'Cada 12-15 días, dejar secar tierra', notas: 'Resiste sol pleno. Evita encharcar la maceta, el exceso de humedad pudre la raíz.', vendidosHist: 28, fechaIngreso: '2026-03-02', fechaCompra: '2026-03-02', foto: '' }, { id: 'p2', nombre: 'Monstera Deliciosa', categoria: 'Interior', precio: 350, costo: 180, stock: 3, stockMin: 4, luz: 'Luz indirecta brillante', riego: '1-2 veces por semana', notas: 'Evitar sol directo (quema las hojas). Limpiar hojas con paño húmedo cada 2 semanas.', vendidosHist: 15, fechaIngreso: '2026-02-18', fechaCompra: '2026-02-18', foto: '' }, { id: 'p3', nombre: 'Lavanda', categoria: 'Aromaticas', precio: 120, costo: 55, stock: 8, stockMin: 5, luz: 'Sol directo', riego: 'Cada 5-7 días', notas: 'Podar después de floración para mantener forma compacta y favorecer nuevos brotes.', vendidosHist: 22, fechaIngreso: '2026-03-10', fechaCompra: '2026-03-10', foto: '' }, { id: 'p4', nombre: 'Cactus San Pedro', categoria: 'Suculentas', precio: 150, costo: 70, stock: 6, stockMin: 3, luz: 'Sol directo', riego: 'Cada 3-4 semanas', notas: 'Muy resistente. Reducir riego drásticamente en invierno.', vendidosHist: 9, fechaIngreso: '2026-01-25', fechaCompra: '2026-01-25', foto: '' }, { id: 'p5', nombre: 'Potos (Epipremnum)', categoria: 'Interior', precio: 90, costo: 40, stock: 2, stockMin: 5, luz: 'Poca luz', riego: 'Cuando la tierra esté seca al tacto', notas: 'Ideal para principiantes y espacios con poca luz. Tolera el olvido ocasional.', vendidosHist: 34, fechaIngreso: '2026-02-01', fechaCompra: '2026-02-01', foto: '' }, { id: 'p6', nombre: 'Rosal Miniatura', categoria: 'Flores', precio: 180, costo: 90, stock: 5, stockMin: 4, luz: 'Sol directo', riego: 'Cada 2-3 días', notas: 'Fertilizar mensualmente en primavera-verano para favorecer la floración.', vendidosHist: 11, fechaIngreso: '2026-03-20', fechaCompra: '2026-03-20', foto: '' } ]; const seedSales = [ { id: 's1', plantId: 'p5', plantName: 'Potos (Epipremnum)', qty: 2, unitPrice: 90, discountType: 'none', discountValue: 0, discountAmt: 0, total: 180, fecha: '2026-06-12', folio: 'V-0001' }, { id: 's2', plantId: 'p1', plantName: 'Echeveria Elegans', qty: 3, unitPrice: 80, discountType: 'none', discountValue: 0, discountAmt: 0, total: 240, fecha: '2026-06-13', folio: 'V-0002' }, { id: 's3', plantId: 'p3', plantName: 'Lavanda', qty: 1, unitPrice: 120, discountType: 'pct', discountValue: 10, discountAmt: 12, total: 108, fecha: '2026-06-14', folio: 'V-0003' }, { id: 's4', plantId: 'p2', plantName: 'Monstera Deliciosa', qty: 1, unitPrice: 350, discountType: 'none', discountValue: 0, discountAmt: 0, total: 350, fecha: '2026-06-14', folio: 'V-0004' } ]; /* ---------------------------------------------------------- 2. CAPA DE DATOS (hoy: localStorage / luego: AJAX + PHP) ---------------------------------------------------------- */ let plants = []; let sales = []; let categories = []; function loadData() { // TODO BACKEND: sustituir por $.ajax({url:'api/plantas.php', ...}), // $.ajax({url:'api/ventas.php', ...}) y $.ajax({url:'api/categorias.php', ...}) // que devuelvan JSON análogo. try { const rawPlants = localStorage.getItem(STORAGE_PLANTS); plants = rawPlants ? JSON.parse(rawPlants) : JSON.parse(JSON.stringify(seedPlants)); } catch (e) { plants = JSON.parse(JSON.stringify(seedPlants)); } try { const rawSales = localStorage.getItem(STORAGE_SALES); sales = rawSales ? JSON.parse(rawSales) : JSON.parse(JSON.stringify(seedSales)); } catch (e) { sales = JSON.parse(JSON.stringify(seedSales)); } try { const rawCats = localStorage.getItem(STORAGE_CATEGORIES); categories = rawCats ? JSON.parse(rawCats) : JSON.parse(JSON.stringify(seedCategories)); } catch (e) { categories = JSON.parse(JSON.stringify(seedCategories)); } persistPlants(); persistSales(); persistCategories(); } function persistPlants() { // TODO BACKEND: $.ajax POST a api/plantas.php con { plants } y manejar respuesta try { localStorage.setItem(STORAGE_PLANTS, JSON.stringify(plants)); } catch (e) { showToast('No se pudo guardar: el almacenamiento local está lleno (probablemente por fotos muy pesadas).', 'danger'); } } function persistSales() { // TODO BACKEND: $.ajax POST a api/ventas.php con { sales } try { localStorage.setItem(STORAGE_SALES, JSON.stringify(sales)); } catch (e) {} } function persistCategories() { // TODO BACKEND: $.ajax POST a api/categorias.php con { categories } try { localStorage.setItem(STORAGE_CATEGORIES, JSON.stringify(categories)); } catch (e) {} } function categoryLabel(key) { const c = categories.find(c => c.key === key); return c ? c.label : key; } /* ---------------------------------------------------------- 3. ESTADO DE LA UI ---------------------------------------------------------- */ let activeCategory = 'all'; let searchTerm = ''; let chartTopSellers = null; let chartCategories = null; let currentDiscountType = 'none'; // 'none' | 'pct' | 'fixed' let lastSaleForTicket = null; /* ---------------------------------------------------------- 4. HELPERS ---------------------------------------------------------- */ function money(n) { return '$' + Number(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } function totalVendidos(plant) { const enVentas = sales.filter(s => s.plantId === plant.id).reduce((sum, s) => sum + Number(s.qty), 0); return Number(plant.vendidosHist || 0) + enVentas; } function formatDate(iso) { if (!iso) return '—'; const d = new Date(iso + 'T00:00:00'); return d.toLocaleDateString('es-MX', { day: '2-digit', month: 'short', year: 'numeric' }); } function daysSince(iso) { if (!iso) return null; const d = new Date(iso + 'T00:00:00'); const today = new Date(); today.setHours(0, 0, 0, 0); return Math.floor((today - d) / (1000 * 60 * 60 * 24)); } function nextFolio() { const n = sales.length + 1; return 'V-' + String(n).padStart(4, '0'); } function showToast(message, type = 'success') { const icon = type === 'success' ? 'bi-check-circle' : (type === 'danger' ? 'bi-exclamation-triangle' : 'bi-info-circle'); const bg = type === 'success' ? 'var(--primary)' : (type === 'danger' ? 'var(--clay)' : '#445'); const $toast = $(` `); $('#toastContainer').append($toast); const toast = new bootstrap.Toast($toast[0], { delay: 3500 }); toast.show(); $toast.on('hidden.bs.toast', () => $toast.remove()); } function escapeHtml(str) { return $('
').text(str || '').html(); } /* ---------------------------------------------------------- 5. NAVEGACIÓN ENTRE SECCIONES ---------------------------------------------------------- */ const SECTION_META = { panel: { title: 'Panel general', subtitle: 'Resumen de tu vivero hoy' }, inventario: { title: 'Inventario', subtitle: 'Agrega, edita y controla tu stock' }, ventas: { title: 'Ventas', subtitle: 'Registra ventas y revisa tu historial' }, cuidados: { title: 'Guía de cuidados', subtitle: 'Ficha de cuidados por planta' } }; $('.sidebar-nav .nav-link').on('click', function (e) { e.preventDefault(); const section = $(this).data('section'); $('.sidebar-nav .nav-link').removeClass('active'); $(this).addClass('active'); $('.content-section').removeClass('active'); $('#section-' + section).addClass('active'); $('#pageTitle').text(SECTION_META[section].title); $('#pageSubtitle').text(SECTION_META[section].subtitle); // mostrar el buscador solo en inventario $('#searchBoxWrap').toggle(section === 'inventario'); $('#btnAddPlant').toggle(section === 'inventario' || section === 'panel'); // cerrar sidebar en móvil $('#sidebar').removeClass('open'); $('#sidebarOverlay').removeClass('show'); }); // estado inicial de botones del topbar $('#searchBoxWrap').hide(); // sidebar móvil $('#btnMenu').on('click', function () { $('#sidebar').addClass('open'); $('#sidebarOverlay').addClass('show'); }); $('#sidebarOverlay').on('click', function () { $('#sidebar').removeClass('open'); $(this).removeClass('show'); }); /* ---------------------------------------------------------- 6. RENDER: PANEL (estadísticas, alertas, gráficas) ---------------------------------------------------------- */ function renderStats() { const totalUnidades = plants.reduce((s, p) => s + Number(p.stock), 0); const valorInventario = plants.reduce((s, p) => s + Number(p.stock) * Number(p.precio), 0); const ingresos = sales.reduce((s, v) => s + Number(v.total), 0); const alertas = plants.filter(p => Number(p.stock) <= Number(p.stockMin)).length; const tags = [ { label: 'Unidades en stock', value: totalUnidades, extra: 'plantas', cls: '' }, { label: 'Valor del inventario', value: money(valorInventario), extra: '', cls: 'green' }, { label: 'Ingresos por ventas', value: money(ingresos), extra: '', cls: 'green' }, { label: 'Alertas de stock bajo', value: alertas, extra: alertas === 1 ? 'planta' : 'plantas', cls: alertas > 0 ? 'accent' : '' } ]; const html = tags.map(t => `
${t.label}
${t.value} ${t.extra ? `${t.extra}` : ''}
`).join(''); $('#statTags').html(html); } function renderRestock() { const low = plants .filter(p => Number(p.stock) <= Number(p.stockMin)) .sort((a, b) => a.stock - b.stock); if (!low.length) { $('#restockList').html('

Todo tu inventario está por encima del mínimo. ¡Buen trabajo!

'); return; } const html = low.map(p => { const sugerido = Math.max(Number(p.stockMin) * 2 - Number(p.stock), Number(p.stockMin)); return `
${escapeHtml(p.nombre)}
stock ${p.stock} / mínimo ${p.stockMin}
comprar ~${sugerido}
`; }).join(''); $('#restockList').html(html); } function renderRecentSales() { const recent = [...sales].sort((a, b) => b.fecha.localeCompare(a.fecha) || b.id.localeCompare(a.id)).slice(0, 5); if (!recent.length) { $('#recentSales').html('

Aún no hay ventas registradas.

'); return; } const html = recent.map(s => `
${escapeHtml(s.plantName)}
${formatDate(s.fecha)} · ${s.qty} u.
${money(s.total)}
`).join(''); $('#recentSales').html(html); } function renderCharts() { // ---- Top vendidas ---- const ranked = [...plants] .map(p => ({ nombre: p.nombre, vendidos: totalVendidos(p) })) .sort((a, b) => b.vendidos - a.vendidos) .slice(0, 5); const ctx1 = document.getElementById('chartTopSellers'); const dataTop = { labels: ranked.map(r => r.nombre), datasets: [{ label: 'Unidades vendidas', data: ranked.map(r => r.vendidos), backgroundColor: '#3F6B4F', borderRadius: 6, maxBarThickness: 26 }] }; if (chartTopSellers) { chartTopSellers.data = dataTop; chartTopSellers.update(); } else { chartTopSellers = new Chart(ctx1, { type: 'bar', data: dataTop, options: { indexAxis: 'y', responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { beginAtZero: true, grid: { color: '#E4DCC8' }, ticks: { font: { family: 'IBM Plex Mono', size: 11 } } }, y: { grid: { display: false }, ticks: { font: { family: 'Quicksand', size: 12 } } } } } }); } // ---- Categorías (unidades en stock) ---- const catTotals = {}; plants.forEach(p => { catTotals[p.categoria] = (catTotals[p.categoria] || 0) + Number(p.stock); }); const catLabels = Object.keys(catTotals).map(c => categoryLabel(c)); const catValues = Object.values(catTotals); const ctx2 = document.getElementById('chartCategories'); const dataCat = { labels: catLabels, datasets: [{ data: catValues, backgroundColor: ['#3F6B4F', '#7FA889', '#C9694A', '#D9B26A', '#8AA1C9', '#B98AC9', '#5FA8A0', '#C98AAE', '#9AB35F', '#A88AC9'], borderWidth: 0 }] }; if (chartCategories) { chartCategories.data = dataCat; chartCategories.update(); } else { chartCategories = new Chart(ctx2, { type: 'doughnut', data: dataCat, options: { responsive: true, maintainAspectRatio: false, cutout: '62%', plugins: { legend: { position: 'bottom', labels: { font: { family: 'Quicksand', size: 11 }, boxWidth: 14 } } } } }); } } /* ---------------------------------------------------------- 7. RENDER: INVENTARIO ---------------------------------------------------------- */ function renderCategoryFilters() { const cats = [{ key: 'all', label: 'Todas' }, ...categories]; const html = cats.map(c => { return ``; }).join(''); $('#categoryFilters').html(html); $('.filter-chip').off('click').on('click', function () { activeCategory = $(this).data('cat'); $('.filter-chip').removeClass('active'); $(this).addClass('active'); renderPlantGrid(); }); } function renderPlantGrid() { let list = plants.filter(p => activeCategory === 'all' || p.categoria === activeCategory); if (searchTerm.trim()) { const q = searchTerm.trim().toLowerCase(); list = list.filter(p => p.nombre.toLowerCase().includes(q)); } if (!list.length) { $('#plantGrid').empty(); $('#inventoryEmpty').prop('hidden', false); return; } $('#inventoryEmpty').prop('hidden', true); const html = list.map(p => { const isLow = Number(p.stock) <= Number(p.stockMin); const isEmpty = Number(p.stock) === 0; const pct = Math.min(100, Math.round((Number(p.stock) / Math.max(Number(p.stockMin) * 2, 1)) * 100)); const barClass = isEmpty ? 'empty' : (isLow ? 'low' : ''); const lightIcon = LIGHT_ICONS[p.luz] || 'bi-sun'; const fechaCompra = p.fechaCompra || p.fechaIngreso; const dias = daysSince(fechaCompra); const fifoOld = dias !== null && dias >= 60; // 2+ meses en inventario: alerta para sacarla primero const photoHtml = p.foto ? `${escapeHtml(p.nombre)}` : `
`; return `
${isLow ? `${isEmpty ? 'Agotado' : 'Stock bajo'}` : ''}
${photoHtml}
${escapeHtml(p.nombre)}
${escapeHtml(categoryLabel(p.categoria))}
Precio ${money(p.precio)}
Costo ${money(p.costo)}
Vendidas ${totalVendidos(p)} u.
Stock
${p.stock} / mín ${p.stockMin}
Lote del ${formatDate(fechaCompra)} ${dias !== null ? `(${dias} días)` : ''} ${fifoOld ? ' · sacar primero' : ''}
${escapeHtml(p.luz)} ${escapeHtml(p.riego)}
`; }).join(''); $('#plantGrid').html(html); } $('#inventorySearch').on('input', function () { searchTerm = $(this).val(); renderPlantGrid(); }); // delegación: vender / editar desde la grilla $('#plantGrid').on('click', '[data-action="edit"]', function () { openPlantModal($(this).data('id')); }); $('#plantGrid').on('click', '[data-action="sell"]', function () { goToSaleFor($(this).data('id')); }); /* ---------------------------------------------------------- 8. MODAL: AGREGAR / EDITAR / ELIMINAR PLANTA ---------------------------------------------------------- */ const plantModalEl = document.getElementById('plantModal'); const plantModal = new bootstrap.Modal(plantModalEl); $('#btnAddPlant').on('click', () => openPlantModal(null)); function fillCategorySelect() { const html = categories.map(c => ``).join(''); $('#plantCategory').html(html); } function openPlantModal(id) { const plant = plants.find(p => p.id === id); fillCategorySelect(); $('#plantForm')[0].reset(); resetPhotoField(); if (plant) { $('#plantModalTitle').html(' Editar planta'); $('#plantId').val(plant.id); $('#plantName').val(plant.nombre); $('#plantCategory').val(plant.categoria); $('#plantStock').val(plant.stock); $('#plantMinStock').val(plant.stockMin); $('#plantCost').val(plant.costo); $('#plantPrice').val(plant.precio); $('#plantPurchaseDate').val(plant.fechaCompra || plant.fechaIngreso || ''); $('#plantLight').val(plant.luz); $('#plantWater').val(plant.riego); $('#plantNotes').val(plant.notas); $('#btnDeletePlant').prop('hidden', false); if (plant.foto) { $('#plantPhotoData').val(plant.foto); showPhotoPreview(plant.foto); } } else { $('#plantModalTitle').html(' Agregar planta'); $('#plantId').val(''); $('#plantMinStock').val(3); $('#plantPurchaseDate').val(new Date().toISOString().slice(0, 10)); $('#btnDeletePlant').prop('hidden', true); } plantModal.show(); } /* ---- manejo de foto (subir archivo -> base64 -> preview) ---- */ function resetPhotoField() { $('#plantPhotoData').val(''); $('#photoFileInput').val(''); $('#photoPreviewImg').attr('src', '').hide(); $('#photoPlaceholder').show(); } function showPhotoPreview(dataUrl) { $('#photoPreviewImg').attr('src', dataUrl).show(); $('#photoPlaceholder').hide(); } $('#photoPreviewWrap').on('click', () => $('#photoFileInput').trigger('click')); $('#photoFileInput').on('change', function (e) { const file = e.target.files && e.target.files[0]; if (!file) return; if (!file.type.startsWith('image/')) { showToast('Por favor selecciona un archivo de imagen.', 'danger'); return; } // Límite razonable para no saturar localStorage (~5MB por sitio) if (file.size > 2 * 1024 * 1024) { showToast('La imagen es muy pesada (máx. 2MB). Intenta con una foto comprimida.', 'danger'); return; } const reader = new FileReader(); reader.onload = function (ev) { const dataUrl = ev.target.result; $('#plantPhotoData').val(dataUrl); showPhotoPreview(dataUrl); }; reader.readAsDataURL(file); }); $('#plantForm').on('submit', function (e) { e.preventDefault(); const id = $('#plantId').val(); const data = { nombre: $('#plantName').val().trim(), categoria: $('#plantCategory').val(), stock: Number($('#plantStock').val()), stockMin: Number($('#plantMinStock').val() || 0), costo: Number($('#plantCost').val()), precio: Number($('#plantPrice').val()), fechaCompra: $('#plantPurchaseDate').val(), luz: $('#plantLight').val(), riego: $('#plantWater').val().trim(), notas: $('#plantNotes').val().trim(), foto: $('#plantPhotoData').val() || '' }; if (id) { const plant = plants.find(p => p.id === id); Object.assign(plant, data); showToast(`"${data.nombre}" se actualizó correctamente.`); } else { data.id = 'p' + Date.now(); data.vendidosHist = 0; data.fechaIngreso = data.fechaCompra || new Date().toISOString().slice(0, 10); plants.push(data); showToast(`"${data.nombre}" se agregó al inventario.`); } persistPlants(); plantModal.hide(); renderEverything(); }); $('#btnDeletePlant').on('click', function () { const id = $('#plantId').val(); const plant = plants.find(p => p.id === id); if (!plant) return; if (!confirm(`¿Eliminar "${plant.nombre}" del inventario? Esta acción no se puede deshacer.`)) return; plants = plants.filter(p => p.id !== id); persistPlants(); plantModal.hide(); showToast(`"${plant.nombre}" se eliminó del inventario.`, 'info'); renderEverything(); }); /* ---------------------------------------------------------- 9. GESTIÓN DE CATEGORÍAS ---------------------------------------------------------- */ const categoryModalEl = document.getElementById('categoryModal'); const categoryModal = new bootstrap.Modal(categoryModalEl); $('#btnManageCategories').on('click', function () { resetCategoryForm(); renderCategoryManageList(); categoryModal.show(); }); function slugifyCategory(label) { let base = label .normalize('NFD').replace(/[\u0300-\u036f]/g, '') // quitar acentos .replace(/[^a-zA-Z0-9]+/g, ''); if (!base) base = 'Cat' + Date.now(); let key = base; let i = 2; while (categories.some(c => c.key === key)) { key = base + i; i++; } return key; } function resetCategoryForm() { $('#categoryForm')[0].reset(); $('#categoryEditKey').val(''); $('#categorySubmitBtn').html(' Agregar'); } function renderCategoryManageList() { if (!categories.length) { $('#categoryList').html('

Aún no tienes categorías. Agrega la primera arriba.

'); return; } const html = categories.map(c => { const count = plants.filter(p => p.categoria === c.key).length; const canDelete = count === 0; return `
${escapeHtml(c.label)} ${count} ${count === 1 ? 'planta' : 'plantas'}
`; }).join(''); $('#categoryList').html(html); } $('#categoryForm').on('submit', function (e) { e.preventDefault(); const label = $('#categoryNewLabel').val().trim(); if (!label) return; const editKey = $('#categoryEditKey').val(); if (editKey) { const cat = categories.find(c => c.key === editKey); if (cat) { cat.label = label; showToast(`Categoría renombrada a "${label}".`); } } else { // evitar duplicados por nombre (case-insensitive) if (categories.some(c => c.label.toLowerCase() === label.toLowerCase())) { showToast('Ya existe una categoría con ese nombre.', 'danger'); return; } categories.push({ key: slugifyCategory(label), label }); showToast(`Categoría "${label}" agregada.`); } persistCategories(); resetCategoryForm(); renderCategoryManageList(); renderCategoryFilters(); fillCategorySelect(); renderPlantGrid(); renderCharts(); }); $('#categoryList').on('click', '.cmi-edit', function () { const key = $(this).data('key'); const cat = categories.find(c => c.key === key); if (!cat) return; $('#categoryEditKey').val(key); $('#categoryNewLabel').val(cat.label).focus(); $('#categorySubmitBtn').html(' Guardar cambio'); }); $('#categoryList').on('click', '.cmi-delete:not(:disabled)', function () { const key = $(this).data('key'); const cat = categories.find(c => c.key === key); if (!cat) return; if (!confirm(`¿Eliminar la categoría "${cat.label}"?`)) return; categories = categories.filter(c => c.key !== key); if (activeCategory === key) activeCategory = 'all'; persistCategories(); resetCategoryForm(); renderCategoryManageList(); renderCategoryFilters(); fillCategorySelect(); renderPlantGrid(); renderCharts(); showToast(`Categoría "${cat.label}" eliminada.`, 'info'); }); /* ---------------------------------------------------------- 10. VENTAS (con descuento opcional) ---------------------------------------------------------- */ function renderSaleSelect() { const current = $('#salePlant').val(); const html = plants.map(p => ``).join(''); $('#salePlant').html(html); if (current && plants.some(p => p.id === current)) $('#salePlant').val(current); updateSaleFormFromSelection(); } function updateSaleFormFromSelection() { const plant = plants.find(p => p.id === $('#salePlant').val()); if (!plant) { $('#saleStockHint').text('Agrega plantas a tu inventario para poder registrar ventas.'); return; } $('#salePrice').val(plant.precio); const qty = Math.max(1, Number($('#saleQty').val() || 1)); $('#saleQty').attr('max', plant.stock); if (qty > plant.stock) $('#saleQty').val(plant.stock || 1); updateSaleTotal(); $('#saleStockHint').removeClass('warn').text(`Stock disponible de "${plant.nombre}": ${plant.stock} unidades.`); } function calcDiscount(subtotal) { const val = Number($('#discountValue').val() || 0); if (currentDiscountType === 'pct') { const amt = subtotal * (Math.min(val, 100) / 100); return { amt, label: `Descuento (${val}%)` }; } if (currentDiscountType === 'fixed') { const amt = Math.min(val, subtotal); return { amt, label: 'Descuento' }; } return { amt: 0, label: 'Descuento' }; } function updateSaleTotal() { const qty = Number($('#saleQty').val() || 0); const price = Number($('#salePrice').val() || 0); const subtotal = qty * price; const { amt, label } = calcDiscount(subtotal); const total = Math.max(0, subtotal - amt); $('#saleTotalDisplay').text(money(total)); if (currentDiscountType !== 'none' && amt > 0) { $('#discountLabel').text(`−${money(amt)} de descuento`); } else { $('#discountLabel').text(''); } } $('#salePlant').on('change', updateSaleFormFromSelection); $('#saleQty, #salePrice').on('input', updateSaleTotal); // toggle de tipo de descuento $('.disc-type-btn').on('click', function () { currentDiscountType = $(this).data('type'); $('.disc-type-btn').removeClass('active'); $(this).addClass('active'); if (currentDiscountType === 'none') { $('#discountValue').hide().val(0); } else { $('#discountValue').show().attr('placeholder', currentDiscountType === 'pct' ? '% de descuento' : 'monto en $'); if (currentDiscountType === 'pct') $('#discountValue').attr('max', 100); else $('#discountValue').removeAttr('max'); } updateSaleTotal(); }); $('#discountValue').on('input', updateSaleTotal); $('#saleForm').on('submit', function (e) { e.preventDefault(); const plant = plants.find(p => p.id === $('#salePlant').val()); if (!plant) { showToast('Primero agrega una planta al inventario.', 'danger'); return; } const qty = Number($('#saleQty').val()); const price = Number($('#salePrice').val()); const subtotal = qty * price; const { amt: discountAmt } = calcDiscount(subtotal); const discountValue = Number($('#discountValue').val() || 0); const total = Math.max(0, subtotal - discountAmt); if (qty <= 0) { showToast('La cantidad debe ser mayor a 0.', 'danger'); return; } if (qty > plant.stock) { $('#saleStockHint').addClass('warn').text(`Solo hay ${plant.stock} unidades de "${plant.nombre}" disponibles.`); showToast('No hay suficiente stock para esa cantidad.', 'danger'); return; } plant.stock -= qty; const sale = { id: 'v' + Date.now(), plantId: plant.id, plantName: plant.nombre, qty, unitPrice: price, discountType: currentDiscountType, discountValue: currentDiscountType === 'none' ? 0 : discountValue, discountAmt, total, fecha: new Date().toISOString().slice(0, 10), folio: nextFolio() }; sales.push(sale); persistPlants(); persistSales(); showToast(`Venta registrada: ${qty} × "${plant.nombre}" = ${money(total)}.`); // reset del formulario de venta (incluye descuento) $('#saleQty').val(1); $('.disc-type-btn[data-type="none"]').trigger('click'); renderEverything(); openTicket(sale); }); function renderSalesTable() { const sorted = [...sales].sort((a, b) => b.fecha.localeCompare(a.fecha) || b.id.localeCompare(a.id)); if (!sorted.length) { $('#salesTableBody').empty(); $('#salesEmpty').prop('hidden', false); $('#salesSummary').text(''); return; } $('#salesEmpty').prop('hidden', true); const totalIngresos = sales.reduce((s, v) => s + Number(v.total), 0); const totalUnidades = sales.reduce((s, v) => s + Number(v.qty), 0); $('#salesSummary').text(`${sorted.length} ventas · ${totalUnidades} unidades · ${money(totalIngresos)}`); const html = sorted.map(s => { const discText = s.discountAmt > 0 ? (s.discountType === 'pct' ? `${s.discountValue}%` : money(s.discountAmt)) : '—'; return ` ${formatDate(s.fecha)} ${escapeHtml(s.plantName)} ${s.qty} ${money(s.unitPrice)} ${discText} ${money(s.total)} `; }).join(''); $('#salesTableBody').html(html); } $('#salesTableBody').on('click', '.del-sale-btn', function () { const id = $(this).data('id'); const sale = sales.find(s => s.id === id); if (!sale) return; if (!confirm(`¿Eliminar esta venta de "${sale.plantName}"? El stock vendido (${sale.qty}) se devolverá al inventario.`)) return; const plant = plants.find(p => p.id === sale.plantId); if (plant) plant.stock += sale.qty; sales = sales.filter(s => s.id !== id); persistPlants(); persistSales(); showToast('Venta eliminada y stock devuelto al inventario.', 'info'); renderEverything(); }); $('#salesTableBody').on('click', '.ticket-btn', function () { const id = $(this).data('id'); const sale = sales.find(s => s.id === id); if (sale) openTicket(sale); }); // Ir a la pestaña de ventas con la planta preseleccionada (botón "Vender") function goToSaleFor(plantId) { $('.sidebar-nav .nav-link[data-section="ventas"]').trigger('click'); $('#salePlant').val(plantId); updateSaleFormFromSelection(); $('html, body').animate({ scrollTop: 0 }, 200); $('#salePlant').focus(); } /* ---------------------------------------------------------- 11. TICKET DE VENTA (ver, imprimir, descargar, enviar) ---------------------------------------------------------- */ function openTicket(sale) { lastSaleForTicket = sale; const subtotal = sale.qty * sale.unitPrice; const fechaTxt = new Date(sale.fecha + 'T00:00:00').toLocaleString('es-MX', { day: '2-digit', month: 'short', year: 'numeric' }); $('#ticketDate').text(fechaTxt); $('#ticketFolio').text(sale.folio || sale.id); $('#ticketPlant').text(sale.plantName); $('#ticketQty').text(sale.qty); $('#ticketUnitPrice').text(money(sale.unitPrice)); $('#ticketSubtotal').text(money(subtotal)); $('#ticketTotal').text(money(sale.total)); if (sale.discountAmt > 0) { const label = sale.discountType === 'pct' ? `Descuento (${sale.discountValue}%)` : 'Descuento'; $('#ticketDiscountLabel').text(label); $('#ticketDiscountAmt').text('−' + money(sale.discountAmt)); $('#ticketDiscountRow').show(); } else { $('#ticketDiscountRow').hide(); } $('#ticketOverlay').addClass('show'); } function closeTicket() { $('#ticketOverlay').removeClass('show'); } $('#ticketClose').on('click', closeTicket); $('#ticketOverlay').on('click', function (e) { if (e.target === this) closeTicket(); }); $('#ticketPrint').on('click', function () { window.print(); }); $('#ticketDownload').on('click', function () { if (typeof html2canvas === 'undefined') { showToast('No se pudo cargar la herramienta para descargar la imagen. Revisa tu conexión.', 'danger'); return; } const ticketEl = document.querySelector('.ticket-paper'); html2canvas(ticketEl, { backgroundColor: '#ffffff', scale: 2 }).then(canvas => { const link = document.createElement('a'); const folio = lastSaleForTicket ? (lastSaleForTicket.folio || lastSaleForTicket.id) : 'ticket'; link.download = `ticket-${folio}.png`; link.href = canvas.toDataURL('image/png'); link.click(); }).catch(() => { showToast('No se pudo generar la imagen del ticket.', 'danger'); }); }); $('#ticketShare').on('click', function () { if (!lastSaleForTicket) return; const s = lastSaleForTicket; const lines = [ `🌿 *ViveroApp* — Ticket ${s.folio || s.id}`, `Planta: ${s.plantName}`, `Cantidad: ${s.qty}`, `Precio unitario: ${money(s.unitPrice)}`, s.discountAmt > 0 ? `Descuento: −${money(s.discountAmt)}` : null, `Total: ${money(s.total)}`, `Fecha: ${formatDate(s.fecha)}`, '¡Gracias por tu compra! 🌱' ].filter(Boolean).join('\n'); const url = 'https://wa.me/?text=' + encodeURIComponent(lines); window.open(url, '_blank'); }); /* ---------------------------------------------------------- 12. GUÍA DE CUIDADOS ---------------------------------------------------------- */ function renderCareGrid() { if (!plants.length) { $('#careGrid').html('

Agrega plantas a tu inventario para ver su ficha de cuidados aquí.

'); return; } const html = plants.map(p => { const lightIcon = LIGHT_ICONS[p.luz] || 'bi-sun'; const photoHtml = p.foto ? `${escapeHtml(p.nombre)}` : `
`; return `
${photoHtml}
${escapeHtml(categoryLabel(p.categoria))}

${escapeHtml(p.nombre)}

Luz: ${escapeHtml(p.luz)}
Riego: ${escapeHtml(p.riego) || 'No especificado'}
Precio: ${money(p.precio)}
${p.notas ? `
${escapeHtml(p.notas)}
` : ''}
`; }).join(''); $('#careGrid').html(html); } /* ---------------------------------------------------------- 13. RENDER GLOBAL ---------------------------------------------------------- */ function renderEverything() { renderStats(); renderRestock(); renderRecentSales(); renderCharts(); renderCategoryFilters(); renderPlantGrid(); renderSaleSelect(); renderSalesTable(); renderCareGrid(); } /* ---------------------------------------------------------- 14. ARRANQUE ---------------------------------------------------------- */ loadData(); renderCategoryFilters(); renderEverything(); });