import React, { useState, useMemo, useEffect, useRef } from 'react'; import ReactDOM from 'react-dom/client'; import { LayoutDashboard, TrendingUp, ShoppingCart, Receipt, Users, Settings, Tags, Menu, X, Plus, Edit2, Trash2, Download, Upload, Search, Calendar, LogOut, Check, AlertTriangle, DollarSign, PieChart as PieChartIcon, FileText, FileSpreadsheet } from 'lucide-react'; import { PieChart, Pie, Cell, BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer } from 'recharts'; const COLOR_PALETTE = ['#4f46e5', '#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8B5CF6', '#EC4899', '#10B981', '#F43F5E', '#14B8A6']; // ============================================================================ // Cierre Operativo guarda todo en el mismo proyecto Supabase que Finanzas y // Caja Menor (window.SB, ver supabase-client.js), con las mismas 2 cuentas. // Las tablas op_* son compartidas entre todos los usuarios autenticados (no // aisladas por user_id) porque este es el cierre de TODA la operación, no // datos personales de cada quién — mismo criterio que cm_* en Caja Menor. // ============================================================================ const periodFromDb = (r) => ({ id: r.id, month: r.month, startDate: r.start_date, endDate: r.end_date, isClosed: !!r.is_closed }); const periodToDb = (p) => ({ month: p.month, start_date: p.startDate, end_date: p.endDate, is_closed: !!p.isClosed }); const catFromDb = (r) => ({ id: r.id, name: r.name, color: r.color, parentId: r.parent_id || '', sourceCmCategoriaId: r.source_cm_categoria_id || '' }); const catToDb = (c) => ({ name: c.name, color: c.color || '#4f46e5' }); const ventaFromDb = (r) => ({ id: r.id, periodId: r.period_id, fecha: r.fecha, producto: r.producto || '', cliente: r.cliente || '', sucursal: r.sucursal || '', cantidad: Number(r.cantidad) || 0, valor: Number(r.valor) || 0, fuente: r.fuente || '', documento: r.documento || '', unidad: r.unidad || '', precioUnitario: Number(r.precio_unitario) || 0, origen: r.origen || '' }); const ventaToDb = (v) => ({ period_id: v.periodId, fecha: v.fecha || null, producto: v.producto || '', cliente: v.cliente || '', sucursal: v.sucursal || '', cantidad: Number(v.cantidad) || 0, valor: Number(v.valor) || 0, fuente: v.fuente || null, documento: v.documento || null, unidad: v.unidad || null, precio_unitario: v.precioUnitario != null ? Number(v.precioUnitario) : null }); const compraFromDb = (r) => ({ id: r.id, periodId: r.period_id, fecha: r.fecha, producto: r.producto || '', proveedor: r.proveedor || '', cantidad: Number(r.cantidad) || 0, valor: Number(r.valor) || 0, presentacion: r.presentacion || '', unidad: r.unidad || '', precioUnitario: Number(r.precio_unitario) || 0, metodoPago: r.metodo_pago || '', compradora: r.compradora || '', origen: r.origen || '' }); const compraToDb = (c) => ({ period_id: c.periodId, fecha: c.fecha || null, producto: c.producto || '', proveedor: c.proveedor || '', cantidad: Number(c.cantidad) || 0, valor: Number(c.valor) || 0, presentacion: c.presentacion || null, unidad: c.unidad || null, precio_unitario: c.precioUnitario != null ? Number(c.precioUnitario) : null, metodo_pago: c.metodoPago || null, compradora: c.compradora || null }); const gastoFromDb = (r) => ({ id: r.id, periodId: r.period_id, fecha: r.fecha, categoryId: r.category_id || '', descripcion: r.descripcion || '', proveedor: r.proveedor || '', valor: Number(r.valor) || 0, sourceCmId: r.source_cm_id || '', origen: r.origen || '' }); const gastoToDb = (g) => ({ period_id: g.periodId, fecha: g.fecha || null, category_id: g.categoryId || null, descripcion: g.descripcion || '', proveedor: g.proveedor || '', valor: Number(g.valor) || 0 }); const nominaFromDb = (r) => ({ id: r.id, periodId: r.period_id, empleado: r.empleado || '', cargo: r.cargo || '', salarioBase: Number(r.salario_base) || 0, horasExtras: Number(r.horas_extras) || 0, valorHorasExtras: Number(r.valor_horas_extras) || 0, deducciones: Number(r.deducciones) || 0, totalPagado: Number(r.total_pagado) || 0, documento: r.documento || '', periodoQuincena: r.periodo_quincena || '', auxilioTransporte: Number(r.auxilio_transporte) || 0, valorMensualBase: Number(r.valor_mensual_base) || 0, valorQuincenaBase: Number(r.valor_quincena_base) || 0, valorUnitarioHoraExtra: Number(r.valor_unitario_hora_extra) || 0, recargoNocturnoHoras: Number(r.recargo_nocturno_horas) || 0, valorUnitarioRecargoNocturno: Number(r.valor_unitario_recargo_nocturno) || 0, valorRecargoNocturno: Number(r.valor_recargo_nocturno) || 0, recargoDominicalHoras: Number(r.recargo_dominical_horas) || 0, valorUnitarioRecargoDominical: Number(r.valor_unitario_recargo_dominical) || 0, valorRecargoDominical: Number(r.valor_recargo_dominical) || 0, horaExtraDominicalHoras: Number(r.hora_extra_dominical_horas) || 0, valorUnitarioHoraExtraDominical: Number(r.valor_unitario_hora_extra_dominical) || 0, valorHoraExtraDominical: Number(r.valor_hora_extra_dominical) || 0, valorTotalExtrasRecargos: Number(r.valor_total_extras_recargos) || 0, parafiscales: Number(r.parafiscales) || 0, adicionalesComisiones: Number(r.adicionales_comisiones) || 0, origen: r.origen || '', }); const nominaToDb = (n) => ({ period_id: n.periodId, empleado: n.empleado || '', cargo: n.cargo || '', salario_base: Number(n.salarioBase) || 0, horas_extras: Number(n.horasExtras) || 0, valor_horas_extras: Number(n.valorHorasExtras) || 0, deducciones: Number(n.deducciones) || 0, total_pagado: Number(n.totalPagado) || 0, documento: n.documento || null, periodo_quincena: n.periodoQuincena || null, auxilio_transporte: n.auxilioTransporte != null ? Number(n.auxilioTransporte) : null, valor_mensual_base: n.valorMensualBase != null ? Number(n.valorMensualBase) : null, valor_quincena_base: n.valorQuincenaBase != null ? Number(n.valorQuincenaBase) : null, valor_unitario_hora_extra: n.valorUnitarioHoraExtra != null ? Number(n.valorUnitarioHoraExtra) : null, recargo_nocturno_horas: n.recargoNocturnoHoras != null ? Number(n.recargoNocturnoHoras) : null, valor_unitario_recargo_nocturno: n.valorUnitarioRecargoNocturno != null ? Number(n.valorUnitarioRecargoNocturno) : null, valor_recargo_nocturno: n.valorRecargoNocturno != null ? Number(n.valorRecargoNocturno) : null, recargo_dominical_horas: n.recargoDominicalHoras != null ? Number(n.recargoDominicalHoras) : null, valor_unitario_recargo_dominical: n.valorUnitarioRecargoDominical != null ? Number(n.valorUnitarioRecargoDominical) : null, valor_recargo_dominical: n.valorRecargoDominical != null ? Number(n.valorRecargoDominical) : null, hora_extra_dominical_horas: n.horaExtraDominicalHoras != null ? Number(n.horaExtraDominicalHoras) : null, valor_unitario_hora_extra_dominical: n.valorUnitarioHoraExtraDominical != null ? Number(n.valorUnitarioHoraExtraDominical) : null, valor_hora_extra_dominical: n.valorHoraExtraDominical != null ? Number(n.valorHoraExtraDominical) : null, valor_total_extras_recargos: n.valorTotalExtrasRecargos != null ? Number(n.valorTotalExtrasRecargos) : null, parafiscales: n.parafiscales != null ? Number(n.parafiscales) : null, adicionales_comisiones: n.adicionalesComisiones != null ? Number(n.adicionalesComisiones) : null, }); const formatCurrency = (value) => new Intl.NumberFormat('es-CO', { style: 'currency', currency: 'COP', minimumFractionDigits: 0 }).format(value || 0); // Agrupa y suma "valor" de un conjunto de filas por una clave (cliente, producto, proveedor, etc.) // Agrupa por nombre "normalizado" (sin tildes, sin mayúsculas/minúsculas, sin // espacios de más) para que variantes como "Comercializadora... S.A.S" y // "COMERCIALIZADORA... S.A.S" no queden como dos filas separadas con el // total partido — se fusionan en una sola, sumando ambas, y se muestra la // variante de escritura que más se repite como etiqueta. function groupSum(rows, keyFn) { const map = {}; rows.forEach(r => { const raw = (keyFn(r) || '').trim() || 'Sin dato'; const key = normalizeCatName(raw); if (!map[key]) map[key] = { name: raw, value: 0, counts: {} }; map[key].value += Number(r.valor) || 0; map[key].counts[raw] = (map[key].counts[raw] || 0) + 1; if (map[key].counts[raw] > (map[key].counts[map[key].name] || 0)) map[key].name = raw; }); return Object.values(map).map(({ name, value }) => ({ name, value })).sort((a, b) => b.value - a.value); } // Agrupa filas de ventas o compras (ambas traen producto/cantidad/valor) en un // solo registro totalizado por producto — para poder "jugar" con desgloses: // ver cada línea o ver el total real por referencia. function porProductoDetallado(rows) { const map = {}; rows.forEach(r => { const key = normalizeCatName(r.producto) || 'sin-producto'; if (!map[key]) map[key] = { id: key, producto: r.producto || 'Sin producto', cantidad: 0, valor: 0, lineas: 0 }; map[key].cantidad += Number(r.cantidad) || 0; map[key].valor += Number(r.valor) || 0; map[key].lineas += 1; }); return Object.values(map).map(r => ({ ...r, precioPromedio: r.cantidad ? r.valor / r.cantidad : 0 })); } // Igual que groupSum, pero sumando también "cantidad" — para las tablas de // Top Productos del Informe Ejecutivo (PDF), que además del valor necesitan // mostrar el total de kilos/unidades vendidos o comprados. function groupSumWithQty(rows, keyFn) { const map = {}; rows.forEach(r => { const raw = (keyFn(r) || '').trim() || 'Sin dato'; const key = normalizeCatName(raw); if (!map[key]) map[key] = { name: raw, value: 0, cantidad: 0, counts: {} }; map[key].value += Number(r.valor) || 0; map[key].cantidad += Number(r.cantidad) || 0; map[key].counts[raw] = (map[key].counts[raw] || 0) + 1; if (map[key].counts[raw] > (map[key].counts[map[key].name] || 0)) map[key].name = raw; }); return Object.values(map).map(({ name, value, cantidad }) => ({ name, value, cantidad })).sort((a, b) => b.value - a.value); } // Igual que porProductoDetallado, pero totalizando por cliente/proveedor en // vez de por producto — mismo criterio de "jugar con desgloses" pedido. function agruparTotales(rows, keyFn, nameField) { const map = {}; rows.forEach(r => { const raw = keyFn(r); const key = normalizeCatName(raw) || 'sin-dato'; if (!map[key]) map[key] = { id: key, [nameField]: raw || 'Sin dato', cantidad: 0, valor: 0, lineas: 0 }; map[key].cantidad += Number(r.cantidad) || 0; map[key].valor += Number(r.valor) || 0; map[key].lineas += 1; }); return Object.values(map).map(r => ({ ...r, precioPromedio: r.cantidad ? r.valor / r.cantidad : 0 })); } // Igual que agruparTotales pero sin cantidad/precio (los gastos no manejan // cantidad) — para "Totalizar por Categoría"/"Totalizar por Proveedor" en el // detalle de Análisis de Gastos. function agruparValorGastos(rows, keyFn, nameField) { const map = {}; rows.forEach(r => { const raw = keyFn(r); const key = normalizeCatName(raw) || 'sin-dato'; if (!map[key]) map[key] = { id: key, [nameField]: raw || 'Sin dato', valor: 0, lineas: 0 }; map[key].valor += Number(r.valor) || 0; map[key].lineas += 1; }); return Object.values(map); } function hexToRgb(hex) { return hex.replace('#', '').match(/.{2}/g).map(h => parseInt(h, 16)); } // Barra horizontal genérica para el Informe Ejecutivo (PDF) — dibujada a mano // con rect()/text() en vez de capturar los gráficos de recharts (más liviano // y nítido), mismo criterio ya usado en el "Resumen Ejecutivo" de Caja Menor. function drawBarChartPdf(doc, dataPairs, x, y, width, color, title) { doc.setFontSize(11); doc.setFont(undefined, 'bold'); doc.setTextColor(15, 23, 42); doc.text(title, x, y); const maxVal = Math.max(...dataPairs.map(d => d[1]), 1); const labelW = 170; const barMaxWidth = Math.max(width - labelW - 80, 40); const [r, g, b] = hexToRgb(color); let cy = y + 20; dataPairs.forEach(([name, value]) => { const w = Math.max((value / maxVal) * barMaxWidth, 3); doc.setFontSize(8.5); doc.setFont(undefined, 'normal'); doc.setTextColor(30, 41, 59); doc.text(name.length > 26 ? `${name.slice(0, 26)}…` : name, x, cy); doc.setFillColor(r, g, b); doc.rect(x + labelW, cy - 8, w, 11, 'F'); doc.setFontSize(8); doc.setTextColor(30, 41, 59); doc.text(formatCurrency(value), x + labelW + w + 6, cy); cy += 18; }); return cy + 8; } // Carga perezosa de SheetJS (window.XLSX) — compartida por las 4 plantillas/importaciones. let xlsxLoadPromise = null; function ensureXLSX() { if (window.XLSX) return Promise.resolve(window.XLSX); if (!xlsxLoadPromise) { xlsxLoadPromise = new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = 'https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js'; script.onload = () => resolve(window.XLSX); script.onerror = reject; document.head.appendChild(script); }); } return xlsxLoadPromise; } // Carga perezosa de jsPDF + autotable para el Informe Ejecutivo (Reportes) — // mismo patrón/versión ya probado en caja-menor/app.jsx (autotable debe // cargar DESPUÉS de jsPDF porque se engancha a su prototipo). let jspdfLoadPromise = null; function ensureJSPDF() { if (window.jspdf && window.jspdf.jsPDF && window.jspdf.jsPDF.API.autoTable) return Promise.resolve(window.jspdf.jsPDF); if (!jspdfLoadPromise) { jspdfLoadPromise = new Promise((resolve, reject) => { const s1 = document.createElement('script'); s1.src = 'https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js'; s1.onload = () => { const s2 = document.createElement('script'); s2.src = 'https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js'; s2.onload = () => resolve(window.jspdf.jsPDF); s2.onerror = reject; document.head.appendChild(s2); }; s1.onerror = reject; document.head.appendChild(s1); }); } return jspdfLoadPromise; } // jsPDF no compone bien el canal alfa de los PNG con transparencia (pinta esas // zonas de negro sólido) — se "aplana" el logo sobre un canvas relleno con el // color de fondo real del PDF antes de pasarlo a addImage. Mismo patrón que // caja-menor/app.jsx (cmLoadFlattenedLogo/cmAddLogo). Fondo del informe es // blanco, así que se usa el logo de texto negro para que contraste bien. const opLogoDataUrlCache = {}; function opLoadFlattenedLogo(src, bgColor) { const cacheKey = `${src}|${bgColor}`; if (!opLogoDataUrlCache[cacheKey]) { opLogoDataUrlCache[cacheKey] = new Promise((resolve, reject) => { const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => { const canvas = document.createElement('canvas'); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; const ctx = canvas.getContext('2d'); ctx.fillStyle = bgColor; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.drawImage(img, 0, 0); resolve(canvas.toDataURL('image/png')); }; img.onerror = reject; img.src = src; }); } return opLogoDataUrlCache[cacheKey]; } async function opAddLogo(doc, x, y, maxHeight) { try { const dataUrl = await opLoadFlattenedLogo('../assets/logo-negro.png', '#ffffff'); const props = doc.getImageProperties(dataUrl); const w = (props.width / props.height) * maxHeight; doc.addImage(dataUrl, 'PNG', x, y, w, maxHeight); return w; } catch (err) { return 0; } } function parseFecha(v) { if (v instanceof Date) return v.toISOString().slice(0, 10); if (typeof v === 'number') { const ms = Math.round((v - 25569) * 86400 * 1000); return new Date(ms).toISOString().slice(0, 10); } const s = String(v || '').trim(); const m = s.match(/^(\d{4})-(\d{2})-(\d{2})/); return m ? m[0] : null; } function parseNum(v) { if (typeof v === 'number') return v; const n = parseFloat(String(v || '').replace(/\./g, '').replace(',', '.')); return isNaN(n) ? (parseFloat(v) || 0) : n; } // Fecha en texto "dd/mm/yyyy" (formato del extracto de Corabastos) — parseFecha // original solo entiende Date/serial/ISO, así que se prueba aparte sin tocarla. function parseFechaFlexible(v) { const iso = parseFecha(v); if (iso) return iso; const s = String(v || '').trim(); const m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/); if (m) { const [, d, mo, y] = m; return `${y}-${mo.padStart(2, '0')}-${d.padStart(2, '0')}`; } return null; } // Compara nombres de categoría de forma tolerante a mayúsculas/acentos/espacios // — para poder cruzar "Peajes" con "peajes" o " Ruta Adicional " con "ruta adicional". const ACCENT_MAP = { á: 'a', é: 'e', í: 'i', ó: 'o', ú: 'u', ñ: 'n', ü: 'u' }; function normalizeCatName(s) { return String(s || '') .trim().toLowerCase().replace(/\s+/g, ' ') .replace(/[áéíóúñü]/g, ch => ACCENT_MAP[ch] || ch); } // Los gastos de Corabastos con descripción "Caja menor Bodega"/"Caja menor // bodega" no son un gasto real — es efectivo que la caja de Corabastos le // traslada a Caja Menor (una transferencia interna). El gasto de verdad ya // queda registrado del lado de Caja Menor (o reduce su saldo), así que // contarlo aquí TAMBIÉN duplicaría el gasto. Se detecta por texto (no por // una marca en la importación) para que aplique también a datos ya // importados, sin tener que volver a subir el archivo. function esTransferenciaCajaMenor(g) { return g.origen === 'corabastos' && /caja\s*menor/i.test(g.descripcion || ''); } // Huella determinística de una fila importada — permite hacer upsert (reimportar // el mismo archivo actualiza en vez de duplicar) aunque el origen no traiga un // número consecutivo propio. `seq` desempata filas idénticas dentro del archivo. function rowFingerprint(fecha, valor, descripcion, seq) { return `${fecha}|${valor}|${normalizeCatName(descripcion)}|${seq}`; } // Inserta en lotes de 500 filas para no exceder límites de tamaño de payload — // una carga mensual completa de ventas puede tener cientos o miles de filas. async function bulkInsert(table, rows, chunkSize = 500) { const inserted = []; for (let i = 0; i < rows.length; i += chunkSize) { const chunk = rows.slice(i, i + chunkSize); const res = await window.SB.crudInsert(table, chunk); inserted.push(...res); } return inserted; } // Igual que bulkInsert pero con upsert (usado por los importadores de Corabastos // y Gastos Generales, que deben poder reimportarse sin duplicar filas). async function bulkUpsert(table, rows, onConflict, chunkSize = 500) { const upserted = []; for (let i = 0; i < rows.length; i += chunkSize) { const chunk = rows.slice(i, i + chunkSize); const res = await window.SB.crudUpsert(table, chunk, { onConflict }); upserted.push(...res); } return upserted; } // Primer y último día del mes "YYYY-MM" (para crear el op_periodo automático // cuando una fila importada cae en un mes que todavía no existe). function monthBounds(monthStr) { const [y, m] = monthStr.split('-').map(Number); const start = `${monthStr}-01`; const lastDay = new Date(y, m, 0).getDate(); const end = `${monthStr}-${String(lastDay).padStart(2, '0')}`; return { start, end }; } // El informe de Nómina no trae una fecha real por fila, solo un texto de // quincena tipo "1 DE JULIO - 15 DE JULIO" / "15 DE JULIO - 31 DE JULIO" — se // detecta la quincena por el día con el que arranca ese texto. function quincenaLabel(periodoQuincena) { // "1 DE JULIO - 15 DE JULIO" y "15 DE JULIO - 31 DE JULIO" comparten el // número 15 — usar el día INICIAL clasificaba ambas como Quincena 1. Lo // correcto es mirar el día FINAL del rango: si termina en ~15 es la // primera quincena, si termina más adelante (28-31) es la segunda. const nums = String(periodoQuincena || '').match(/\d{1,2}/g); if (!nums || nums.length < 2) return 'Sin quincena'; const endDay = parseInt(nums[nums.length - 1], 10); return endDay <= 16 ? 'Quincena 1' : 'Quincena 2'; } // El archivo de Compras usa "-" (y a veces "(sin presentacion)") como marcador // de "sin dato" en vez de dejar la celda vacía — se limpia a cadena vacía real. function cleanPlaceholder(v) { const s = String(v || '').trim(); if (!s || s === '-' || s.toLowerCase() === '(sin presentacion)') return ''; return s; } // Busca la fila de encabezado real de un archivo cuando no está en la fila 0 // (ej. el Excel de Ventas trae 2 filas de título antes del encabezado) — // busca la primera fila cuya primera celda coincida con `firstColLabel`. function findHeaderRow(raw, firstColLabel, maxScan = 10) { for (let i = 0; i < Math.min(maxScan, raw.length); i++) { if (String(raw[i]?.[0] || '').trim().toLowerCase() === firstColLabel) return i; } return -1; } function CierreOperativoApp() { const [activeTab, setActiveTab] = useState('dashboard'); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(null); const [loadingPeriodData, setLoadingPeriodData] = useState(false); const [userProfile, setUserProfile] = useState(() => { try { const saved = localStorage.getItem('op_profile_v1'); if (saved) return JSON.parse(saved); } catch (e) { /* ignore */ } return { name: '', role: 'Responsable de Cierre' }; }); const [showProfileModal, setShowProfileModal] = useState(false); const [showUserMenu, setShowUserMenu] = useState(false); useEffect(() => { try { localStorage.setItem('op_profile_v1', JSON.stringify(userProfile)); } catch (e) { /* ignore */ } }, [userProfile]); const [periods, setPeriods] = useState([]); const [categories, setCategories] = useState([]); const [cmCategorias, setCmCategorias] = useState([]); const [activePeriodId, setActivePeriodId] = useState(''); const [ventas, setVentas] = useState([]); const [compras, setCompras] = useState([]); const [gastos, setGastos] = useState([]); const [nomina, setNomina] = useState([]); const [historico, setHistorico] = useState([]); const [deleteConfirm, setDeleteConfirm] = useState({ isOpen: false, type: null, id: null, title: '' }); const [toast, setToast] = useState({ show: false, message: '', type: 'success' }); const triggerToast = (message, type = 'success') => { setToast({ show: true, message, type }); setTimeout(() => setToast({ show: false, message: '', type: 'success' }), 3500); }; const activePeriod = periods.find(p => p.id === activePeriodId); // ---- Carga inicial: periodos + categorías, una vez hay sesión activa ---- const loadedOnceRef = useRef(false); useEffect(() => { let cancelled = false; async function loadInitial() { try { const [pRows, catRows, cmCatRows] = await Promise.all([ window.SB.crudSelect('op_periodos', { order: { col: 'start_date', asc: true } }), window.SB.crudSelect('op_categorias_gasto', { order: { col: 'created_at', asc: true } }), window.SB.crudSelect('cm_categorias', { order: { col: 'name', asc: true } }), ]); if (cancelled) return; const mappedPeriods = pRows.map(periodFromDb); setPeriods(mappedPeriods); setCategories(catRows.map(catFromDb)); setCmCategorias(cmCatRows.map(r => ({ id: r.id, name: r.name, color: r.color }))); if (mappedPeriods.length) { const openOne = mappedPeriods.find(p => !p.isClosed); setActivePeriodId((openOne || mappedPeriods[mappedPeriods.length - 1]).id); } setLoadError(null); } catch (err) { console.error(err); if (!cancelled) setLoadError(err.message || 'Error al cargar Cierre Operativo'); } finally { if (!cancelled) setLoading(false); } } function maybeLoad(session) { if (!session || loadedOnceRef.current) return; loadedOnceRef.current = true; setLoading(true); loadInitial(); if (session.user?.email) { setUserProfile(p => (p.name ? p : { ...p, name: session.user.email.split('@')[0] })); } } window.SB.getSession().then(maybeLoad); window.SB.onAuthChange(maybeLoad); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // ---- Datos del periodo activo (ventas/compras/gastos/nómina de ESE periodo) ---- useEffect(() => { if (!activePeriodId) { setVentas([]); setCompras([]); setGastos([]); setNomina([]); return; } let cancelled = false; (async () => { setLoadingPeriodData(true); try { const [v, c, g, n] = await Promise.all([ window.SB.crudSelect('op_ventas', { eq: { period_id: activePeriodId }, order: { col: 'fecha', asc: true } }), window.SB.crudSelect('op_compras', { eq: { period_id: activePeriodId }, order: { col: 'fecha', asc: true } }), window.SB.crudSelect('op_gastos', { eq: { period_id: activePeriodId }, order: { col: 'fecha', asc: true } }), window.SB.crudSelect('op_nomina', { eq: { period_id: activePeriodId } }), ]); if (cancelled) return; setVentas(v.map(ventaFromDb)); setCompras(c.map(compraFromDb)); setGastos(g.map(gastoFromDb)); setNomina(n.map(nominaFromDb)); } catch (err) { triggerToast(err.message || 'Error al cargar los datos del periodo', 'error'); } finally { if (!cancelled) setLoadingPeriodData(false); } })(); return () => { cancelled = true; }; }, [activePeriodId]); // ---- Histórico liviano (solo period_id + valor) para la gráfica de evolución ---- useEffect(() => { if (!periods.length) { setHistorico([]); return; } let cancelled = false; (async () => { try { const [vRows, cRows, gRows, nRows] = await Promise.all([ window.SB.crudSelect('op_ventas', { select: 'period_id, valor' }), window.SB.crudSelect('op_compras', { select: 'period_id, valor' }), window.SB.crudSelect('op_gastos', { select: 'period_id, valor' }), window.SB.crudSelect('op_nomina', { select: 'period_id, total_pagado' }), ]); if (cancelled) return; const byPeriod = {}; periods.forEach(p => { byPeriod[p.id] = { periodId: p.id, month: p.month, ventas: 0, compras: 0, gastos: 0, nomina: 0 }; }); vRows.forEach(r => { if (byPeriod[r.period_id]) byPeriod[r.period_id].ventas += Number(r.valor) || 0; }); cRows.forEach(r => { if (byPeriod[r.period_id]) byPeriod[r.period_id].compras += Number(r.valor) || 0; }); gRows.forEach(r => { if (byPeriod[r.period_id]) byPeriod[r.period_id].gastos += Number(r.valor) || 0; }); nRows.forEach(r => { if (byPeriod[r.period_id]) byPeriod[r.period_id].nomina += Number(r.total_pagado) || 0; }); const arr = Object.values(byPeriod) .map(p => ({ ...p, utilidadNeta: p.ventas - p.compras - p.gastos - p.nomina })) .sort((a, b) => a.month.localeCompare(b.month)); setHistorico(arr); } catch (err) { console.error(err); } })(); return () => { cancelled = true; }; }, [periods]); const handleSaveProfile = (e) => { e.preventDefault(); setShowProfileModal(false); triggerToast('Perfil actualizado'); }; const metrics = useMemo(() => { const totalVentas = ventas.reduce((s, v) => s + (Number(v.valor) || 0), 0); const totalCompras = compras.reduce((s, c) => s + (Number(c.valor) || 0), 0); // Excluye transferencias de Corabastos a Caja Menor (no son gasto real, // ver esTransferenciaCajaMenor) de TODOS los totales/agrupaciones de // gasto — solo se conservan visibles en la tabla cruda de Gastos. const gastosReales = gastos.filter(g => !esTransferenciaCajaMenor(g)); const totalGastos = gastosReales.reduce((s, g) => s + (Number(g.valor) || 0), 0); const totalNomina = nomina.reduce((s, n) => s + (Number(n.totalPagado) || 0), 0); const utilidadBruta = totalVentas - totalCompras; const utilidadNeta = utilidadBruta - totalGastos - totalNomina; const margenNeto = totalVentas > 0 ? (utilidadNeta / totalVentas * 100) : 0; const margenBruto = totalVentas > 0 ? (utilidadBruta / totalVentas * 100) : 0; const topClientes = groupSum(ventas, v => v.cliente).slice(0, 10); const topProductosVenta = groupSum(ventas, v => v.producto).slice(0, 10); const ventasPorSucursal = groupSum(ventas, v => v.sucursal).slice(0, 10); const ventasPorFuente = groupSum(ventas, v => v.fuente).slice(0, 10); const nVentas = ventas.length; const ticketPromedioVenta = nVentas > 0 ? totalVentas / nVentas : 0; const topProveedores = groupSum(compras, c => c.proveedor).slice(0, 10); const topProductosCompra = groupSum(compras, c => c.producto).slice(0, 10); const comprasPorMetodoPago = groupSum(compras, c => c.metodoPago || 'Sin dato').slice(0, 10); const comprasPorCompradora = groupSum(compras, c => c.compradora || 'Sin dato').slice(0, 10); const nCompras = compras.length; const ticketPromedioCompra = nCompras > 0 ? totalCompras / nCompras : 0; const byCat = {}; gastosReales.forEach(g => { const cat = categories.find(c => c.id === g.categoryId); const name = cat ? cat.name : 'Sin categoría'; const color = cat ? cat.color : '#94A3B8'; byCat[name] = byCat[name] || { name, value: 0, color }; byCat[name].value += Number(g.valor) || 0; }); const gastosPorCategoria = Object.values(byCat).sort((a, b) => b.value - a.value); const ORIGEN_GASTO_LABEL = { corabastos: 'Corabastos', general: 'General', '': 'Manual / Otros' }; const byOrigen = {}; gastosReales.forEach(g => { const key = g.sourceCmId ? 'Caja Menor' : (ORIGEN_GASTO_LABEL[g.origen] || g.origen); byOrigen[key] = (byOrigen[key] || 0) + (Number(g.valor) || 0); }); const gastosPorOrigen = Object.entries(byOrigen).map(([name, value]) => ({ name, value })).sort((a, b) => b.value - a.value); const totalTransferenciasCajaMenor = gastos.filter(esTransferenciaCajaMenor).reduce((s, g) => s + (Number(g.valor) || 0), 0); return { totalVentas, totalCompras, totalGastos, totalNomina, utilidadBruta, utilidadNeta, margenNeto, margenBruto, topClientes, topProductosVenta, ventasPorSucursal, ventasPorFuente, nVentas, ticketPromedioVenta, topProveedores, topProductosCompra, comprasPorMetodoPago, comprasPorCompradora, nCompras, ticketPromedioCompra, gastosPorCategoria, gastosPorOrigen, totalTransferenciasCajaMenor, }; }, [ventas, compras, gastos, nomina, categories]); const executeDelete = async () => { const { type, id } = deleteConfirm; if (!type || !id) return; const tableByType = { venta: 'op_ventas', compra: 'op_compras', gasto: 'op_gastos', nomina: 'op_nomina', categoria: 'op_categorias_gasto', periodo: 'op_periodos' }; const setterByType = { venta: setVentas, compra: setCompras, gasto: setGastos, nomina: setNomina }; try { if (type === 'periodo') { if (periods.length <= 1) { triggerToast('No se puede eliminar el único periodo existente', 'error'); } else { await window.SB.crudDelete('op_periodos', id); setPeriods(prev => prev.filter(p => p.id !== id)); setActivePeriodId(prev => (prev === id ? (periods.find(p => p.id !== id)?.id || '') : prev)); triggerToast('Periodo eliminado', 'info'); } } else if (type === 'categoria') { await window.SB.crudDelete('op_categorias_gasto', id); setCategories(prev => prev.filter(c => c.id !== id)); triggerToast('Categoría eliminada', 'info'); } else { await window.SB.crudDelete(tableByType[type], id); setterByType[type](prev => prev.filter(x => x.id !== id)); triggerToast('Registro eliminado', 'info'); } } catch (err) { triggerToast(err.message || 'Error al eliminar', 'error'); } setDeleteConfirm({ isOpen: false, type: null, id: null, title: '' }); }; // ========================================================================== // Navegación // ========================================================================== const Navigation = () => { const navItems = [ { id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard }, { id: 'ventas', label: 'Ventas', icon: TrendingUp }, { id: 'compras', label: 'Compras', icon: ShoppingCart }, { id: 'gastos', label: 'Gastos', icon: Receipt }, { id: 'analisis', label: 'Análisis', icon: PieChartIcon }, { id: 'nomina', label: 'Nómina', icon: Users }, { id: 'reportes', label: 'Reportes', icon: FileText }, { id: 'periodos', label: 'Periodos / Cierre', icon: Settings }, ]; return ( ); }; // ========================================================================== // Dashboard // ========================================================================== const KpiCard = ({ label, value, accent, sub }) => (
{label}
{value}
{sub &&
{sub}
}
); const PieBlock = ({ data, tooltipFormatter }) => ( data.length > 0 ? (
{data.map((entry, index) => ( ))} formatCurrency(v))} />
) : ); const EvolucionLineBlock = ({ dataKey, name, color }) => ( historico.length > 0 ? (
`$${(v / 1000000).toFixed(0)}M`} tick={{ fontSize: 11, fill: '#64748B' }} /> formatCurrency(v)} />
) : ); const DashboardResumen = () => (
Utilidad Bruta
{formatCurrency(metrics.utilidadBruta)}
Ventas − Compras
Margen Bruto
= 0 ? 'text-emerald-400' : 'text-rose-400'}`}>{metrics.margenBruto.toFixed(1)}%
Utilidad Bruta / Ventas
Utilidad Neta
= 0 ? 'text-emerald-400' : 'text-rose-400'}`}>{formatCurrency(metrics.utilidadNeta)}
Utilidad Bruta − Gastos − Nómina
Margen Neto
= 0 ? 'text-emerald-400' : 'text-rose-400'}`}>{metrics.margenNeto.toFixed(1)}%
Utilidad Neta / Ventas
`$${(v / 1000000).toFixed(0)}M`} tick={{ fontSize: 11, fill: '#64748B' }} /> formatCurrency(v)} />
); const DashboardVentas = () => (
); const DashboardCompras = () => (
); const DashboardGastos = () => { const [filtroOrigenGasto, setFiltroOrigenGasto] = useState(''); const gastosFiltrados = gastos.filter(g => !esTransferenciaCajaMenor(g)).filter(g => filtroOrigenGasto ? g.origen === filtroOrigenGasto : true); const totalGastosFiltrados = gastosFiltrados.reduce((s, g) => s + (Number(g.valor) || 0), 0); const byCat = {}; gastosFiltrados.forEach(g => { const cat = categories.find(c => c.id === g.categoryId); const name = cat ? cat.name : 'Sin categoría'; const color = cat ? cat.color : '#94A3B8'; byCat[name] = byCat[name] || { name, value: 0, color }; byCat[name].value += Number(g.valor) || 0; }); const gastosPorCategoriaFiltrado = Object.values(byCat).sort((a, b) => b.value - a.value); return (
); }; const DashboardView = () => { const [dashboardTab, setDashboardTab] = useState('resumen'); const TABS = [ { id: 'resumen', label: 'Resumen General' }, { id: 'ventas', label: 'Ventas' }, { id: 'compras', label: 'Compras' }, { id: 'gastos', label: 'Gastos' }, ]; return (

Cierre Operativo

{activePeriod ? (

Periodo: {activePeriod.month} ({activePeriod.startDate} al {activePeriod.endDate})

) : (

No hay ningún periodo creado todavía — ve a Periodos / Cierre para aperturar el primero.

)}
{TABS.map(t => ( ))}
{dashboardTab === 'resumen' && } {dashboardTab === 'ventas' && } {dashboardTab === 'compras' && } {dashboardTab === 'gastos' && }
); }; const ChartCard = ({ title, children }) => (

{title}

{children}
); const EmptyChart = () => (
Sin datos en este periodo
); // Los nombres de cliente/proveedor/producto suelen ser largos y, sin // truncar, el eje Y los partía en 3-4 líneas y "aplastaba" la gráfica // entera — se recorta el tick visualmente pero el tooltip sigue // mostrando el nombre completo. const truncateLabel = (s, n = 26) => (String(s || '').length > n ? `${String(s).slice(0, n - 1)}…` : (s || '')); // Recharts recorta el texto del tick por su cuenta (con su propio "…") en // cuanto no le alcanza el ancho, sin importar el tickFormatter — por eso // los nombres se veían cortadísimos aunque truncateLabel permitiera hasta // 30 caracteres. Un tick "custom" (un propio) evita ese recorte // automático y muestra exactamente lo que truncateLabel decide. const YAxisNameTick = ({ x, y, payload }) => ( {truncateLabel(payload.value)} ); const BarBlock = ({ data, color }) => ( data.length > 0 ? (
`$${(v / 1000).toFixed(0)}k`} tick={{ fontSize: 10, fill: '#64748B' }} /> } interval={0} /> formatCurrency(v)} />
) : ); // Con importaciones masivas, las tablas de Ventas/Compras/Gastos pueden // tener miles de filas — renderizarlas todas de una vez en el DOM hacía la // navegación muy pesada (scroll, búsqueda). Se pagina en el cliente // (mostrar de a TABLE_PAGE_SIZE) reutilizando este control en las 3 vistas. const TABLE_PAGE_SIZE = 50; const Pager = ({ page, setPage, total }) => { const totalPages = Math.max(1, Math.ceil(total / TABLE_PAGE_SIZE)); if (total <= TABLE_PAGE_SIZE) return null; const from = total === 0 ? 0 : page * TABLE_PAGE_SIZE + 1; const to = Math.min(total, (page + 1) * TABLE_PAGE_SIZE); return (
Mostrando {from}–{to} de {total}
{page + 1} / {totalPages}
); }; // Lista los lotes de importación (agrupados por periodo) de un origen dado // ('informe_ventas' / 'informe_compras' / 'informe_nomina') y deja borrar // el lote completo de un periodo con un clic — para deshacer una carga // incompleta o equivocada antes de volver a importar el archivo correcto. const ImportBatchManager = ({ rows, origen, table, label, onDeleted }) => { const [confirmBatch, setConfirmBatch] = useState(null); const [deleting, setDeleting] = useState(false); const batches = {}; rows.filter(r => r.origen === origen).forEach(r => { const pid = r.periodId; if (!batches[pid]) { const p = periods.find(x => x.id === pid); batches[pid] = { periodId: pid, month: p?.month || 'Periodo eliminado', count: 0, valor: 0 }; } batches[pid].count += 1; batches[pid].valor += Number(r.valor) || 0; }); const list = Object.values(batches).sort((a, b) => b.month.localeCompare(a.month)); if (!list.length) return null; const handleDelete = async () => { if (!confirmBatch) return; setDeleting(true); try { await window.SB.crudDeleteWhere(table, { origen, period_id: confirmBatch.periodId }); onDeleted(confirmBatch.periodId); triggerToast(`${confirmBatch.count} fila(s) eliminada(s) del periodo ${confirmBatch.month}`); setConfirmBatch(null); } catch (err) { triggerToast(err.message || 'Error al eliminar la importación', 'error'); } finally { setDeleting(false); } }; return (

Importaciones de "{label}" por periodo

Si subiste un archivo incompleto o equivocado, elimina aquí el lote completo de ese periodo antes de volver a importar.

{list.map(b => (
{b.month} · {b.count} fila(s) · {formatCurrency(b.valor)}
))}
{confirmBatch && (

Eliminar importación

Vas a eliminar {confirmBatch.count} fila(s) de "{label}" del periodo {confirmBatch.month} ({formatCurrency(confirmBatch.valor)}). Esta acción no se puede deshacer.

)}
); }; // Detecta filas "huérfanas" del periodo activo sin fecha — la firma típica // de haber usado el botón "Importar Excel" (plantilla simple) con el // archivo del informe completo (10 columnas), que lee columnas equivocadas // y deja fecha vacía en casi todas las filas. Permite limpiarlas de un clic. const OrphanRowsWarning = ({ rows, table, label, onDeleted }) => { const [showConfirm, setShowConfirm] = useState(false); const [deleting, setDeleting] = useState(false); const huerfanas = rows.filter(r => !r.fecha); if (!huerfanas.length) return null; const handleDelete = async () => { setDeleting(true); try { await window.SB.crudDeleteWhereNull(table, { period_id: activePeriodId }, 'fecha'); onDeleted(); triggerToast(`${huerfanas.length} fila(s) sin fecha eliminada(s)`); setShowConfirm(false); } catch (err) { triggerToast(err.message || 'Error al eliminar', 'error'); } finally { setDeleting(false); } }; return (

{huerfanas.length} fila(s) sin fecha detectadas en "{label}" — probablemente una importación con el botón equivocado.

Se pueden eliminar de una vez antes de volver a importar el archivo correcto.

{showConfirm && (

Eliminar filas sin fecha

Vas a eliminar {huerfanas.length} fila(s) sin fecha de "{label}" en el periodo activo. Esta acción no se puede deshacer.

)}
); }; // ========================================================================== // Análisis — filtros específicos por cliente/sucursal (Ventas) o proveedor // (Compras), con totales que se recalculan según el filtro elegido. Vive // aparte del Dashboard porque este último muestra rankings fijos de todo // el periodo, mientras que aquí el usuario elige un corte específico. // ========================================================================== // Combo de texto libre + lista desplegable sobre una lista plana de strings // (cliente/sucursal/proveedor) — se puede escribir para buscar o elegir de // la lista, igual que el ProviderCombobox de Caja Menor pero sin objetos. const SearchableSelect = ({ value, onChange, options, placeholder }) => { const [query, setQuery] = useState(value || ''); const [open, setOpen] = useState(false); const boxRef = useRef(null); useEffect(() => { setQuery(value || ''); }, [value]); useEffect(() => { function onDocClick(e) { if (boxRef.current && !boxRef.current.contains(e.target)) setOpen(false); } document.addEventListener('mousedown', onDocClick); return () => document.removeEventListener('mousedown', onDocClick); }, []); const filtered = options.filter(o => o.toLowerCase().includes(query.toLowerCase())); return (
{ setOpen(true); e.target.select(); }} onChange={(e) => { setQuery(e.target.value); setOpen(true); if (value) onChange(''); }} /> {open && (
{ e.preventDefault(); onChange(''); setQuery(''); setOpen(false); }}>{placeholder}
{filtered.map(o => (
{ e.preventDefault(); onChange(o); setQuery(o); setOpen(false); }}>{o}
))} {filtered.length === 0 &&
Sin coincidencias
}
)}
); }; const VENTAS_DETALLE_COLS = [ { key: 'fecha', label: 'Fecha', align: 'left' }, { key: 'cliente', label: 'Cliente', align: 'left' }, { key: 'sucursal', label: 'Sucursal', align: 'left' }, { key: 'producto', label: 'Producto', align: 'left' }, { key: 'cantidad', label: 'Cantidad', align: 'right' }, { key: 'precioUnitario', label: 'Precio Unit.', align: 'right', money: true }, { key: 'valor', label: 'Valor', align: 'right', money: true, bold: true }, { key: 'fuente', label: 'Fuente', align: 'left' }, ]; const POR_PRODUCTO_COLS = [ { key: 'producto', label: 'Producto', align: 'left' }, { key: 'cantidad', label: 'Cantidad Total', align: 'right' }, { key: 'precioPromedio', label: 'Precio Promedio', align: 'right', money: true }, { key: 'valor', label: 'Valor Total', align: 'right', money: true, bold: true }, { key: 'lineas', label: 'Nº Líneas', align: 'right' }, ]; const COMPRAS_DETALLE_COLS = [ { key: 'fecha', label: 'Fecha', align: 'left' }, { key: 'proveedor', label: 'Proveedor', align: 'left' }, { key: 'producto', label: 'Producto', align: 'left' }, { key: 'presentacion', label: 'Presentación', align: 'left' }, { key: 'cantidad', label: 'Cantidad', align: 'right' }, { key: 'precioUnitario', label: 'Precio Unit.', align: 'right', money: true }, { key: 'valor', label: 'Valor', align: 'right', money: true, bold: true }, { key: 'metodoPago', label: 'Método Pago', align: 'left' }, { key: 'compradora', label: 'Compradora', align: 'left' }, ]; const DetalleVistaSelect = ({ value, onChange, options }) => ( ); const POR_CLIENTE_COLS = [ { key: 'cliente', label: 'Cliente', align: 'left' }, { key: 'cantidad', label: 'Cantidad Total', align: 'right' }, { key: 'precioPromedio', label: 'Precio Promedio', align: 'right', money: true }, { key: 'valor', label: 'Valor Total', align: 'right', money: true, bold: true }, { key: 'lineas', label: 'Nº Líneas', align: 'right' }, ]; const POR_PROVEEDOR_COLS = [ { key: 'proveedor', label: 'Proveedor', align: 'left' }, { key: 'cantidad', label: 'Cantidad Total', align: 'right' }, { key: 'precioPromedio', label: 'Precio Promedio', align: 'right', money: true }, { key: 'valor', label: 'Valor Total', align: 'right', money: true, bold: true }, { key: 'lineas', label: 'Nº Líneas', align: 'right' }, ]; const AnalisisVentas = () => { const [filtroCliente, setFiltroCliente] = useState(''); const [filtroSucursal, setFiltroSucursal] = useState(''); const [vistaDetalle, setVistaDetalle] = useState('desglosado'); // Cascada en ambos sentidos: elegir un cliente limita las sucursales a // las que ESE cliente realmente tiene, y viceversa — antes se mostraban // las sucursales de TODOS los clientes mezcladas. const clientes = [...new Set(ventas.filter(v => filtroSucursal ? v.sucursal === filtroSucursal : true).map(v => v.cliente).filter(Boolean))].sort(); const sucursales = [...new Set(ventas.filter(v => filtroCliente ? v.cliente === filtroCliente : true).map(v => v.sucursal).filter(Boolean))].sort(); useEffect(() => { if (filtroSucursal && !sucursales.includes(filtroSucursal)) setFiltroSucursal(''); }, [filtroCliente]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (filtroCliente && !clientes.includes(filtroCliente)) setFiltroCliente(''); }, [filtroSucursal]); // eslint-disable-line react-hooks/exhaustive-deps // Las notas de crédito no siempre traen la sucursal de la factura que // están reversando (suelen llegar con sucursal "N/A") — si se filtran // igual que una venta normal, se "pierden" al elegir una sucursal // puntual y el total sale inflado (sin descontar la nota de crédito). // Por eso, al filtrar por sucursal, las notas de crédito del cliente // elegido siempre se incluyen para que el total quede neto real. const filtradas = ventas.filter(v => { if (filtroCliente && v.cliente !== filtroCliente) return false; if (filtroSucursal) { const esNotaCredito = /nota\s*cr[ée]dito/i.test(v.fuente || ''); if (!esNotaCredito && v.sucursal !== filtroSucursal) return false; } return true; }); const totalValor = filtradas.reduce((s, v) => s + (Number(v.valor) || 0), 0); const totalKilos = filtradas.reduce((s, v) => s + (Number(v.cantidad) || 0), 0); const nReferencias = new Set(filtradas.map(v => v.producto).filter(Boolean)).size; const porCliente = groupSum(filtradas, v => v.cliente).slice(0, 10); const porSucursal = groupSum(filtradas, v => v.sucursal).slice(0, 10); const porProducto = groupSum(filtradas, v => v.producto).slice(0, 10); return (
{(filtroCliente || filtroSucursal) && ( )}
v.cliente, 'cliente') : filtradas} columns={vistaDetalle === 'porProducto' ? POR_PRODUCTO_COLS : vistaDetalle === 'porCliente' ? POR_CLIENTE_COLS : VENTAS_DETALLE_COLS} defaultSortKey="valor" headerRight={} />
); }; const AnalisisCompras = () => { const [filtroProveedor, setFiltroProveedor] = useState(''); const [vistaDetalle, setVistaDetalle] = useState('desglosado'); const proveedores = useMemo(() => [...new Set(compras.map(c => c.proveedor).filter(Boolean))].sort(), [compras]); const filtradas = compras.filter(c => filtroProveedor ? c.proveedor === filtroProveedor : true); const totalValor = filtradas.reduce((s, c) => s + (Number(c.valor) || 0), 0); const totalKilos = filtradas.reduce((s, c) => s + (Number(c.cantidad) || 0), 0); const nReferencias = new Set(filtradas.map(c => c.producto).filter(Boolean)).size; const totalCredito = filtradas.filter(c => normalizeCatName(c.metodoPago) === 'credito').reduce((s, c) => s + (Number(c.valor) || 0), 0); const totalEfectivo = filtradas.filter(c => normalizeCatName(c.metodoPago) === 'efectivo').reduce((s, c) => s + (Number(c.valor) || 0), 0); const porProveedor = groupSum(filtradas, c => c.proveedor).slice(0, 10); const porProducto = groupSum(filtradas, c => c.producto).slice(0, 10); const porMetodo = groupSum(filtradas, c => c.metodoPago || 'Sin dato').slice(0, 10); return (
{filtroProveedor && ( )}
c.proveedor, 'proveedor') : filtradas} columns={vistaDetalle === 'porProducto' ? POR_PRODUCTO_COLS : vistaDetalle === 'porProveedor' ? POR_PROVEEDOR_COLS : COMPRAS_DETALLE_COLS} defaultSortKey="valor" headerRight={} />
); }; const ORIGEN_GASTO_LABEL2 = { general: 'General', corabastos: 'Corabastos', cm: 'Caja Menor', manual: 'Manual / Otros' }; const origenDeGasto = (g) => (g.sourceCmId ? 'cm' : (g.origen || 'manual')); const GASTOS_DETALLE_COLS = [ { key: 'fecha', label: 'Fecha', align: 'left' }, { key: 'categoriaNombre', label: 'Categoría', align: 'left' }, { key: 'descripcion', label: 'Descripción', align: 'left' }, { key: 'proveedor', label: 'Proveedor', align: 'left' }, { key: 'origenLabel', label: 'Origen', align: 'left' }, { key: 'valor', label: 'Valor', align: 'right', money: true, bold: true }, ]; const GASTOS_POR_CATEGORIA_COLS = [ { key: 'categoria', label: 'Categoría', align: 'left' }, { key: 'valor', label: 'Valor Total', align: 'right', money: true, bold: true }, { key: 'lineas', label: 'Nº Líneas', align: 'right' }, ]; const GASTOS_POR_PROVEEDOR_COLS = [ { key: 'proveedor', label: 'Proveedor', align: 'left' }, { key: 'valor', label: 'Valor Total', align: 'right', money: true, bold: true }, { key: 'lineas', label: 'Nº Líneas', align: 'right' }, ]; const AnalisisGastos = () => { const [filtroOrigen, setFiltroOrigen] = useState(''); const [filtroCategoriaId, setFiltroCategoriaId] = useState(''); const [vistaDetalle, setVistaDetalle] = useState('desglosado'); const catNombre = (g) => categories.find(c => c.id === g.categoryId)?.name || 'Sin categoría'; // Cascada igual que en Gastos: la categoría se limita a las que existen // dentro del origen elegido. Las transferencias Corabastos→Caja Menor se // excluyen desde acá: no son gasto real (ver esTransferenciaCajaMenor), // solo se conservan visibles/con badge en la tabla cruda de Gastos. const gastosSinTransferencias = gastos.filter(g => !esTransferenciaCajaMenor(g)); const totalTransferenciasCM = gastos.filter(esTransferenciaCajaMenor).reduce((s, g) => s + (Number(g.valor) || 0), 0); const gastosDelOrigen = gastosSinTransferencias.filter(g => filtroOrigen ? origenDeGasto(g) === filtroOrigen : true); const categoriasDisponibles = categories.filter(c => new Set(gastosDelOrigen.map(g => g.categoryId).filter(Boolean)).has(c.id)); useEffect(() => { if (filtroCategoriaId && !categoriasDisponibles.some(c => c.id === filtroCategoriaId)) setFiltroCategoriaId(''); // eslint-disable-next-line react-hooks/exhaustive-deps }, [filtroOrigen]); const filtradas = gastosDelOrigen.filter(g => filtroCategoriaId ? g.categoryId === filtroCategoriaId : true); const totalValor = filtradas.reduce((s, g) => s + (Number(g.valor) || 0), 0); const nCategorias = new Set(filtradas.map(g => g.categoryId).filter(Boolean)).size; const promedioLinea = filtradas.length ? totalValor / filtradas.length : 0; const porCategoria = groupSum(filtradas, catNombre).slice(0, 10); const porOrigen = groupSum(filtradas, g => ORIGEN_GASTO_LABEL2[origenDeGasto(g)]).slice(0, 10); const filtradasDecoradas = filtradas.map(g => ({ ...g, categoriaNombre: catNombre(g), origenLabel: ORIGEN_GASTO_LABEL2[origenDeGasto(g)] })); const porDiaMap = {}; filtradas.forEach(g => { const d = g.fecha || 'Sin fecha'; porDiaMap[d] = (porDiaMap[d] || 0) + (Number(g.valor) || 0); }); const porDia = Object.entries(porDiaMap).map(([name, value]) => ({ name, value })).sort((a, b) => a.name.localeCompare(b.name)); return (
{(filtroOrigen || filtroCategoriaId) && ( )}
{totalTransferenciasCM > 0 && (

{formatCurrency(totalTransferenciasCM)} transferidos de Corabastos a Caja Menor este periodo — no se cuentan aquí como gasto (el gasto real ya queda reflejado del lado de Caja Menor). Se pueden ver en la tabla de Gastos con el badge "🔁 Transferencia".

)}
{porDia.length > 0 ? (
`$${(v / 1000).toFixed(0)}k`} tick={{ fontSize: 10, fill: '#64748B' }} /> formatCurrency(v)} />
) : }
g.proveedor, 'proveedor') : filtradasDecoradas} columns={vistaDetalle === 'porCategoria' ? GASTOS_POR_CATEGORIA_COLS : vistaDetalle === 'porProveedor' ? GASTOS_POR_PROVEEDOR_COLS : GASTOS_DETALLE_COLS} defaultSortKey="valor" headerRight={} />
); }; // Tabla de detalle de Nómina — permite ver y ORDENAR (clic en cualquier // encabezado) todos los empleados a la vez, en vez de solo el top 10 del // gráfico de barras. Pagina de a TABLE_PAGE_SIZE para no volver pesada la // navegación si hay muchas quincenas/periodos acumulados. const NOMINA_COLS = [ { key: 'empleado', label: 'Empleado', align: 'left' }, { key: 'periodoQuincena', label: 'Quincena', align: 'left' }, { key: 'salarioBase', label: 'Salario Base', align: 'right', money: true }, { key: 'horasExtras', label: 'H. Extras', align: 'right' }, { key: 'valorHorasExtras', label: 'Valor H. Extras', align: 'right', money: true }, { key: 'recargoNocturnoHoras', label: 'Rec. Nocturno (h)', align: 'right' }, { key: 'valorRecargoNocturno', label: 'Valor Rec. Nocturno', align: 'right', money: true }, { key: 'recargoDominicalHoras', label: 'Rec. Dominical (h)', align: 'right' }, { key: 'valorRecargoDominical', label: 'Valor Rec. Dominical', align: 'right', money: true }, { key: 'deducciones', label: 'Deducciones', align: 'right', money: true }, { key: 'parafiscales', label: 'Parafiscales', align: 'right', money: true }, { key: 'adicionalesComisiones', label: 'Adicionales', align: 'right', money: true }, { key: 'totalPagado', label: 'Total Pagado', align: 'right', money: true, bold: true }, ]; // Tabla ordenable + paginada genérica, reutilizada por los detalles de // Nómina/Ventas/Compras/Productos — soporta una celda "render" propia (ej. // el semáforo de utilidad) además de las columnas de texto/moneda simples. const SortableTable = ({ title, subtitle, rows, columns, defaultSortKey, defaultSortDir, headerRight }) => { const [sortCol, setSortCol] = useState(defaultSortKey || columns[0]?.key); const [sortDir, setSortDir] = useState(defaultSortDir || 'desc'); const [page, setPage] = useState(0); useEffect(() => { setPage(0); }, [rows.length]); const toggleSort = (key) => { if (sortCol === key) { setSortDir(d => (d === 'asc' ? 'desc' : 'asc')); } else { setSortCol(key); setSortDir('desc'); } }; const sorted = [...rows].sort((a, b) => { const va = a[sortCol]; const vb = b[sortCol]; if (typeof va === 'string' || typeof vb === 'string') { const cmp = String(va || '').localeCompare(String(vb || '')); return sortDir === 'asc' ? cmp : -cmp; } const cmp = (Number(va) || 0) - (Number(vb) || 0); return sortDir === 'asc' ? cmp : -cmp; }); return (

{title}

{subtitle || 'Clic en un encabezado para ordenar por esa columna'}

{headerRight}
{columns.map(col => ( ))} {sorted.slice(page * TABLE_PAGE_SIZE, (page + 1) * TABLE_PAGE_SIZE).map((r, i) => ( {columns.map(col => ( ))} ))} {sorted.length === 0 && }
toggleSort(col.key)}> {col.label}{sortCol === col.key ? (sortDir === 'asc' ? ' ▲' : ' ▼') : ''}
{col.render ? col.render(r[col.key], r) : (col.money ? formatCurrency(r[col.key]) : (r[col.key] != null ? r[col.key] : '—'))}
Sin datos en este filtro.
); }; const NominaDetailTable = ({ rows }) => { // 3 vistas: Quincena 1 sola, Quincena 2 sola, o "todo unificado" — un solo // registro totalizado por empleado sumando ambas quincenas del mes. const [vista, setVista] = useState('todo'); // El salario base (SUELDO) es una referencia MENSUAL que el informe // repite igual en las dos filas de quincena de cada empleado — no es un // valor que se "recibe" dos veces. Sumarlo como el resto de columnas // duplicaba el salario mensual (ej. $6M -> $12M) en la vista unificada; // se excluye de la suma y se toma tal cual (ya es el mensual completo). const CONSTANT_KEYS = ['salarioBase']; const SUM_KEYS = NOMINA_COLS.filter(c => !['empleado', 'periodoQuincena', ...CONSTANT_KEYS].includes(c.key)).map(c => c.key); let displayRows; if (vista === 'q1' || vista === 'q2') { const label = vista === 'q1' ? 'Quincena 1' : 'Quincena 2'; displayRows = rows.filter(n => quincenaLabel(n.periodoQuincena) === label); } else { const byEmp = {}; rows.forEach(n => { const key = n.documento || n.empleado; if (!byEmp[key]) { byEmp[key] = { id: key, empleado: n.empleado, documento: n.documento, periodoQuincena: 'Todo el mes' }; SUM_KEYS.forEach(k => { byEmp[key][k] = 0; }); CONSTANT_KEYS.forEach(k => { byEmp[key][k] = n[k]; }); } SUM_KEYS.forEach(k => { byEmp[key][k] += Number(n[k]) || 0; }); }); displayRows = Object.values(byEmp); } return ( setVista(e.target.value)}> } /> ); }; const AnalisisNomina = () => { const [filtroEmpleado, setFiltroEmpleado] = useState(''); const [novedadExpandida, setNovedadExpandida] = useState(null); const empleados = [...new Set(nomina.map(n => n.empleado).filter(Boolean))].sort(); const filtradas = nomina.filter(n => filtroEmpleado ? n.empleado === filtroEmpleado : true); const totalNomina = filtradas.reduce((s, n) => s + (Number(n.totalPagado) || 0), 0); const totalHorasExtras = filtradas.reduce((s, n) => s + (Number(n.horasExtras) || 0) + (Number(n.horaExtraDominicalHoras) || 0), 0); const totalValorHorasExtras = filtradas.reduce((s, n) => s + (Number(n.valorHorasExtras) || 0) + (Number(n.valorHoraExtraDominical) || 0), 0); const totalRecargoNocturnoHoras = filtradas.reduce((s, n) => s + (Number(n.recargoNocturnoHoras) || 0), 0); const totalValorRecargoNocturno = filtradas.reduce((s, n) => s + (Number(n.valorRecargoNocturno) || 0), 0); const totalRecargoDominicalHoras = filtradas.reduce((s, n) => s + (Number(n.recargoDominicalHoras) || 0), 0); const totalValorRecargoDominical = filtradas.reduce((s, n) => s + (Number(n.valorRecargoDominical) || 0), 0); const totalDeducciones = filtradas.reduce((s, n) => s + (Number(n.deducciones) || 0), 0); const totalAdicionales = filtradas.reduce((s, n) => s + (Number(n.adicionalesComisiones) || 0), 0); const nEmpleados = new Set(filtradas.map(n => n.documento || n.empleado).filter(Boolean)).size; const q1Rows = filtradas.filter(n => quincenaLabel(n.periodoQuincena) === 'Quincena 1'); const q2Rows = filtradas.filter(n => quincenaLabel(n.periodoQuincena) === 'Quincena 2'); const sumField = (rows, key) => rows.reduce((s, n) => s + (Number(n[key]) || 0), 0); const totalQuincena1 = sumField(q1Rows, 'totalPagado'); const totalQuincena2 = sumField(q2Rows, 'totalPagado'); // Alertas/novedades: qué explica la variación de Quincena 1 a Quincena 2 // (para que el usuario entienda el "por qué", no solo el número final). // Cada novedad trae además su propio "detalle" — los empleados que más // aportaron a ESE cambio puntual, para saber exactamente dónde mirar. const deltaTotal = totalQuincena2 - totalQuincena1; const pctTotal = totalQuincena1 > 0 ? (deltaTotal / totalQuincena1) * 100 : 0; const CAMPOS_NOVEDAD = [ { label: 'Horas Extras', field: 'valorHorasExtras' }, { label: 'Recargo Nocturno', field: 'valorRecargoNocturno' }, { label: 'Recargo Dominical/Festivo', field: 'valorRecargoDominical' }, { label: 'Hora Extra Dominical', field: 'valorHoraExtraDominical' }, { label: 'Deducciones', field: 'deducciones' }, { label: 'Adicionales/Comisiones', field: 'adicionalesComisiones' }, ]; const impactos = CAMPOS_NOVEDAD.map(({ label, field }) => { const nombres = {}; const byEmpQ1 = {}; q1Rows.forEach(n => { const k = n.documento || n.empleado; nombres[k] = n.empleado; byEmpQ1[k] = (byEmpQ1[k] || 0) + (Number(n[field]) || 0); }); const byEmpQ2 = {}; q2Rows.forEach(n => { const k = n.documento || n.empleado; nombres[k] = n.empleado; byEmpQ2[k] = (byEmpQ2[k] || 0) + (Number(n[field]) || 0); }); const keys = new Set([...Object.keys(byEmpQ1), ...Object.keys(byEmpQ2)]); const detalle = [...keys] .map(k => ({ empleado: nombres[k] || k, q1: byEmpQ1[k] || 0, q2: byEmpQ2[k] || 0, delta: (byEmpQ2[k] || 0) - (byEmpQ1[k] || 0) })) .filter(d => Math.abs(d.delta) > 0) .sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta)); const q1 = sumField(q1Rows, field); const q2 = sumField(q2Rows, field); return { label, q1, q2, delta: q2 - q1, detalle }; }).filter(c => Math.abs(c.delta) > 0).sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta)); const porEmpleado = groupSum(filtradas.map(n => ({ ...n, valor: n.totalPagado })), n => n.empleado).slice(0, 10); const porQuincena = groupSum(filtradas.map(n => ({ ...n, valor: n.totalPagado })), n => n.periodoQuincena || 'Sin quincena'); const desgloseExtras = [ { name: 'Horas Extras Diurnas', value: filtradas.reduce((s, n) => s + (Number(n.valorHorasExtras) || 0), 0) }, { name: 'Recargo Nocturno', value: totalValorRecargoNocturno }, { name: 'Recargo Dominical/Festivo', value: totalValorRecargoDominical }, { name: 'Hora Extra Dominical', value: filtradas.reduce((s, n) => s + (Number(n.valorHoraExtraDominical) || 0), 0) }, ].filter(d => d.value > 0); return (
{filtroEmpleado && ( )}

Alertas y novedades: Quincena 1 vs Quincena 2

{(!totalQuincena1 && !totalQuincena2) ? (

No hay datos suficientes de ambas quincenas para comparar todavía.

) : (

= 0 ? 'text-rose-600' : 'text-emerald-600'}`}> La nómina de la Quincena 2 {deltaTotal >= 0 ? 'subió' : 'bajó'} {formatCurrency(Math.abs(deltaTotal))} ({Math.abs(pctTotal).toFixed(1)}%) respecto a la Quincena 1.

