Skip to main content

Overview

The Reports & Analytics module provides complete business intelligence for Luis IT Repair, including sales dashboards, cash register control, expense tracking, and visual analytics with interactive charts.

Key Features

Real-Time KPIs

Today’s sales, ticket count, averages, units sold, and total IVA at a glance

Visual Analytics

Interactive charts for sales trends, top products, payment methods, and profit margins

Cash Register

Daily opening/closing with denomination counting, reconciliation, and PDF generation

Expense Tracking

Record withdrawals, expenses, and receipts with categorization and daily totals

Calendar View

Date-based filtering with interactive calendar and daily sales summaries

Closing History

Complete history of cash register closings with cashier tracking and reconciliation reports

Dashboard KPIs

The dashboard displays six key performance indicators:
const kpis = useMemo(() => {
  const total = ventasFiltradas.reduce((acc, v) => acc + Number(v.total || 0), 0);
  const tickets = ventasFiltradas.length;
  const promedio = tickets > 0 ? total / tickets : 0;

  const hoyIni = startOfToday();
  const hoyFin = endOfToday();
  const ventasHoy = ventas.filter(v => {
    const f = toDate(v.fecha);
    return f && f >= hoyIni && f <= hoyFin;
  });
  const totalHoy = ventasHoy.reduce((acc, v) => acc + Number(v.total || 0), 0);

  let unidades = 0;
  ventasFiltradas.forEach(v => {
    (v.productos || []).forEach(p => {
      unidades += Number(p?.cantidad || 0);
    });
  });

  const iva = ventasFiltradas.reduce((acc, v) => acc + Number(v.iva || 0), 0);
  
  return { total, tickets, promedio, totalHoy, unidades, iva };
}, [ventas, ventasFiltradas]);

KPI Cards

KPICalculationPurpose
Ventas hoySum of today’s salesTrack daily performance
Total periodoSum of filtered date rangePeriod-to-period comparison
TicketsCount of transactionsVolume indicator
Ticket promedioTotal ÷ TicketsAverage transaction value
Unidades vendidasSum of all item quantitiesInventory turnover
IVA totalSum of tax collectedTax reporting

Visual Analytics

Sales by Day (Line Chart)

Shows daily sales trends over the last 30 days:
const ventasPorDia = useMemo(() => {
  const map = new Map();
  ventasFiltradas.forEach(v => {
    const f = toDate(v.fecha);
    if (!f) return;
    const key = ymd(f);
    const curr = map.get(key) || 0;
    map.set(key, curr + Number(v.total || 0));
  });

  return [...map.entries()]
    .sort((a, b) => a[0].localeCompare(b[0]))
    .slice(-30)
    .map(([fecha, total]) => ({ fecha, total: Number(total.toFixed(2)) }));
}, [ventasFiltradas]);
<LineChart data={ventasPorDia}>
  <CartesianGrid strokeDasharray="3 3" stroke={chartGridColor} />
  <XAxis dataKey="fecha" tick={{ fill: chartTextColor }} />
  <YAxis tick={{ fill: chartTextColor }} />
  <Tooltip formatter={(v) => money(v)} />
  <Legend />
  <Line 
    type="monotone" 
    dataKey="total" 
    stroke="#2563eb" 
    strokeWidth={2} 
  />
</LineChart>

Top Products (Bar Chart)

Displays best-selling items by unit count:
const topProductos = useMemo(() => {
  const map = new Map();
  ventasFiltradas.forEach(v => {
    (v.productos || []).forEach(p => {
      // Group services together
      const esServicio = p?.tipo?.toLowerCase() === "servicio" || 
        ["lap", "laptop", "computadora", "impresora", "reparación", "servicio"]
          .some(s => String(p?.nombre || "").toLowerCase().includes(s));
      
      const nombre = esServicio ? `🔧 Servicios` : (p?.nombre || "Sin nombre");
      const cantidad = Number(p?.cantidad || 0);
      const importe = Number(p?.precioVenta || 0) * cantidad;
      
      const curr = map.get(nombre) || { cantidad: 0, importe: 0 };
      map.set(nombre, {
        cantidad: curr.cantidad + cantidad,
        importe: curr.importe + importe
      });
    });
  });

  return [...map.entries()]
    .map(([nombre, val]) => ({ nombre, ...val }))
    .sort((a, b) => b.cantidad - a.cantidad)
    .slice(0, 10);
}, [ventasFiltradas]);
Services are automatically grouped into a single “Servicios” category to prevent cluttering the chart with individual repair entries.

Payment Methods (Pie Chart)

