/* ============================================================
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
? `

`
: `
`;
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
? `

`
: `
`;
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();
});