{impactos.length > 0 ? (
    {impactos.slice(0, 4).map(i => (
  • {novedadExpandida === i.label && (

    Empleados que más aportaron a este cambio — revisa estos primero:

      {i.detalle.slice(0, 5).map(d => (
    • {d.empleado} = 0 ? 'text-rose-600' : 'text-emerald-600'}`}>{d.delta >= 0 ? '+' : ''}{formatCurrency(d.delta)} ({formatCurrency(d.q1)} → {formatCurrency(d.q2)})
    • ))}
    )}
  • ))}
) : (

No se detectaron novedades relevantes en horas extras, recargos, deducciones o adicionales entre las dos quincenas.

)}
)}
); }; const AnalisisView = () => { const [analisisTab, setAnalisisTab] = useState('ventas'); const TABS = [{ id: 'ventas', label: 'Ventas' }, { id: 'compras', label: 'Compras' }, { id: 'gastos', label: 'Gastos' }, { id: 'nomina', label: 'Nómina' }]; return (

Análisis

Filtra por cliente, sucursal, proveedor o empleado para entender a fondo tu operación

{TABS.map(t => ( ))}
{analisisTab === 'ventas' && } {analisisTab === 'compras' && } {analisisTab === 'gastos' && } {analisisTab === 'nomina' && }
); }; // ========================================================================== // Ventas // ========================================================================== const VentasView = () => { const [showForm, setShowForm] = useState(false); const [editingId, setEditingId] = useState(null); const [search, setSearch] = useState(''); const [page, setPage] = useState(0); const [isImporting, setIsImporting] = useState(false); const fileInputRef = useRef(null); const blankForm = { fecha: new Date().toISOString().slice(0, 10), producto: '', cliente: '', sucursal: '', cantidad: '', valor: '' }; const [formData, setFormData] = useState(blankForm); useEffect(() => { setPage(0); }, [search]); // ---- Importador dedicado del "Informe de Ventas" (formato fijo, sin plantilla) ---- const fileInputInformeRef = useRef(null); const [isImportingInforme, setIsImportingInforme] = useState(false); const importInformeVentas = async (file) => { setIsImportingInforme(true); try { const XLSX = await ensureXLSX(); const buf = await file.arrayBuffer(); const wb = XLSX.read(buf, { type: 'array' }); const raw = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]], { header: 1, raw: true }); const headerIdx = findHeaderRow(raw, 'fuente'); if (headerIdx === -1) { triggerToast('No se reconoció el formato del archivo (falta encabezado "Fuente")', 'error'); return; } const rows = []; const periodCache = {}; periods.forEach(p => { periodCache[p.month] = p.id; }); let sinFecha = 0, descartadas = 0; const seqCounter = {}; for (let i = headerIdx + 1; i < raw.length; i++) { const r = raw[i]; if (!r || !r.length) continue; const fuente = String(r[0] || '').trim(); const producto = String(r[5] || '').trim(); if (!fuente || fuente.toLowerCase() === 'total general') { descartadas++; continue; } const fecha = parseFechaFlexible(r[2]); if (!fecha) { sinFecha++; continue; } const documento = String(r[1] || '').trim(); const cliente = String(r[3] || '').trim(); const sucursal = String(r[4] || '').trim(); const cantidad = parseNum(r[6]); const unidad = String(r[7] || '').trim(); const precioUnitario = parseNum(r[8]); const valor = parseNum(r[9]); const month = fecha.slice(0, 7); let periodId = periodCache[month]; if (!periodId) { const { start, end } = monthBounds(month); const [row] = await window.SB.crudInsert('op_periodos', [{ month, start_date: start, end_date: end, is_closed: false }]); periodId = row.id; periodCache[month] = periodId; setPeriods(prev => [...prev, periodFromDb(row)].sort((a, b) => a.month.localeCompare(b.month))); } const fpKey = `${fecha}|${valor}|${normalizeCatName(`${documento}|${producto}`)}`; seqCounter[fpKey] = (seqCounter[fpKey] || 0) + 1; rows.push({ ...ventaToDb({ periodId, fecha, producto, cliente, sucursal, cantidad, valor, fuente, documento, unidad, precioUnitario }), origen: 'informe_ventas', external_ref: rowFingerprint(fecha, valor, `${documento}|${producto}`, seqCounter[fpKey]), }); } if (!rows.length) { triggerToast('No se encontraron filas válidas para importar', 'error'); return; } const result = await bulkUpsert('op_ventas', rows, 'origen,external_ref'); if (rows.some(row => row.period_id === activePeriodId)) { const v = await window.SB.crudSelect('op_ventas', { eq: { period_id: activePeriodId }, order: { col: 'fecha', asc: true } }); setVentas(v.map(ventaFromDb)); } let msg = `${result.length} venta(s) importada(s)/actualizada(s)`; if (sinFecha) msg += ` · ${sinFecha} sin fecha omitida(s)`; if (descartadas) msg += ` · ${descartadas} fila(s) de total descartada(s)`; triggerToast(msg); } catch (err) { triggerToast(err.message || 'Error al importar el Informe de Ventas', 'error'); } finally { setIsImportingInforme(false); } }; const openNew = () => { if (!activePeriodId) { triggerToast('Primero crea un periodo en Periodos / Cierre', 'error'); return; } setEditingId(null); setFormData(blankForm); setShowForm(true); }; const editRow = (v) => { setFormData({ fecha: v.fecha || '', producto: v.producto, cliente: v.cliente, sucursal: v.sucursal, cantidad: v.cantidad, valor: v.valor }); setEditingId(v.id); setShowForm(true); }; const handleSave = async (e) => { e.preventDefault(); const payload = { ...formData, periodId: activePeriodId }; try { if (editingId) { const [row] = await window.SB.crudUpdate('op_ventas', editingId, ventaToDb(payload)); setVentas(prev => prev.map(v => v.id === editingId ? ventaFromDb(row) : v)); triggerToast('Venta actualizada'); } else { const [row] = await window.SB.crudInsert('op_ventas', [ventaToDb(payload)]); setVentas(prev => [...prev, ventaFromDb(row)]); triggerToast('Venta registrada'); } setShowForm(false); setEditingId(null); } catch (err) { triggerToast(err.message || 'Error al guardar la venta', 'error'); } }; const downloadTemplate = async () => { try { const XLSX = await ensureXLSX(); const ws = XLSX.utils.aoa_to_sheet([ ['Fecha (AAAA-MM-DD)', 'Producto', 'Cliente', 'Sucursal Cliente', 'Cantidad', 'Valor'], ['2026-07-15', 'Lechuga Crespa x Kg', 'Hoteles Sibelius SAS', 'Sede Norte', 120, 850000], ]); ws['!cols'] = [{ wch: 16 }, { wch: 26 }, { wch: 26 }, { wch: 20 }, { wch: 12 }, { wch: 14 }]; const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, 'Ventas'); XLSX.writeFile(wb, 'Plantilla_Ventas_CierreOperativo.xlsx'); } catch (err) { triggerToast('Error al generar la plantilla', 'error'); } }; const handleImportFile = async (e) => { const file = e.target.files?.[0]; if (!file) return; if (!activePeriodId) { triggerToast('Primero crea/selecciona un periodo', 'error'); e.target.value = ''; return; } setIsImporting(true); try { const XLSX = await ensureXLSX(); const buf = await file.arrayBuffer(); const wb = XLSX.read(buf, { type: 'array' }); const rows = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]], { header: 1, raw: true }); // Si el archivo trae la columna "Fuente" en alguna de las primeras // filas, es el Informe de Ventas completo (10 columnas), no la // plantilla simple — usar este importador leería las columnas // equivocadas (como ya pasó) y dejaría cientos de filas basura. if (rows.slice(0, 6).some(r => String(r?.[0] || '').trim().toLowerCase() === 'fuente')) { triggerToast('Este archivo es el Informe de Ventas completo (trae la columna "Fuente") — usa el botón "Importar Informe de Ventas", no "Importar Excel".', 'error'); return; } const startIdx = rows.length && String(rows[0]?.[0] || '').trim().toLowerCase().startsWith('fecha') ? 1 : 0; const toInsert = []; let skipped = 0; for (let i = startIdx; i < rows.length; i++) { const r = rows[i]; if (!r || !r.length) continue; const producto = String(r[1] || '').trim(); const cliente = String(r[2] || '').trim(); if (!producto && !cliente) { skipped++; continue; } toInsert.push(ventaToDb({ periodId: activePeriodId, fecha: parseFecha(r[0]), producto, cliente, sucursal: String(r[3] || '').trim(), cantidad: parseNum(r[4]), valor: parseNum(r[5]), })); } if (!toInsert.length) { triggerToast('No se encontraron filas válidas para importar', 'error'); return; } const rowsInserted = await bulkInsert('op_ventas', toInsert); setVentas(prev => [...prev, ...rowsInserted.map(ventaFromDb)]); triggerToast(`${rowsInserted.length} venta(s) importada(s)${skipped ? ` · ${skipped} fila(s) omitida(s)` : ''}`); } catch (err) { triggerToast(err.message || 'Error al importar el archivo', 'error'); } finally { setIsImporting(false); if (fileInputRef.current) fileInputRef.current.value = ''; } }; const filtered = ventas.filter(v => `${v.producto} ${v.cliente} ${v.sucursal}`.toLowerCase().includes(search.toLowerCase())); const total = filtered.reduce((s, v) => s + v.valor, 0); return (

Ventas

Desglose de ventas por producto, cliente y sucursal

{ const f = e.target.files?.[0]; if (f) importInformeVentas(f); e.target.value = ''; }} />
{showForm && (

{editingId ? 'Editar Venta' : 'Nueva Venta'}

setFormData({ ...formData, fecha: e.target.value })} />
setFormData({ ...formData, cantidad: e.target.value })} />
setFormData({ ...formData, producto: e.target.value })} />
setFormData({ ...formData, cliente: e.target.value })} />
setFormData({ ...formData, sucursal: e.target.value })} />
setFormData({ ...formData, valor: e.target.value })} />
)} setVentas(prev => prev.filter(v => !!v.fecha))} /> setVentas(prev => prev.filter(v => !(v.periodId === pid && v.origen === 'informe_ventas')))} />
setSearch(e.target.value)} />
Total: {formatCurrency(total)}
{filtered.slice(page * TABLE_PAGE_SIZE, (page + 1) * TABLE_PAGE_SIZE).map(v => ( ))} {filtered.length === 0 && }
FechaProductoClienteSucursalCantidadValorAcciones
{v.fecha || '—'} {v.producto} {v.cliente} {v.sucursal || '—'} {v.cantidad || '—'} {formatCurrency(v.valor)}
Sin ventas registradas en este periodo.
); }; // ========================================================================== // Compras // ========================================================================== const ComprasView = () => { const [showForm, setShowForm] = useState(false); const [editingId, setEditingId] = useState(null); const [search, setSearch] = useState(''); const [page, setPage] = useState(0); const [isImporting, setIsImporting] = useState(false); const fileInputRef = useRef(null); const blankForm = { fecha: new Date().toISOString().slice(0, 10), producto: '', proveedor: '', cantidad: '', valor: '' }; const [formData, setFormData] = useState(blankForm); useEffect(() => { setPage(0); }, [search]); // ---- Importador dedicado del "Informe de Compras" (formato fijo, sin plantilla) ---- const fileInputInformeRef = useRef(null); const [isImportingInforme, setIsImportingInforme] = useState(false); const importInformeCompras = async (file) => { setIsImportingInforme(true); try { const XLSX = await ensureXLSX(); const buf = await file.arrayBuffer(); const wb = XLSX.read(buf, { type: 'array' }); const raw = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]], { header: 1, raw: true }); const startIdx = raw.length && String(raw[0]?.[0] || '').trim().toLowerCase() === 'fecha' ? 1 : 0; const rows = []; const periodCache = {}; periods.forEach(p => { periodCache[p.month] = p.id; }); let sinFecha = 0; const seqCounter = {}; for (let i = startIdx; i < raw.length; i++) { const r = raw[i]; if (!r || !r.length) continue; const fecha = parseFechaFlexible(r[0]); const proveedor = String(r[1] || '').trim(); if (!fecha || !proveedor) { sinFecha++; continue; } const producto = String(r[2] || '').trim(); const presentacion = cleanPlaceholder(r[3]); const cantidad = parseNum(r[4]); const unidad = cleanPlaceholder(r[5]); const precioUnitario = parseNum(r[6]); const valor = parseNum(r[7]); const metodoPago = cleanPlaceholder(r[8]); const compradora = cleanPlaceholder(r[9]); const month = fecha.slice(0, 7); let periodId = periodCache[month]; if (!periodId) { const { start, end } = monthBounds(month); const [row] = await window.SB.crudInsert('op_periodos', [{ month, start_date: start, end_date: end, is_closed: false }]); periodId = row.id; periodCache[month] = periodId; setPeriods(prev => [...prev, periodFromDb(row)].sort((a, b) => a.month.localeCompare(b.month))); } const fpKey = `${fecha}|${valor}|${normalizeCatName(`${proveedor}|${producto}`)}`; seqCounter[fpKey] = (seqCounter[fpKey] || 0) + 1; rows.push({ ...compraToDb({ periodId, fecha, producto, proveedor, cantidad, valor, presentacion, unidad, precioUnitario, metodoPago, compradora }), origen: 'informe_compras', external_ref: rowFingerprint(fecha, valor, `${proveedor}|${producto}`, seqCounter[fpKey]), }); } if (!rows.length) { triggerToast('No se encontraron filas válidas para importar', 'error'); return; } const result = await bulkUpsert('op_compras', rows, 'origen,external_ref'); if (rows.some(row => row.period_id === activePeriodId)) { const c = await window.SB.crudSelect('op_compras', { eq: { period_id: activePeriodId }, order: { col: 'fecha', asc: true } }); setCompras(c.map(compraFromDb)); } let msg = `${result.length} compra(s) importada(s)/actualizada(s)`; if (sinFecha) msg += ` · ${sinFecha} fila(s) sin fecha/proveedor omitida(s)`; triggerToast(msg); } catch (err) { triggerToast(err.message || 'Error al importar el Informe de Compras', 'error'); } finally { setIsImportingInforme(false); } }; const openNew = () => { if (!activePeriodId) { triggerToast('Primero crea un periodo en Periodos / Cierre', 'error'); return; } setEditingId(null); setFormData(blankForm); setShowForm(true); }; const editRow = (c) => { setFormData({ fecha: c.fecha || '', producto: c.producto, proveedor: c.proveedor, cantidad: c.cantidad, valor: c.valor }); setEditingId(c.id); setShowForm(true); }; const handleSave = async (e) => { e.preventDefault(); const payload = { ...formData, periodId: activePeriodId }; try { if (editingId) { const [row] = await window.SB.crudUpdate('op_compras', editingId, compraToDb(payload)); setCompras(prev => prev.map(c => c.id === editingId ? compraFromDb(row) : c)); triggerToast('Compra actualizada'); } else { const [row] = await window.SB.crudInsert('op_compras', [compraToDb(payload)]); setCompras(prev => [...prev, compraFromDb(row)]); triggerToast('Compra registrada'); } setShowForm(false); setEditingId(null); } catch (err) { triggerToast(err.message || 'Error al guardar la compra', 'error'); } }; const downloadTemplate = async () => { try { const XLSX = await ensureXLSX(); const ws = XLSX.utils.aoa_to_sheet([ ['Fecha (AAAA-MM-DD)', 'Producto', 'Proveedor', 'Cantidad', 'Valor'], ['2026-07-10', 'Papa Criolla x Kg', 'Promotora y Operadora de Mercados', 200, 620000], ]); ws['!cols'] = [{ wch: 16 }, { wch: 26 }, { wch: 30 }, { wch: 12 }, { wch: 14 }]; const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, 'Compras'); XLSX.writeFile(wb, 'Plantilla_Compras_CierreOperativo.xlsx'); } catch (err) { triggerToast('Error al generar la plantilla', 'error'); } }; const handleImportFile = async (e) => { const file = e.target.files?.[0]; if (!file) return; if (!activePeriodId) { triggerToast('Primero crea/selecciona un periodo', 'error'); e.target.value = ''; return; } setIsImporting(true); try { const XLSX = await ensureXLSX(); const buf = await file.arrayBuffer(); const wb = XLSX.read(buf, { type: 'array' }); const rows = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]], { header: 1, raw: true }); // El Informe de Compras real trae "Proveedor" en la columna B (índice // 1); la plantilla simple trae "Producto" ahí — si detectamos // "Proveedor" es el archivo equivocado para este botón. if (String(rows[0]?.[1] || '').trim().toLowerCase() === 'proveedor') { triggerToast('Este archivo es el Informe de Compras completo — usa el botón "Importar Informe de Compras", no "Importar Excel".', 'error'); return; } const startIdx = rows.length && String(rows[0]?.[0] || '').trim().toLowerCase().startsWith('fecha') ? 1 : 0; const toInsert = []; let skipped = 0; for (let i = startIdx; i < rows.length; i++) { const r = rows[i]; if (!r || !r.length) continue; const producto = String(r[1] || '').trim(); const proveedor = String(r[2] || '').trim(); if (!producto && !proveedor) { skipped++; continue; } toInsert.push(compraToDb({ periodId: activePeriodId, fecha: parseFecha(r[0]), producto, proveedor, cantidad: parseNum(r[3]), valor: parseNum(r[4]), })); } if (!toInsert.length) { triggerToast('No se encontraron filas válidas para importar', 'error'); return; } const rowsInserted = await bulkInsert('op_compras', toInsert); setCompras(prev => [...prev, ...rowsInserted.map(compraFromDb)]); triggerToast(`${rowsInserted.length} compra(s) importada(s)${skipped ? ` · ${skipped} fila(s) omitida(s)` : ''}`); } catch (err) { triggerToast(err.message || 'Error al importar el archivo', 'error'); } finally { setIsImporting(false); if (fileInputRef.current) fileInputRef.current.value = ''; } }; const filtered = compras.filter(c => `${c.producto} ${c.proveedor}`.toLowerCase().includes(search.toLowerCase())); const total = filtered.reduce((s, c) => s + c.valor, 0); return (

Compras

Desglose de compras por producto y proveedor

{ const f = e.target.files?.[0]; if (f) importInformeCompras(f); e.target.value = ''; }} />
{showForm && (

{editingId ? 'Editar Compra' : 'Nueva Compra'}

setFormData({ ...formData, fecha: e.target.value })} />
setFormData({ ...formData, cantidad: e.target.value })} />
setFormData({ ...formData, producto: e.target.value })} />
setFormData({ ...formData, proveedor: e.target.value })} />
setFormData({ ...formData, valor: e.target.value })} />
)} setCompras(prev => prev.filter(c => !!c.fecha))} /> setCompras(prev => prev.filter(c => !(c.periodId === pid && c.origen === 'informe_compras')))} />
setSearch(e.target.value)} />
Total: {formatCurrency(total)}
{filtered.slice(page * TABLE_PAGE_SIZE, (page + 1) * TABLE_PAGE_SIZE).map(c => ( ))} {filtered.length === 0 && }
FechaProductoProveedorCantidadValorAcciones
{c.fecha || '—'} {c.producto} {c.proveedor} {c.cantidad || '—'} {formatCurrency(c.valor)}
Sin compras registradas en este periodo.
); }; // ========================================================================== // Gastos (+ categorías personalizables) // ========================================================================== const GastosView = () => { const [showCatModal, setShowCatModal] = useState(false); const [editingCatId, setEditingCatId] = useState(null); const blankCatForm = { name: '', color: '#4f46e5' }; const [catForm, setCatForm] = useState(blankCatForm); const [catsExpanded, setCatsExpanded] = useState(false); const CATS_COLLAPSED_COUNT = 6; const [showForm, setShowForm] = useState(false); const [editingId, setEditingId] = useState(null); const [search, setSearch] = useState(''); const [filterOrigen, setFilterOrigen] = useState(''); const [filterCategoryId, setFilterCategoryId] = useState(''); const [page, setPage] = useState(0); const [isImporting, setIsImporting] = useState(false); const fileInputRef = useRef(null); const blankForm = { fecha: new Date().toISOString().slice(0, 10), categoryId: '', descripcion: '', proveedor: '', valor: '' }; useEffect(() => { setPage(0); }, [search, filterOrigen, filterCategoryId]); const [formData, setFormData] = useState(blankForm); // ---- Importadores de Corabastos / Gastos Generales (formatos fijos, sin plantilla) ---- const fileInputCoraRef = useRef(null); const fileInputGeneralRef = useRef(null); const [isImportingCora, setIsImportingCora] = useState(false); const [isImportingGeneral, setIsImportingGeneral] = useState(false); const [pendingImport, setPendingImport] = useState(null); // { origen, rows, map, pendingCats } const [catChoices, setCatChoices] = useState({}); // Cruza cada nombre de categoría del archivo contra las categorías ya existentes // en Cierre Operativo y, si no hay match, contra las de Caja Menor — en ese caso // usa la MISMA función que ya usa el trigger de sincronización de Caja Menor // (get_or_create_op_category_for_cm) para que quede enlazada 1:1 y el análisis // las sume juntas. Lo que no cruza con nada se deja pendiente de confirmar. const buildCategoryMatch = async (rawNames) => { const byNormOp = {}; categories.forEach(c => { byNormOp[normalizeCatName(c.name)] = c.id; }); const byNormCm = {}; cmCategorias.forEach(c => { byNormCm[normalizeCatName(c.name)] = c.id; }); const map = {}; const pendingByNorm = {}; const newlyLinked = []; for (const raw of rawNames) { const norm = normalizeCatName(raw); if (!norm || map[norm] !== undefined) continue; if (byNormOp[norm]) { map[norm] = byNormOp[norm]; continue; } if (byNormCm[norm]) { try { const opId = await window.SB.rpc('get_or_create_op_category_for_cm', { p_cm_categoria_id: byNormCm[norm] }); map[norm] = opId; byNormOp[norm] = opId; const cmCat = cmCategorias.find(c => c.id === byNormCm[norm]); newlyLinked.push({ id: opId, name: cmCat?.name || raw, color: cmCat?.color || '#4f46e5', parentId: '', sourceCmCategoriaId: byNormCm[norm] }); continue; } catch (err) { /* si falla el enlace, se pide confirmación manual abajo */ } } pendingByNorm[norm] = raw; } if (newlyLinked.length) { setCategories(prev => { const existingIds = new Set(prev.map(c => c.id)); return [...prev, ...newlyLinked.filter(c => !existingIds.has(c.id))]; }); } return { map, pending: Object.entries(pendingByNorm).map(([norm, raw]) => ({ norm, raw })) }; }; // Inserta/actualiza los gastos ya resueltos (categoría asignada a cada fila), // creando el periodo mensual automáticamente si la fecha cae en un mes nuevo. const finishImport = async (origen, rows, categoryMap) => { const periodCache = {}; periods.forEach(p => { periodCache[p.month] = p.id; }); const seqCounter = {}; const toUpsert = []; for (const r of rows) { const month = r.fecha.slice(0, 7); let periodId = periodCache[month]; if (!periodId) { const { start, end } = monthBounds(month); const [row] = await window.SB.crudInsert('op_periodos', [{ month, start_date: start, end_date: end, is_closed: false }]); periodId = row.id; periodCache[month] = periodId; setPeriods(prev => [...prev, periodFromDb(row)].sort((a, b) => a.month.localeCompare(b.month))); } const key = `${r.fecha}|${r.valor}|${normalizeCatName(r.descripcion)}`; seqCounter[key] = (seqCounter[key] || 0) + 1; toUpsert.push({ ...gastoToDb({ periodId, fecha: r.fecha, categoryId: categoryMap[r.catNorm] || '', descripcion: r.descripcion, proveedor: r.proveedor, valor: r.valor }), origen, external_ref: rowFingerprint(r.fecha, r.valor, r.descripcion, seqCounter[key]), }); } if (!toUpsert.length) { triggerToast('No se encontraron filas válidas para importar', 'error'); return; } const result = await bulkUpsert('op_gastos', toUpsert, 'origen,external_ref'); if (toUpsert.some(row => row.period_id === activePeriodId)) { const g = await window.SB.crudSelect('op_gastos', { eq: { period_id: activePeriodId }, order: { col: 'fecha', asc: true } }); setGastos(g.map(gastoFromDb)); } triggerToast(`${result.length} gasto(s) importado(s)/actualizado(s)`); }; const runImport = async (origen, rows) => { const rawNames = [...new Set(rows.map(r => r.catRaw).filter(Boolean))]; const { map, pending } = await buildCategoryMatch(rawNames); if (pending.length > 0) { const defaults = {}; pending.forEach((p, i) => { defaults[p.norm] = { mode: 'new', name: p.raw, color: COLOR_PALETTE[i % COLOR_PALETTE.length] }; }); setCatChoices(defaults); setPendingImport({ origen, rows, map, pendingCats: pending }); return; } await finishImport(origen, rows, map); }; const confirmCategoryChoices = async () => { if (!pendingImport) return; const finalMap = { ...pendingImport.map }; const newCats = []; for (const { norm, raw } of pendingImport.pendingCats) { const choice = catChoices[norm] || { mode: 'new', name: raw, color: COLOR_PALETTE[0] }; if (choice.mode === 'link') { if (!choice.linkId) { triggerToast(`Elige una categoría para vincular "${raw}"`, 'error'); return; } finalMap[norm] = choice.linkId; } else { newCats.push({ norm, name: (choice.name || raw).trim() || raw, color: choice.color || COLOR_PALETTE[0] }); } } try { if (newCats.length) { const inserted = await window.SB.crudInsert('op_categorias_gasto', newCats.map(c => catToDb({ name: c.name, color: c.color }))); inserted.forEach((row, i) => { finalMap[newCats[i].norm] = row.id; }); setCategories(prev => [...prev, ...inserted.map(catFromDb)]); } await finishImport(pendingImport.origen, pendingImport.rows, finalMap); setPendingImport(null); setCatChoices({}); } catch (err) { triggerToast(err.message || 'Error al guardar las categorías', 'error'); } }; const importCorabastos = async (file) => { setIsImportingCora(true); try { const XLSX = await ensureXLSX(); const buf = await file.arrayBuffer(); const wb = XLSX.read(buf, { type: 'array' }); const raw = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]], { header: 1, raw: true }); const startIdx = raw.length && String(raw[0]?.[0] || '').trim().toLowerCase().startsWith('fecha') ? 1 : 0; const rows = []; for (let i = startIdx; i < raw.length; i++) { const r = raw[i]; if (!r || !r.length) continue; const fecha = parseFechaFlexible(r[0]); if (!fecha) continue; const catRaw = String(r[1] || '').trim(); const valor = parseNum(r[2]); const notas = String(r[3] || '').trim(); rows.push({ fecha, catNorm: normalizeCatName(catRaw), catRaw, descripcion: notas || catRaw || 'Gasto Corabastos', proveedor: '', valor }); } if (!rows.length) { triggerToast('No se encontraron filas válidas en el archivo', 'error'); return; } await runImport('corabastos', rows); } catch (err) { triggerToast(err.message || 'Error al importar Gastos Corabastos', 'error'); } finally { setIsImportingCora(false); } }; const importGeneral = async (file) => { setIsImportingGeneral(true); try { const XLSX = await ensureXLSX(); const buf = await file.arrayBuffer(); const wb = XLSX.read(buf, { type: 'array' }); const raw = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]], { header: 1, raw: true }); const startIdx = raw.length && String(raw[0]?.[0] || '').trim().toLowerCase().startsWith('fecha') ? 1 : 0; const rows = []; for (let i = startIdx; i < raw.length; i++) { const r = raw[i]; if (!r || !r.length) continue; const fecha = parseFechaFlexible(r[0]); if (!fecha) continue; const concepto = String(r[1] || '').trim().replace(/\s+/g, ' '); const catRaw = String(r[2] || '').trim(); const valor = parseNum(r[3]); const observacion = String(r[4] || '').trim(); const descripcion = observacion ? `${concepto} — ${observacion}` : (concepto || 'Gasto operación'); rows.push({ fecha, catNorm: normalizeCatName(catRaw), catRaw, descripcion, proveedor: '', valor }); } if (!rows.length) { triggerToast('No se encontraron filas válidas en el archivo', 'error'); return; } await runImport('general', rows); } catch (err) { triggerToast(err.message || 'Error al importar Gastos Generales', 'error'); } finally { setIsImportingGeneral(false); } }; const handleSaveCat = async (e) => { e.preventDefault(); try { if (editingCatId) { const [row] = await window.SB.crudUpdate('op_categorias_gasto', editingCatId, catToDb(catForm)); setCategories(prev => prev.map(c => c.id === editingCatId ? catFromDb(row) : c)); triggerToast('Categoría actualizada'); } else { const [row] = await window.SB.crudInsert('op_categorias_gasto', [catToDb(catForm)]); setCategories(prev => [...prev, catFromDb(row)]); triggerToast('Categoría creada'); } setShowCatModal(false); setEditingCatId(null); } catch (err) { triggerToast(err.message || 'Error al guardar la categoría', 'error'); } }; const openNew = () => { if (!activePeriodId) { triggerToast('Primero crea un periodo en Periodos / Cierre', 'error'); return; } setEditingId(null); setFormData(blankForm); setShowForm(true); }; const editRow = (g) => { setFormData({ fecha: g.fecha || '', categoryId: g.categoryId, descripcion: g.descripcion, proveedor: g.proveedor, valor: g.valor }); setEditingId(g.id); setShowForm(true); }; const handleSave = async (e) => { e.preventDefault(); const payload = { ...formData, periodId: activePeriodId }; try { if (editingId) { const [row] = await window.SB.crudUpdate('op_gastos', editingId, gastoToDb(payload)); setGastos(prev => prev.map(g => g.id === editingId ? gastoFromDb(row) : g)); triggerToast('Gasto actualizado'); } else { const [row] = await window.SB.crudInsert('op_gastos', [gastoToDb(payload)]); setGastos(prev => [...prev, gastoFromDb(row)]); triggerToast('Gasto registrado'); } setShowForm(false); setEditingId(null); } catch (err) { triggerToast(err.message || 'Error al guardar el gasto', 'error'); } }; const downloadTemplate = async () => { try { const XLSX = await ensureXLSX(); const ws = XLSX.utils.aoa_to_sheet([ ['Fecha (AAAA-MM-DD)', 'Categoría', 'Descripción', 'Proveedor', 'Valor'], ['2026-07-05', categories[0]?.name || 'Transporte', 'Flete entrega Bogotá', 'Transportadora XYZ', 450000], ]); ws['!cols'] = [{ wch: 16 }, { wch: 20 }, { wch: 30 }, { wch: 24 }, { wch: 14 }]; const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, 'Gastos'); XLSX.writeFile(wb, 'Plantilla_Gastos_CierreOperativo.xlsx'); } catch (err) { triggerToast('Error al generar la plantilla', 'error'); } }; const handleImportFile = async (e) => { const file = e.target.files?.[0]; if (!file) return; if (!activePeriodId) { triggerToast('Primero crea/selecciona un periodo', 'error'); e.target.value = ''; return; } setIsImporting(true); try { const XLSX = await ensureXLSX(); const buf = await file.arrayBuffer(); const wb = XLSX.read(buf, { type: 'array' }); const rows = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]], { header: 1, raw: true }); const startIdx = rows.length && String(rows[0]?.[0] || '').trim().toLowerCase().startsWith('fecha') ? 1 : 0; const catByName = {}; categories.forEach(c => { catByName[c.name.trim().toLowerCase()] = c.id; }); const toInsert = []; let skipped = 0, unmatchedCategory = 0; for (let i = startIdx; i < rows.length; i++) { const r = rows[i]; if (!r || !r.length) continue; const descripcion = String(r[2] || '').trim(); const catName = String(r[1] || '').trim(); if (!descripcion) { skipped++; continue; } let categoryId = ''; if (catName) { categoryId = catByName[catName.toLowerCase()] || ''; if (!categoryId) unmatchedCategory++; } toInsert.push(gastoToDb({ periodId: activePeriodId, fecha: parseFecha(r[0]), categoryId, descripcion, proveedor: String(r[3] || '').trim(), valor: parseNum(r[4]), })); } if (!toInsert.length) { triggerToast('No se encontraron filas válidas para importar', 'error'); return; } const rowsInserted = await bulkInsert('op_gastos', toInsert); setGastos(prev => [...prev, ...rowsInserted.map(gastoFromDb)]); let msg = `${rowsInserted.length} gasto(s) importado(s)`; if (skipped) msg += ` · ${skipped} sin descripción`; if (unmatchedCategory) msg += ` · ${unmatchedCategory} sin categoría reconocida`; triggerToast(msg); } catch (err) { triggerToast(err.message || 'Error al importar el archivo', 'error'); } finally { setIsImporting(false); if (fileInputRef.current) fileInputRef.current.value = ''; } }; // El desplegable de categorías solo debe ofrecer las categorías que // realmente tienen gastos dentro del origen elegido (si se elige uno) — // así "General"/"Corabastos" y "categoría" filtran en cascada, no en // paralelo. Si la categoría antes elegida deja de aplicar al cambiar de // origen, se limpia sola en vez de dejar la tabla vacía en silencio. const gastosDelOrigen = gastos.filter(g => filterOrigen ? g.origen === filterOrigen : true); const availableCatIds = new Set(gastosDelOrigen.map(g => g.categoryId).filter(Boolean)); const categoryOptions = categories.filter(c => availableCatIds.has(c.id)); useEffect(() => { if (filterCategoryId && !availableCatIds.has(filterCategoryId)) setFilterCategoryId(''); // eslint-disable-next-line react-hooks/exhaustive-deps }, [filterOrigen]); const filtered = gastosDelOrigen .filter(g => `${g.descripcion} ${g.proveedor}`.toLowerCase().includes(search.toLowerCase())) .filter(g => filterCategoryId ? g.categoryId === filterCategoryId : true); const total = filtered.reduce((s, g) => s + g.valor, 0); const ORIGEN_LABEL = { corabastos: 'Corabastos', general: 'General' }; const catLabelFor = (g) => { const cat = categories.find(c => c.id === g.categoryId); if (!cat) return 'Sin categoría'; const parent = cat.parentId ? categories.find(c => c.id === cat.parentId) : null; return parent ? `${parent.name} › ${cat.name}` : cat.name; }; // Exporta EXACTAMENTE las filas visibles con el filtro actual (búsqueda + // origen + categoría) — no todo el listado de gastos del periodo. const exportFiltered = async () => { if (!filtered.length) { triggerToast('No hay gastos con ese filtro para exportar', 'error'); return; } try { const XLSX = await ensureXLSX(); const filtroCategoria = filterCategoryId ? catLabelFor({ categoryId: filterCategoryId }) : 'Todas'; const filtroOrigen = filterOrigen ? (ORIGEN_LABEL[filterOrigen] || filterOrigen) : 'Todos'; const filtroBusqueda = search.trim() || '(ninguna)'; const aoa = [ ['Filtros aplicados'], ['Periodo', activePeriod?.month || '—'], ['Origen', filtroOrigen], ['Categoría', filtroCategoria], ['Búsqueda', filtroBusqueda], ['Total filtrado', total], [], ['Fecha', 'Origen', 'Categoría', 'Descripción', 'Proveedor', 'Valor', 'Notas'], ...filtered.map(g => [ g.fecha || '', ORIGEN_LABEL[g.origen] || g.origen || (g.sourceCmId ? 'Caja Menor' : '—'), catLabelFor(g), g.descripcion || '', g.proveedor || '', g.valor, [g.sourceCmId ? 'Sincronizado desde Caja Menor' : '', esTransferenciaCajaMenor(g) ? 'Transferencia (no cuenta como gasto)' : ''] .filter(Boolean).join(' · '), ]), ]; const ws = XLSX.utils.aoa_to_sheet(aoa); ws['!cols'] = [{ wch: 12 }, { wch: 12 }, { wch: 26 }, { wch: 32 }, { wch: 24 }, { wch: 14 }, { wch: 32 }]; const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, 'Gastos filtrados'); const parteOrigen = filterOrigen ? `_${filterOrigen}` : ''; const parteCategoria = filterCategoryId ? `_${catLabelFor({ categoryId: filterCategoryId }).replace(/[^\w]+/g, '')}` : ''; const fecha = new Date().toISOString().slice(0, 10); XLSX.writeFile(wb, `Gastos_Filtrados${parteOrigen}${parteCategoria}_${fecha}.xlsx`); } catch (err) { triggerToast('Error al generar el Excel', 'error'); } }; return (

Gastos

Categoriza y registra todo tipo de gasto operativo

{(catsExpanded ? categories : categories.slice(0, CATS_COLLAPSED_COUNT)).map(c => { const parent = c.parentId ? categories.find(x => x.id === c.parentId) : null; const isUmbrella = c.name === 'Caja Menor Administración' && !c.parentId; const isSynced = isUmbrella || !!c.sourceCmCategoriaId; return ( {parent ? `${parent.name} › ${c.name}` : c.name} {isSynced ? ( 🔒 ) : ( <> )} ); })} {categories.length === 0 && Aún no hay categorías — crea la primera arriba.} {categories.length > CATS_COLLAPSED_COUNT && ( )}
{categories.some(c => c.sourceCmCategoriaId || (c.name === 'Caja Menor Administración' && !c.parentId)) && (

🔒 Las categorías de Caja Menor se sincronizan automáticamente y solo se pueden editar desde ese módulo.

)} {showCatModal && (

{editingCatId ? 'Editar Categoría' : 'Nueva Categoría de Gasto'}

setCatForm({ ...catForm, name: e.target.value })} />
setCatForm({ ...catForm, color: e.target.value })} /> {catForm.color}
)}
{ const f = e.target.files?.[0]; if (f) importCorabastos(f); e.target.value = ''; }} /> { const f = e.target.files?.[0]; if (f) importGeneral(f); e.target.value = ''; }} />
{pendingImport && (

Confirma las categorías nuevas

{pendingImport.pendingCats.length} categoría{pendingImport.pendingCats.length === 1 ? '' : 's'} del archivo no coincide{pendingImport.pendingCats.length === 1 ? '' : 'n'} con ninguna existente

{pendingImport.pendingCats.map(({ norm, raw }) => { const choice = catChoices[norm] || { mode: 'new', name: raw, color: COLOR_PALETTE[0] }; return (

Categoría en el archivo: {raw}

{choice.mode === 'new' ? (
setCatChoices(prev => ({ ...prev, [norm]: { ...choice, name: e.target.value } }))} /> setCatChoices(prev => ({ ...prev, [norm]: { ...choice, color: e.target.value } }))} />
) : ( )}
); })}
)} {showForm && (

{editingId ? 'Editar Gasto' : 'Nuevo Gasto'}

setFormData({ ...formData, fecha: e.target.value })} />
setFormData({ ...formData, descripcion: e.target.value })} />
setFormData({ ...formData, proveedor: e.target.value })} />
setFormData({ ...formData, valor: e.target.value })} />
)}
setSearch(e.target.value)} />
Total: {formatCurrency(total)}
{filtered.slice(page * TABLE_PAGE_SIZE, (page + 1) * TABLE_PAGE_SIZE).map(g => { const cat = categories.find(c => c.id === g.categoryId); const parentCat = cat?.parentId ? categories.find(c => c.id === cat.parentId) : null; const isSynced = !!g.sourceCmId; return ( ); })} {filtered.length === 0 && }
FechaCategoríaDescripciónProveedorValorAcciones
{g.fecha || '—'} {cat ? {parentCat ? `${parentCat.name} › ${cat.name}` : cat.name} : Sin categoría} {g.descripcion} {isSynced && 🔒 Caja Menor} {!isSynced && g.origen && {ORIGEN_LABEL[g.origen] || g.origen}} {esTransferenciaCajaMenor(g) && 🔁 Transferencia} {g.proveedor || '—'} {formatCurrency(g.valor)} {isSynced ? ( Editar en Caja Menor ) : ( <> )}
Sin gastos registrados en este periodo.
); }; // ========================================================================== // Nómina // ========================================================================== const NominaView = () => { const [showForm, setShowForm] = useState(false); const [editingId, setEditingId] = useState(null); const [search, setSearch] = useState(''); const [page, setPage] = useState(0); const [isImporting, setIsImporting] = useState(false); const fileInputRef = useRef(null); const blankForm = { empleado: '', cargo: '', salarioBase: '', horasExtras: '', valorHorasExtras: '', deducciones: '', totalPagado: '' }; const [formData, setFormData] = useState(blankForm); useEffect(() => { setPage(0); }, [search]); // ---- Importador dedicado del "Informe de Nómina" (formato fijo: PERIODO, // COLABORADOR, DOCUMENTO, # DOCUMENTO, SUELDO, AUXILIO TRANSPORTE, VALOR // TOTAL, QUINCENA, HORAS EXTRAS DIURNAS, VALOR UNITARIO HORA EXTRA, VALOR // TOTAL HORAS EXTRAS, RECARGO NOCTURNO, ..., DESCUENTOS, PARAFISCALES, // ADICIONALES O COMISIONES, VALOR TOTAL NOMINA). No trae fecha real (solo // un texto de quincena tipo "1 DE JULIO - 15 DE JULIO", sin año), así que // — a diferencia de Ventas/Compras/Gastos — se importa siempre al periodo // que esté activo en ese momento, igual que el importador genérico. const fileInputInformeRef = useRef(null); const [isImportingInforme, setIsImportingInforme] = useState(false); const importInformeNomina = async (file) => { if (!activePeriodId) { triggerToast('Primero crea/selecciona un periodo', 'error'); return; } setIsImportingInforme(true); try { const XLSX = await ensureXLSX(); const buf = await file.arrayBuffer(); const wb = XLSX.read(buf, { type: 'array' }); const raw = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]], { header: 1, raw: true }); const startIdx = raw.length && String(raw[0]?.[0] || '').trim().toUpperCase() === 'PERIODO' ? 1 : 0; const rows = []; const seqCounter = {}; for (let i = startIdx; i < raw.length; i++) { const r = raw[i]; if (!r || !r.length) continue; const empleado = String(r[1] || '').trim(); if (!empleado) continue; const periodoQuincena = String(r[0] || '').trim(); const documento = String(r[3] || '').trim(); const salarioBase = parseNum(r[4]); const auxilioTransporte = parseNum(r[5]); const valorMensualBase = parseNum(r[6]); const valorQuincenaBase = parseNum(r[7]); const horasExtras = parseNum(r[8]); const valorUnitarioHoraExtra = parseNum(r[9]); const valorHorasExtras = parseNum(r[10]); const recargoNocturnoHoras = parseNum(r[11]); const valorUnitarioRecargoNocturno = parseNum(r[12]); const valorRecargoNocturno = parseNum(r[13]); const recargoDominicalHoras = parseNum(r[14]); const valorUnitarioRecargoDominical = parseNum(r[15]); const valorRecargoDominical = parseNum(r[16]); const horaExtraDominicalHoras = parseNum(r[17]); const valorUnitarioHoraExtraDominical = parseNum(r[18]); const valorHoraExtraDominical = parseNum(r[19]); const valorTotalExtrasRecargos = parseNum(r[20]); const deducciones = parseNum(r[21]); const parafiscales = parseNum(r[22]); const adicionalesComisiones = parseNum(r[23]); const totalPagado = parseNum(r[24]); const fpKey = normalizeCatName(`${documento || empleado}|${periodoQuincena}`); seqCounter[fpKey] = (seqCounter[fpKey] || 0) + 1; rows.push({ ...nominaToDb({ periodId: activePeriodId, empleado, cargo: '', salarioBase, horasExtras, valorHorasExtras, deducciones, totalPagado, documento, periodoQuincena, auxilioTransporte, valorMensualBase, valorQuincenaBase, valorUnitarioHoraExtra, recargoNocturnoHoras, valorUnitarioRecargoNocturno, valorRecargoNocturno, recargoDominicalHoras, valorUnitarioRecargoDominical, valorRecargoDominical, horaExtraDominicalHoras, valorUnitarioHoraExtraDominical, valorHoraExtraDominical, valorTotalExtrasRecargos, parafiscales, adicionalesComisiones, }), origen: 'informe_nomina', external_ref: `${fpKey}|${seqCounter[fpKey]}`, }); } if (!rows.length) { triggerToast('No se encontraron filas válidas para importar', 'error'); return; } const result = await bulkUpsert('op_nomina', rows, 'origen,external_ref'); const n = await window.SB.crudSelect('op_nomina', { eq: { period_id: activePeriodId } }); setNomina(n.map(nominaFromDb)); triggerToast(`${result.length} registro(s) de nómina importado(s)/actualizado(s)`); } catch (err) { triggerToast(err.message || 'Error al importar el Informe de Nómina', 'error'); } finally { setIsImportingInforme(false); } }; const openNew = () => { if (!activePeriodId) { triggerToast('Primero crea un periodo en Periodos / Cierre', 'error'); return; } setEditingId(null); setFormData(blankForm); setShowForm(true); }; const editRow = (n) => { setFormData({ empleado: n.empleado, cargo: n.cargo, salarioBase: n.salarioBase, horasExtras: n.horasExtras, valorHorasExtras: n.valorHorasExtras, deducciones: n.deducciones, totalPagado: n.totalPagado }); setEditingId(n.id); setShowForm(true); }; const handleSave = async (e) => { e.preventDefault(); const base = parseFloat(formData.salarioBase) || 0; const extras = parseFloat(formData.valorHorasExtras) || 0; const deducc = parseFloat(formData.deducciones) || 0; const computedTotal = base + extras - deducc; const total = formData.totalPagado !== '' ? parseFloat(formData.totalPagado) : computedTotal; const payload = { ...formData, periodId: activePeriodId, salarioBase: base, valorHorasExtras: extras, deducciones: deducc, totalPagado: total }; try { if (editingId) { const [row] = await window.SB.crudUpdate('op_nomina', editingId, nominaToDb(payload)); setNomina(prev => prev.map(n => n.id === editingId ? nominaFromDb(row) : n)); triggerToast('Nómina actualizada'); } else { const [row] = await window.SB.crudInsert('op_nomina', [nominaToDb(payload)]); setNomina(prev => [...prev, nominaFromDb(row)]); triggerToast('Nómina registrada'); } setShowForm(false); setEditingId(null); } catch (err) { triggerToast(err.message || 'Error al guardar la nómina', 'error'); } }; const downloadTemplate = async () => { try { const XLSX = await ensureXLSX(); const ws = XLSX.utils.aoa_to_sheet([ ['Empleado', 'Cargo', 'Salario Base', 'Horas Extras', 'Valor Horas Extras', 'Deducciones', 'Total Pagado'], ['Juan Pérez', 'Conductor', 1500000, 12, 180000, 65000, 1615000], ]); ws['!cols'] = [{ wch: 24 }, { wch: 18 }, { wch: 14 }, { wch: 12 }, { wch: 16 }, { wch: 14 }, { wch: 14 }]; const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, 'Nomina'); XLSX.writeFile(wb, 'Plantilla_Nomina_CierreOperativo.xlsx'); } catch (err) { triggerToast('Error al generar la plantilla', 'error'); } }; const handleImportFile = async (e) => { const file = e.target.files?.[0]; if (!file) return; if (!activePeriodId) { triggerToast('Primero crea/selecciona un periodo', 'error'); e.target.value = ''; return; } setIsImporting(true); try { const XLSX = await ensureXLSX(); const buf = await file.arrayBuffer(); const wb = XLSX.read(buf, { type: 'array' }); const rows = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]], { header: 1, raw: true }); // El Informe de Nómina real trae "PERIODO" en la columna A; la // plantilla simple trae "Empleado" ahí — si detectamos "PERIODO" es // el archivo equivocado para este botón. if (String(rows[0]?.[0] || '').trim().toUpperCase() === 'PERIODO') { triggerToast('Este archivo es el Informe de Nómina completo — usa el botón "Importar Informe de Nómina", no "Importar Excel".', 'error'); return; } const startIdx = rows.length && String(rows[0]?.[0] || '').trim().toLowerCase().startsWith('empleado') ? 1 : 0; const toInsert = []; let skipped = 0; for (let i = startIdx; i < rows.length; i++) { const r = rows[i]; if (!r || !r.length) continue; const empleado = String(r[0] || '').trim(); if (!empleado) { skipped++; continue; } const salarioBase = parseNum(r[2]); const valorHorasExtras = parseNum(r[4]); const deducciones = parseNum(r[5]); const totalPagado = r[6] !== undefined && r[6] !== '' ? parseNum(r[6]) : (salarioBase + valorHorasExtras - deducciones); toInsert.push(nominaToDb({ periodId: activePeriodId, empleado, cargo: String(r[1] || '').trim(), salarioBase, horasExtras: parseNum(r[3]), valorHorasExtras, deducciones, totalPagado, })); } if (!toInsert.length) { triggerToast('No se encontraron filas válidas para importar', 'error'); return; } const rowsInserted = await bulkInsert('op_nomina', toInsert); setNomina(prev => [...prev, ...rowsInserted.map(nominaFromDb)]); triggerToast(`${rowsInserted.length} registro(s) de nómina importado(s)${skipped ? ` · ${skipped} fila(s) omitida(s)` : ''}`); } catch (err) { triggerToast(err.message || 'Error al importar el archivo', 'error'); } finally { setIsImporting(false); if (fileInputRef.current) fileInputRef.current.value = ''; } }; const filtered = nomina.filter(n => `${n.empleado} ${n.cargo}`.toLowerCase().includes(search.toLowerCase())); const total = filtered.reduce((s, n) => s + n.totalPagado, 0); return (

Nómina

Salarios, horas extras y deducciones del periodo

{ const f = e.target.files?.[0]; if (f) importInformeNomina(f); e.target.value = ''; }} />
{showForm && (

{editingId ? 'Editar Nómina' : 'Nuevo Registro de Nómina'}

setFormData({ ...formData, empleado: e.target.value })} />
setFormData({ ...formData, cargo: e.target.value })} />
setFormData({ ...formData, salarioBase: e.target.value })} />
setFormData({ ...formData, horasExtras: e.target.value })} />
setFormData({ ...formData, valorHorasExtras: e.target.value })} />
setFormData({ ...formData, deducciones: e.target.value })} />
setFormData({ ...formData, totalPagado: e.target.value })} />
)} setNomina(prev => prev.filter(n => !(n.periodId === pid && n.origen === 'informe_nomina')))} />
setSearch(e.target.value)} />
Total pagado: {formatCurrency(total)}
{filtered.slice(page * TABLE_PAGE_SIZE, (page + 1) * TABLE_PAGE_SIZE).map(n => ( ))} {filtered.length === 0 && }
EmpleadoCargoSalario BaseHoras ExtrasValor H. ExtrasDeduccionesTotal PagadoAcciones
{n.empleado} {n.cargo || n.periodoQuincena || '—'} {formatCurrency(n.salarioBase)} {n.horasExtras || 0} {formatCurrency(n.valorHorasExtras)} {formatCurrency(n.deducciones)} {formatCurrency(n.totalPagado)}
Sin nómina registrada en este periodo.
); }; // ========================================================================== // Reportes — Informe Ejecutivo de Cierre (PDF, tamaño Carta) // ========================================================================== const ReportesView = () => { const [isGenerating, setIsGenerating] = useState(false); const [isGeneratingExcel, setIsGeneratingExcel] = useState(false); const generateInformeEjecutivo = async () => { if (!activePeriod) { triggerToast('Selecciona un periodo primero', 'error'); return; } setIsGenerating(true); try { const jsPDF = await ensureJSPDF(); const doc = new jsPDF({ orientation: 'portrait', unit: 'pt', format: 'letter' }); const marginX = 40; const pageW = doc.internal.pageSize.getWidth(); const pageH = doc.internal.pageSize.getHeight(); const tableW = pageW - marginX * 2; const bottomLimit = pageH - 50; // ---- Encabezado ---- const logoW = await opAddLogo(doc, marginX, 18, 30); const titleX = marginX + (logoW ? logoW + 14 : 0); doc.setFontSize(16); doc.setFont(undefined, 'bold'); doc.setTextColor(15, 23, 42); doc.text('ECOCOSECHA SAS', titleX, 38); doc.setFontSize(11); doc.setFont(undefined, 'normal'); doc.setTextColor(71, 85, 105); doc.text('Informe Ejecutivo de Cierre de Mes', titleX, 54); doc.setFontSize(8.5); doc.text(`Periodo: ${activePeriod.month} · ${activePeriod.startDate} al ${activePeriod.endDate} · Generado el ${new Date().toISOString().slice(0, 10)}`, titleX, 68); let y = 92; doc.setDrawColor(226, 232, 240); doc.line(marginX, y, pageW - marginX, y); y += 22; // ---- KPIs principales ---- const kpis = [ ['Ventas', formatCurrency(metrics.totalVentas)], ['Compras', formatCurrency(metrics.totalCompras)], ['Gastos', formatCurrency(metrics.totalGastos)], ['Nómina', formatCurrency(metrics.totalNomina)], ['Utilidad Bruta', formatCurrency(metrics.utilidadBruta)], ['Margen Bruto', `${metrics.margenBruto.toFixed(1)}%`], ['Utilidad Neta', formatCurrency(metrics.utilidadNeta)], ['Margen Neto', `${metrics.margenNeto.toFixed(1)}%`], ]; const kpiGap = 10; const kpiW = (tableW - 3 * kpiGap) / 4; const kpiH = 52; kpis.forEach(([label, value], i) => { const col = i % 4; const row = Math.floor(i / 4); const kx = marginX + col * (kpiW + kpiGap); const ky = y + row * (kpiH + kpiGap); doc.setFillColor(15, 23, 42); doc.roundedRect(kx, ky, kpiW, kpiH, 6, 6, 'F'); doc.setFontSize(7); doc.setFont(undefined, 'normal'); doc.setTextColor(148, 163, 184); doc.text(label.toUpperCase(), kx + 10, ky + 17); doc.setFontSize(12); doc.setFont(undefined, 'bold'); doc.setTextColor(255, 255, 255); doc.text(value, kx + 10, ky + 37); }); y += 2 * (kpiH + kpiGap) + 20; doc.setFont(undefined, 'normal'); doc.setTextColor(0, 0, 0); // Tabla simple reutilizable [nombre, valor] con paginación dinámica. const simpleTable = (title, rows, color) => { if (y > bottomLimit - 60) { doc.addPage(); y = 50; } doc.setFontSize(9.5); doc.setFont(undefined, 'bold'); doc.setTextColor(15, 23, 42); doc.text(title, marginX, y); doc.autoTable({ startY: y + 8, margin: { left: marginX, right: marginX }, tableWidth: tableW, head: [[title, 'Total']], body: rows.length ? rows.map(r => [r.name, formatCurrency(r.value)]) : [['Sin datos', '—']], headStyles: { fillColor: color, fontSize: 8 }, styles: { fontSize: 8, cellPadding: 4 }, columnStyles: { 1: { halign: 'right', cellWidth: 110 } }, }); y = doc.lastAutoTable.finalY + 18; }; // Igual que simpleTable, pero con una columna extra de Cantidad // (kilos/unidades) — para Top Productos vendidos/comprados. const simpleTableQty = (title, rows, color) => { if (y > bottomLimit - 60) { doc.addPage(); y = 50; } doc.setFontSize(9.5); doc.setFont(undefined, 'bold'); doc.setTextColor(15, 23, 42); doc.text(title, marginX, y); doc.autoTable({ startY: y + 8, margin: { left: marginX, right: marginX }, tableWidth: tableW, head: [[title, 'Cantidad', 'Total']], body: rows.length ? rows.map(r => [r.name, r.cantidad.toLocaleString('es-CO', { maximumFractionDigits: 1 }), formatCurrency(r.value)]) : [['Sin datos', '—', '—']], headStyles: { fillColor: color, fontSize: 8 }, styles: { fontSize: 8, cellPadding: 4 }, columnStyles: { 1: { halign: 'right', cellWidth: 80 }, 2: { halign: 'right', cellWidth: 100 } }, }); y = doc.lastAutoTable.finalY + 18; }; // ---- 1. Ventas ---- if (y > bottomLimit - 60) { doc.addPage(); y = 50; } doc.setFontSize(13); doc.setFont(undefined, 'bold'); doc.setTextColor(15, 23, 42); doc.text('1. Ventas', marginX, y); doc.setFontSize(8.5); doc.setFont(undefined, 'normal'); doc.setTextColor(71, 85, 105); doc.text(`${metrics.nVentas} línea(s) · Ticket promedio ${formatCurrency(metrics.ticketPromedioVenta)}`, marginX, y + 14); y += 30; simpleTable('Top 10 Clientes', metrics.topClientes, [79, 70, 229]); simpleTableQty('Top 10 Productos', groupSumWithQty(ventas, v => v.producto).slice(0, 10), [14, 165, 233]); simpleTable('Ventas por Sucursal', metrics.ventasPorSucursal, [16, 185, 129]); simpleTable('Ventas por Fuente', metrics.ventasPorFuente, [99, 102, 241]); if (metrics.topClientes.length) { if (y > bottomLimit - 160) { doc.addPage(); y = 50; } y = drawBarChartPdf(doc, metrics.topClientes.slice(0, 5).map(c => [c.name, c.value]), marginX, y, tableW, '#4f46e5', 'Top 5 Clientes (gráfico)'); y += 16; } // ---- 2. Compras ---- if (y > bottomLimit - 60) { doc.addPage(); y = 50; } doc.setFontSize(13); doc.setFont(undefined, 'bold'); doc.setTextColor(15, 23, 42); doc.text('2. Compras', marginX, y); doc.setFontSize(8.5); doc.setFont(undefined, 'normal'); doc.setTextColor(71, 85, 105); doc.text(`${metrics.nCompras} línea(s) · Ticket promedio ${formatCurrency(metrics.ticketPromedioCompra)}`, marginX, y + 14); y += 30; simpleTable('Top 10 Proveedores', metrics.topProveedores, [244, 63, 94]); simpleTableQty('Top Productos Comprados', groupSumWithQty(compras, c => c.producto).slice(0, 10), [139, 92, 246]); simpleTable('Compras por Método de Pago', metrics.comprasPorMetodoPago, [245, 158, 11]); // ---- 3. Gastos ---- if (y > bottomLimit - 60) { doc.addPage(); y = 50; } doc.setFontSize(13); doc.setFont(undefined, 'bold'); doc.setTextColor(15, 23, 42); doc.text('3. Gastos', marginX, y); y += 22; simpleTable('Gastos por Categoría (todas)', metrics.gastosPorCategoria, [245, 158, 11]); simpleTable('Gastos por Origen', metrics.gastosPorOrigen, [100, 116, 139]); if (metrics.gastosPorCategoria.length) { if (y > bottomLimit - 160) { doc.addPage(); y = 50; } y = drawBarChartPdf(doc, metrics.gastosPorCategoria.slice(0, 5).map(c => [c.name, c.value]), marginX, y, tableW, '#f59e0b', 'Top 5 Categorías de Gasto (gráfico)'); y += 16; } // ---- 4. Nómina ---- const totalHorasExtrasN = nomina.reduce((s, n) => s + (Number(n.horasExtras) || 0) + (Number(n.horaExtraDominicalHoras) || 0), 0); const totalValorHorasExtrasN = nomina.reduce((s, n) => s + (Number(n.valorHorasExtras) || 0) + (Number(n.valorHoraExtraDominical) || 0), 0); const totalRecargosValorN = nomina.reduce((s, n) => s + (Number(n.valorRecargoNocturno) || 0) + (Number(n.valorRecargoDominical) || 0), 0); const totalDeduccionesN = nomina.reduce((s, n) => s + (Number(n.deducciones) || 0), 0); const totalQ1 = nomina.filter(n => quincenaLabel(n.periodoQuincena) === 'Quincena 1').reduce((s, n) => s + (Number(n.totalPagado) || 0), 0); const totalQ2 = nomina.filter(n => quincenaLabel(n.periodoQuincena) === 'Quincena 2').reduce((s, n) => s + (Number(n.totalPagado) || 0), 0); const topEmpleados = groupSum(nomina.map(n => ({ ...n, valor: n.totalPagado })), n => n.empleado).slice(0, 10); if (y > bottomLimit - 60) { doc.addPage(); y = 50; } doc.setFontSize(13); doc.setFont(undefined, 'bold'); doc.setTextColor(15, 23, 42); doc.text('4. Nómina', marginX, y); y += 22; const nominaKpis = [ ['Total Nómina', formatCurrency(metrics.totalNomina)], ['Quincena 1', formatCurrency(totalQ1)], ['Quincena 2', formatCurrency(totalQ2)], ['Horas Extras', `${totalHorasExtrasN.toLocaleString('es-CO', { maximumFractionDigits: 1 })} h (${formatCurrency(totalValorHorasExtrasN)})`], ['Recargos (Noct.+Dom.)', formatCurrency(totalRecargosValorN)], ['Deducciones', formatCurrency(totalDeduccionesN)], ]; if (y > bottomLimit - (Math.ceil(nominaKpis.length / 2) * (kpiH + kpiGap))) { doc.addPage(); y = 50; } const nKpiW = (tableW - kpiGap) / 2; nominaKpis.forEach(([label, value], i) => { const col = i % 2; const row = Math.floor(i / 2); const kx = marginX + col * (nKpiW + kpiGap); const ky = y + row * (kpiH + kpiGap); doc.setFillColor(15, 23, 42); doc.roundedRect(kx, ky, nKpiW, kpiH, 6, 6, 'F'); doc.setFontSize(7); doc.setFont(undefined, 'normal'); doc.setTextColor(148, 163, 184); doc.text(label.toUpperCase(), kx + 10, ky + 17); doc.setFontSize(11.5); doc.setFont(undefined, 'bold'); doc.setTextColor(255, 255, 255); doc.text(value, kx + 10, ky + 37); }); y += Math.ceil(nominaKpis.length / 2) * (kpiH + kpiGap) + 10; doc.setFont(undefined, 'normal'); doc.setTextColor(0, 0, 0); simpleTable('Top 10 Empleados por Total Pagado', topEmpleados, [79, 70, 229]); // ---- Pie de página en cada hoja ---- const pageCount = doc.internal.getNumberOfPages(); for (let p = 1; p <= pageCount; p++) { doc.setPage(p); doc.setFontSize(7.5); doc.setFont(undefined, 'normal'); doc.setTextColor(148, 163, 184); doc.text(`Ecocosecha SAS · Cierre Operativo · Periodo ${activePeriod.month}`, marginX, pageH - 24); doc.text(`Página ${p} de ${pageCount}`, pageW - marginX - 70, pageH - 24); } doc.save(`CierreOperativo_InformeEjecutivo_${activePeriod.month}.pdf`); triggerToast('Informe ejecutivo descargado ✓'); } catch (err) { console.error(err); triggerToast(err.message || 'Error al generar el informe', 'error'); } finally { setIsGenerating(false); } }; const generateInformeExcel = async () => { if (!activePeriod) { triggerToast('Selecciona un periodo primero', 'error'); return; } setIsGeneratingExcel(true); try { const XLSX = await ensureXLSX(); const wb = XLSX.utils.book_new(); const wsResumen = XLSX.utils.aoa_to_sheet([ ['ECOCOSECHA SAS · INFORME DE CIERRE', ''], ['Periodo', activePeriod.month], ['Rango', `${activePeriod.startDate} al ${activePeriod.endDate}`], [], ['Ventas', metrics.totalVentas], ['Compras', metrics.totalCompras], ['Gastos', metrics.totalGastos], ['Nómina', metrics.totalNomina], ['Utilidad Bruta', metrics.utilidadBruta], ['Margen Bruto %', metrics.margenBruto], ['Utilidad Neta', metrics.utilidadNeta], ['Margen Neto %', metrics.margenNeto], ]); XLSX.utils.book_append_sheet(wb, wsResumen, 'Resumen'); const ventasPorProducto = groupSumWithQty(ventas, v => v.producto).map(r => ({ Producto: r.name, 'Cantidad Total': r.cantidad, 'Precio Promedio': r.cantidad ? r.value / r.cantidad : 0, 'Valor Total': r.value, })); XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(ventasPorProducto), 'Ventas por Producto'); const comprasPorProducto = groupSumWithQty(compras, c => c.producto).map(r => ({ Producto: r.name, 'Cantidad Total': r.cantidad, 'Precio Promedio': r.cantidad ? r.value / r.cantidad : 0, 'Valor Total': r.value, })); XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(comprasPorProducto), 'Compras por Producto'); const ventasDetalle = ventas.map(v => ({ Fecha: v.fecha, Cliente: v.cliente, Sucursal: v.sucursal, Producto: v.producto, Cantidad: v.cantidad, 'Precio Unitario': v.precioUnitario, Valor: v.valor, Fuente: v.fuente, })); XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(ventasDetalle), 'Ventas Detalle'); const comprasDetalle = compras.map(c => ({ Fecha: c.fecha, Proveedor: c.proveedor, Producto: c.producto, Presentacion: c.presentacion, Cantidad: c.cantidad, 'Precio Unitario': c.precioUnitario, Valor: c.valor, 'Metodo Pago': c.metodoPago, Compradora: c.compradora, })); XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(comprasDetalle), 'Compras Detalle'); const gastosCat = metrics.gastosPorCategoria.map(r => ({ Categoria: r.name, Valor: r.value })); XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(gastosCat), 'Gastos por Categoria'); const nominaSheet = nomina.map(n => ({ Empleado: n.empleado, Quincena: n.periodoQuincena, 'Salario Base': n.salarioBase, 'Horas Extras': n.horasExtras, 'Valor H. Extras': n.valorHorasExtras, Deducciones: n.deducciones, 'Total Pagado': n.totalPagado, })); XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(nominaSheet), 'Nomina'); XLSX.writeFile(wb, `CierreOperativo_Informe_${activePeriod.month}.xlsx`); triggerToast('Informe en Excel descargado ✓'); } catch (err) { console.error(err); triggerToast(err.message || 'Error al generar el Excel', 'error'); } finally { setIsGeneratingExcel(false); } }; return (

Reportes

{activePeriod ? (

Periodo: {activePeriod.month} ({activePeriod.startDate} al {activePeriod.endDate})

) : (

No hay ningún periodo activo — ve a Periodos / Cierre para elegir uno.

)}

Informe Ejecutivo de Cierre

PDF listo para imprimir en hojas Carta con el resumen completo del periodo: KPIs y utilidades, ventas (clientes, productos, sucursales, fuente), compras (proveedores, productos, método de pago), gastos (por categoría y por origen) y nómina (horas extras, recargos, quincenas, top empleados).

Informe en Excel

Libro con varias hojas para analizar a fondo: Resumen, Ventas por Producto, Compras por Producto, Ventas Detalle, Compras Detalle, Gastos por Categoría y Nómina.

); }; // ========================================================================== // Periodos / Cierre // ========================================================================== const PeriodosView = () => { const [showModal, setShowModal] = useState(false); const [editingPeriodId, setEditingPeriodId] = useState(null); const blankForm = { month: '', startDate: '', endDate: '', isClosed: false }; const [periodData, setPeriodData] = useState(blankForm); const openNewPeriodModal = () => { const now = new Date(); const yyyy = now.getFullYear(); const mm = String(now.getMonth() + 1).padStart(2, '0'); const firstDay = `${yyyy}-${mm}-01`; const lastDayDate = new Date(yyyy, now.getMonth() + 1, 0); const lastDay = `${yyyy}-${mm}-${String(lastDayDate.getDate()).padStart(2, '0')}`; setPeriodData({ month: `${yyyy}-${mm}`, startDate: firstDay, endDate: lastDay, isClosed: false }); setEditingPeriodId(null); setShowModal(true); }; const handleSavePeriod = async (e) => { e.preventDefault(); try { if (editingPeriodId) { const [row] = await window.SB.crudUpdate('op_periodos', editingPeriodId, periodToDb(periodData)); setPeriods(prev => prev.map(p => p.id === editingPeriodId ? periodFromDb(row) : p)); triggerToast('Periodo actualizado'); } else { const [row] = await window.SB.crudInsert('op_periodos', [periodToDb(periodData)]); const mapped = periodFromDb(row); setPeriods(prev => [...prev, mapped]); setActivePeriodId(mapped.id); triggerToast('Nuevo periodo aperturado'); } setShowModal(false); setEditingPeriodId(null); } catch (err) { triggerToast(err.message || 'Error al guardar el periodo', 'error'); } }; const toggleClosed = async (p) => { try { const closed = !p.isClosed; await window.SB.crudUpdate('op_periodos', p.id, { is_closed: closed }); setPeriods(prev => prev.map(x => x.id === p.id ? { ...x, isClosed: closed } : x)); triggerToast(closed ? 'Periodo cerrado' : 'Periodo reabierto'); } catch (err) { triggerToast(err.message || 'Error al cambiar el estado', 'error'); } }; return (

Periodos y Cierre

Administra los meses de cierre de la operación

{periods.slice().reverse().map(p => ( ))} {periods.length === 0 && }
MesFechasEstadoAcciones
{p.month}{p.id === activePeriodId && Activo} {p.startDate} al {p.endDate} {p.id !== activePeriodId && }
Aún no hay periodos. Usa "Nuevo Periodo" para aperturar el primero.
{showModal && (

{editingPeriodId ? 'Editar Periodo' : 'Aperturar Nuevo Periodo'}

setPeriodData({ ...periodData, month: e.target.value })} />
setPeriodData({ ...periodData, startDate: e.target.value })} />
setPeriodData({ ...periodData, endDate: e.target.value })} />
)}
); }; // ========================================================================== // Render principal // ========================================================================== if (loading) { return (

Cargando Cierre Operativo…

); } if (loadError) { return (

No se pudieron cargar los datos

{loadError}

); } return (
Periodo Activo: {activePeriod?.month || 'Ninguno'} {loadingPeriodData && · cargando...}
setShowUserMenu(v => !v)} className="flex items-center space-x-3 cursor-pointer hover:bg-slate-100 p-1.5 pr-3 rounded-lg transition-colors border border-transparent hover:border-slate-200" title="Cuenta">

{userProfile.name || 'Sin nombre'}

{userProfile.role}

{(userProfile.name || '?').charAt(0).toUpperCase()}
{showUserMenu && ( <>
setShowUserMenu(false)}>

{userProfile.name || 'Sin nombre'}

{userProfile.role}

)}
{activeTab === 'dashboard' && } {activeTab === 'ventas' && } {activeTab === 'compras' && } {activeTab === 'gastos' && } {activeTab === 'analisis' && } {activeTab === 'nomina' && } {activeTab === 'reportes' && } {activeTab === 'periodos' && }
{showProfileModal && (

Editar Perfil

setUserProfile({ ...userProfile, name: e.target.value })} />
setUserProfile({ ...userProfile, role: e.target.value })} />
)} {toast.show && (
{toast.message}
)} {deleteConfirm.isOpen && (

¿Confirmar Eliminación?

{deleteConfirm.title}

)}
); } const root = ReactDOM.createRoot(document.getElementById('app')); root.render();