Visualizes payment type distribution:
const metodosPago = useMemo(() => {
  const resumen = { efectivo: 0, tarjeta: 0, transferencia: 0, otros: 0 };

  ventasFiltradas.forEach(v => {
    const detalle = v?.pagoDetalle || {};
    const tipo = String(v?.tipoPago || "").toLowerCase();

    resumen.efectivo += Number(
      detalle.efectivo || (tipo === "efectivo" ? v.total : 0) || 0
    );
    resumen.tarjeta += Number(
      detalle.tarjeta || (tipo === "tarjeta" ? v.total : 0) || 0
    );
    resumen.transferencia += Number(
      detalle.transferencia || (tipo === "transferencia" ? v.total : 0) || 0
    );

    if (!["efectivo", "tarjeta", "transferencia"].includes(tipo)) {
      resumen.otros += Number(v.total || 0);
    }
  });

  return [
    { name: "Efectivo", value: Number(resumen.efectivo.toFixed(2)) },
    { name: "Tarjeta", value: Number(resumen.tarjeta.toFixed(2)) },
    { name: "Transferencia", value: Number(resumen.transferencia.toFixed(2)) },
    { name: "Otros", value: Number(resumen.otros.toFixed(2)) }
  ].filter(x => x.value > 0);
}, [ventasFiltradas]);

Profit Margins (Bar Chart)

Calculates estimated profit by product:
const utilidadPorProducto = useMemo(() => {
  const map = new Map();
  ventasFiltradas.forEach(v => {
    (v.productos || []).forEach(p => {
      const nombre = p?.nombre || "Sin nombre";
      const cantidad = Number(p?.cantidad || 0);
      const venta = Number(p?.precioVenta || 0);
      const compra = Number(p?.precioCompra || 0);
      const utilidad = (venta - compra) * cantidad;
      
      const curr = map.get(nombre) || 0;
      map.set(nombre, curr + utilidad);
    });
  });

  return [...map.entries()]
    .map(([nombre, utilidad]) => ({ nombre, utilidad: Number(utilidad.toFixed(2)) }))
    .sort((a, b) => b.utilidad - a.utilidad)
    .slice(0, 10);
}, [ventasFiltradas]);

Date Filtering

Filter Controls

The report page includes flexible date filtering:
<div className="reportes-buscador">
  <input
    type="date"
    value={fechaDesde}
    onChange={(e) => setFechaDesde(e.target.value)}
  />
  <input
    type="date"
    value={fechaHasta}
    onChange={(e) => setFechaHasta(e.target.value)}
  />
  <input
    placeholder="Buscar por ID, método o producto..."
    value={filtroTexto}
    onChange={(e) => setFiltroTexto(e.target.value)}
  />
</div>

Calendar View

An interactive calendar provides visual date selection:
<Calendar
  onChange={cambiarFecchaAlSeleccionarDia}
  value={selectedDate}
/>
Clicking a date:
  1. Sets both fechaDesde and fechaHasta to that day
  2. Filters all reports to show only that day’s data
  3. Updates the side panel with daily summary
The calendar can be pinned open using the “Fijar” button, and your preference is saved to localStorage.

Cash Register Management

Daily Opening

Before any sales can be processed, the cash register must be opened:
const registrarAperturaCaja = async (fondoInicial, cajero) => {
  const fechaKey = ymd(new Date());
  
  await setDoc(doc(db, "cortes_caja", fechaKey), {
    fechaKey,
    cerrado: false,
    fondoInicialCaja: fondoInicial,
    cajero: {
      uid: cajero.uid,
      email: cajero.email,
      nombre: cajero.nombre
    },
    aperturaEn: serverTimestamp()
  });
};
The modal requires:
  • Fondo inicial: Starting cash in drawer
  • Cashier info: Automatically captured from auth

Daily Closing

At end of day, click “Cerrar caja” to open the closing modal:

Step 1: Denomination Counting

const DENOMINACIONES = [
  1000, 500, 200, 100, 50, 20, 10, 5, 2, 1, 0.5
];

const totalDenominaciones = useMemo(() => {
  return DENOMINACIONES.reduce((acc, valor) => {
    const cantidad = Number(denominaciones[String(valor)] || 0);
    return acc + (valor * cantidad);
  }, 0);
}, [denominaciones]);
Count physical cash and enter quantities for each denomination.

Step 2: Withdrawals and Expenses

Record any cash removed from drawer:
const agregarMovimiento = (tipo = "retiro") => {
  setRetiros(prev => [
    ...prev,
    {
      id: `r-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
      tipo, // "retiro" | "gasto" | "vale"
      monto: "",
      motivo: "",
      usuario: auth.currentUser?.email || ""
    }
  ]);
};
Withdrawal Types:
  • Retiro: Cash taken out for bank deposit
  • Gasto: Business expense paid from drawer
  • Vale: Cash advance to employee

Step 3: Expense Integration

Expenses recorded via the Expenses modal are automatically included:
const egresosValidos = useMemo(() => {
  return egresos
    .map(e => ({
      id: String(e?.id || ""),
      tipo: String(e?.tipo || "otro"),
      monto: Number(String(e?.monto || "").replace(/,/g, "")),
      descripcion: String(e?.descripcion || "").trim(),
      usuario: String(e?.usuario || "").trim()
    }))
    .filter(e => Number.isFinite(e.monto) && e.monto > 0);
}, [egresos]);

Step 4: Reconciliation

The system calculates expected vs. actual cash:
const efectivoEsperadoHoy = ventasHoy.reduce((acc, v) => {
  const tipo = String(v?.tipoPago || "").toLowerCase();
  const detalle = v?.pagoDetalle || {};
  const efectivo = Number(
    detalle.efectivo || (tipo === "efectivo" ? v.total : 0) || 0
  );
  return acc + efectivo;
}, 0);

const totalSalidasCaja = totalRetiros + totalEgresosDia;
const cajaFinalEsperada = fondoInicialNum + efectivoEsperadoHoy - totalSalidasCaja;
const diferenciaContado = totalDenominaciones - efectivoEsperadoHoy;
Reconciliation Formula:
Caja Final Esperada = Fondo Inicial + Efectivo de Ventas - Salidas
Diferencia = Total Contado - Efectivo Esperado
A negative difference indicates a shortage (money missing). A positive difference indicates an overage (extra money).

Step 5: PDF Generation

After confirmation:
const res = await cerrarCajaHoy(ventas, {
  efectivoContado: totalDenominaciones,
  fondoInicialCaja: fondoInicialNum,
  denominaciones: DENOMINACIONES.map(valor => ({
    valor,
    cantidad: Number(denominaciones[String(valor)] || 0)
  })),
  retiros: retirosValidos,
  egresos: egresosValidos,
  cajero: {
    uid: auth.currentUser?.uid,
    email: auth.currentUser?.email,
    nombre: auth.currentUser?.displayName
  },
  notasCorte
});

await generarPdfCorteCajaDia(ventas, {
  corte: res.corte,
  fechaKey: res?.corte?.fechaKey || ymd(new Date())
});
The PDF includes:
  • Sales summary (tickets, subtotal, IVA, total)
  • Payment method breakdown
  • Cash reconciliation
  • Denomination count
  • Withdrawals and expenses
  • Cashier signature section

Expense Tracking

Opening Expense Modal

Click ”📊 Egresos” button to manage daily expenses:
<ModalEgresos
  mostrar={mostrarModalEgresos}
  onClose={() => setMostrarModalEgresos(false)}
  egresos={egresos}
  onAgregarEgreso={handleAgregarEgreso}
  onEliminarEgreso={handleEliminarEgreso}
  onEditarEgreso={handleEditarEgreso}
  totalEgresos={totalEgresos}
/>

Expense Types

const TIPO_EGRESO_META = {
  factura: { label: "Factura", emoji: "🧾" },
  boleta_venta: { label: "Boleta de venta", emoji: "🛒" },
  nota_credito: { label: "Nota de credito", emoji: "➕" },
  nota_debito: { label: "Nota de debito", emoji: "➖" },
  otro: { label: "Otro", emoji: "📝" }
};

Recording Expenses

const handleAgregarEgreso = async (egreso) => {
  if (cajaCerradaHoy) {
    alert("La caja de hoy ya está cerrada. No se pueden registrar egresos.");
    return;
  }
  
  await guardarEgreso({
    ...egreso,
    usuario: auth.currentUser?.email || "sin_usuario"
  });
  
  await cargarEgresosDia();
};
Each expense requires:
  • Tipo: Expense category
  • Monto: Amount
  • Descripcion: What was purchased
  • Usuario: Automatically captured

Expense-Cash Relationship

Expenses reduce expected cash in drawer:
const totalEgresosDia = egresosValidos.reduce(
  (acc, e) => acc + Number(e.monto || 0), 
  0
);

const totalSalidasCaja = totalRetiros + totalEgresosDia;
Expenses are stored separately but included in cash register closing reconciliation.

Closing History

View all historical closings with filters:
<div className="historial-cortes-filtros">
  <input
    type="date"
    value={fechaCorteDesde}
    onChange={(e) => setFechaCorteDesde(e.target.value)}
  />
  <input
    type="date"
    value={fechaCorteHasta}
    onChange={(e) => setFechaCorteHasta(e.target.value)}
  />
  <input
    placeholder="Filtrar por cajero (email/nombre)"
    value={filtroCajero}
    onChange={(e) => setFiltroCajero(e.target.value)}
  />
</div>

Closing Table

ColumnData
FechaDate key (YYYY-MM-DD)
CajeroEmail or name
TicketsNumber of sales
IVATotal tax collected
Efectivo esperadoCash from sales
ContadoPhysically counted cash
DiferenciaShortage/overage
DocumentoDownload PDF button

Re-Download Closing PDFs

const handleDescargarCorteHistorial = async (corte) => {
  const key = String(corte?.fechaKey || "");
  if (!key) return;
  
  const ventasDia = ventas.filter(v => {
    const fecha = toDate(v.fecha);
    return fecha && ymd(fecha) === key;
  });
  
  await generarPdfCorteCajaDia(ventasDia, {
    corte: corte || null,
    fechaKey: key
  });
};

Sales Detail Table

A searchable, sortable table shows all transactions:
const ventasFiltradas = useMemo(() => {
  const desde = fechaDesde ? new Date(`${fechaDesde}T00:00:00`) : null;
  const hasta = fechaHasta ? new Date(`${fechaHasta}T23:59:59`) : null;
  const txt = filtroTexto.trim().toLowerCase();

  return ventas.filter(v => {
    const fecha = toDate(v.fecha);
    if (!fecha) return false;
    if (desde && fecha < desde) return false;
    if (hasta && fecha > hasta) return false;

    if (!txt) return true;
    
    const id = String(v.id || "").toLowerCase();
    const tipo = String(v.tipoPago || "").toLowerCase();
    const productos = (v.productos || [])
      .map(p => String(p?.nombre || "").toLowerCase())
      .join(" ");
    
    return id.includes(txt) || tipo.includes(txt) || productos.includes(txt);
  });
}, [ventas, fechaDesde, fechaHasta, filtroTexto]);

Dark Mode Support

All charts adapt to theme:
const [isDarkMode, setIsDarkMode] = useState(
  () => readAparienciaConfigStorage().themeMode === "oscuro"
);

const chartTextColor = isDarkMode ? "#cbd5e1" : "#475569";
const chartGridColor = isDarkMode ? "#334155" : "#dbe3ef";
const chartTooltipStyle = {
  background: isDarkMode ? "#111827" : "#ffffff",
  border: `1px solid ${chartGridColor}`,
  color: isDarkMode ? "#e5e7eb" : "#0f172a",
  borderRadius: 10
};

Animation Control

Chart animations respect user preferences:
const [animationsEnabled, setAnimationsEnabled] = useState(
  () => readAparienciaConfigStorage().animations !== false
);

<Line
  type="monotone"
  dataKey="total"
  stroke="#2563eb"
  isAnimationActive={animationsEnabled}
/>

Best Practices

  1. Record all expenses throughout the day
  2. Note any withdrawals made
  3. Count cash carefully by denomination
  4. Investigate significant discrepancies
  5. Download and store closing PDF
  • Daily: Sales totals, cash reconciliation
  • Weekly: Top products, payment trends
  • Monthly: Profit margins, expense patterns
  • Quarterly: Year-over-year comparisons
  • Verify IVA configuration matches tax requirements
  • Ensure all products have purchase prices for margin calculation
  • Train staff on proper expense categorization
  • Audit cash counts with two-person verification

Technical Implementation

The reports module uses:
import { Bar, BarChart, Line, LineChart, Pie, PieChart, 
  ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import Calendar from "react-calendar";
import { collection, getDocs } from "firebase/firestore";
import { generarPdfCorteCajaDia } from "../js/services/pdf_corte_caja";
import { cerrarCajaHoy, obtenerCorteCajaDia, listarCortesCaja } 
  from "../js/services/corte_caja_firestore";
import { guardarEgreso, obtenerEgresosDia, eliminarEgreso, actualizarEgreso } 
  from "../js/services/egresos_firestore";
Key Files:
  • src/pages/reportes.jsx - Main dashboard (1134 lines)
  • src/js/services/pdf_corte_caja.js - PDF generation
  • src/css/reportes.css - Comprehensive styling
Firebase Collections:
  • ventas - Sales transactions
  • cortes_caja - Daily closings
  • egresos_[YYYY-MM-DD] - Daily expenses

Build docs developers (and LLMs) love