Oleada UX 3: POS (repetir ultima venta, aviso de paquetes, atajos, caja desde POS), Ventas (cancelacion auditada con reversion de monedero y stock, detalle inline, presets de fecha), Cortes (historial comparativo y export)

This commit is contained in:
2026-09-17 19:26:02 +00:00
parent b4f91527f1
commit ac5896b1fc
10 changed files with 1328 additions and 57 deletions

View File

@@ -1,6 +1,6 @@
import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { Plus, Lock, Receipt, Printer, ChevronLeft, ChevronRight } from 'lucide-react';
import { Plus, Lock, Receipt, Printer, ChevronLeft, ChevronRight, Download } from 'lucide-react';
import Layout from '../components/Layout';
import {
Card,
@@ -18,10 +18,13 @@ import {
} from '../components/ui';
import { CortePrint } from '../components/CortePrint';
import { odooApi, type CashClosing } from '../services/odoo';
import { exportToExcel } from '../lib/exporter';
const money = (v: number) =>
'$' + (v ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const MES_ACTUAL = new Date().toISOString().slice(0, 7);
const PASOS = [
{ key: 'cash', titulo: 'Efectivo' },
{ key: 'card', titulo: 'Tarjeta' },
@@ -45,6 +48,77 @@ const Cortes: FC = () => {
const [date, setDate] = useState(() => new Date().toISOString().split('T')[0]);
const [openingCash, setOpeningCash] = useState('');
const [submitting, setSubmitting] = useState(false);
// Mes para el resumen exportable
const [mes, setMes] = useState(MES_ACTUAL);
// --- Historial comparativo: últimos cortes CERRADOS y diferencias por usuario ---
const HISTORIAL_N = 14;
const cerrados = closings.filter((c) => c.state === 'closed');
const ultimosCerrados = cerrados.slice(0, HISTORIAL_N);
const porUsuario = (() => {
const map = new Map<string, { usuario: string; cortes: number; faltantes: number; sobrantes: number; neto: number; ultima: string }>();
for (const c of ultimosCerrados) {
const usuario = c.user || 'Usuario no disponible';
const row = map.get(usuario) || { usuario, cortes: 0, faltantes: 0, sobrantes: 0, neto: 0, ultima: c.date };
row.cortes += 1;
row.neto += c.difference || 0;
if ((c.difference || 0) < 0) row.faltantes += c.difference || 0;
else row.sobrantes += c.difference || 0;
if (c.date > row.ultima) row.ultima = c.date;
map.set(usuario, row);
}
return [...map.values()].sort((a, b) => a.neto - b.neto);
})();
const cortesDelMes = closings.filter((c) => (c.date || '').startsWith(mes));
const exportarMes = async () => {
if (cortesDelMes.length === 0) {
toast.error('No hay cortes en el mes seleccionado');
return;
}
try {
await exportToExcel({
filename: `cortes-skeen-${mes}`,
sheetName: 'Cortes',
title: 'Cortes de caja SKEEN',
subtitle: `Mes ${mes} · ${cortesDelMes.length} cortes`,
columns: [
{ header: 'Referencia', key: 'name' },
{ header: 'Fecha', key: 'date', format: 'date' },
{ header: 'Usuario', key: 'user' },
{ header: 'Estado', key: 'estado' },
{ header: 'Apertura', key: 'opening_cash', format: 'currency' },
{ header: 'Efectivo', key: 'total_cash', format: 'currency' },
{ header: 'Tarjeta', key: 'total_card', format: 'currency' },
{ header: 'Transferencia', key: 'total_transfer', format: 'currency' },
{ header: 'Otros', key: 'total_other', format: 'currency' },
{ header: 'Total ventas', key: 'total_sales', format: 'currency' },
{ header: 'Efectivo contado', key: 'counted_cash', format: 'currency' },
{ header: 'Diferencia', key: 'difference', format: 'currency' },
{ header: 'Notas', key: 'notes' },
],
rows: cortesDelMes.map((c) => ({
...c,
user: c.user || 'Usuario no disponible',
estado: c.state === 'closed' ? 'Cerrado' : 'Abierto',
})),
totals: {
name: '',
total_cash: cortesDelMes.reduce((a, c) => a + (c.total_cash || 0), 0),
total_card: cortesDelMes.reduce((a, c) => a + (c.total_card || 0), 0),
total_transfer: cortesDelMes.reduce((a, c) => a + (c.total_transfer || 0), 0),
total_other: cortesDelMes.reduce((a, c) => a + (c.total_other || 0), 0),
total_sales: cortesDelMes.reduce((a, c) => a + (c.total_sales || 0), 0),
difference: cortesDelMes.reduce((a, c) => a + (c.difference || 0), 0),
},
});
toast.success(`Resumen de ${mes} descargado (${cortesDelMes.length} cortes)`);
} catch (err) {
toast.error('Error al exportar');
console.error(err);
}
};
const load = async () => {
try {
@@ -242,6 +316,17 @@ const Cortes: FC = () => {
return (
<Layout title="Cortes de Caja" subtitle="Arqueos y cierres">
<PageHeader title="Cortes de Caja" subtitle="Abre y cierra turnos">
<Input
type="month"
value={mes}
onChange={(e) => setMes(e.target.value || MES_ACTUAL)}
aria-label="Mes a exportar"
className="sm:max-w-[160px]"
/>
<Button variant="outline" onClick={exportarMes} disabled={cortesDelMes.length === 0}>
<Download size={16} className="mr-2" />
Exportar resumen
</Button>
<Button onClick={() => setCreateOpen(true)}>
<Plus size={16} className="mr-2" />
Abrir caja
@@ -426,6 +511,77 @@ const Cortes: FC = () => {
)}
</Modal>
{/* Historial comparativo de diferencias (para detectar patrones por usuario) */}
<Card className="mt-4">
<Card.Body>
<div className="flex flex-wrap items-center justify-between gap-2 mb-3">
<div>
<h3 className="font-heading text-lg text-theme-heading">Historial comparativo de diferencias</h3>
<p className="text-xs text-theme-muted">Últimos {HISTORIAL_N} cortes cerrados. Negativo = faltante, positivo = sobrante.</p>
</div>
{ultimosCerrados.length > 0 && (
<span className="text-xs text-theme-muted">
Diferencia acumulada: <span className={`font-semibold ${differenceColor(ultimosCerrados.reduce((a, c) => a + (c.difference || 0), 0))}`}>
{money(ultimosCerrados.reduce((a, c) => a + (c.difference || 0), 0))}
</span>
</span>
)}
</div>
{ultimosCerrados.length === 0 ? (
<p className="text-sm text-theme-muted">Aún no hay cortes cerrados.</p>
) : (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="lg:col-span-2 overflow-x-auto">
<table className="w-full">
<thead className="bg-theme-bg">
<tr>
<th className="text-left p-2 text-xs font-medium text-theme-muted uppercase">Fecha</th>
<th className="text-left p-2 text-xs font-medium text-theme-muted uppercase">Usuario</th>
<th className="text-right p-2 text-xs font-medium text-theme-muted uppercase">Contado</th>
<th className="text-right p-2 text-xs font-medium text-theme-muted uppercase">Diferencia</th>
</tr>
</thead>
<tbody className="divide-y">
{ultimosCerrados.map((c) => (
<tr key={c.id} className="hover:bg-theme-bg">
<td className="p-2 text-sm text-theme-muted">{c.date}</td>
<td className="p-2 text-sm text-theme-muted">{c.user || 'Usuario no disponible'}</td>
<td className="p-2 text-sm text-right text-theme-heading">{money(c.counted_cash ?? 0)}</td>
<td className={`p-2 text-sm text-right font-semibold ${differenceColor(c.difference)}`}>
{c.difference > 0 ? '+' : ''}{money(c.difference)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div>
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Resumen por usuario</p>
<div className="space-y-2">
{porUsuario.map((u) => (
<div key={u.usuario} className="rounded-xl border border-theme-border bg-theme-bg p-2.5">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-theme-heading truncate">{u.usuario}</span>
<span className={`text-sm font-semibold ${differenceColor(u.neto)}`}>
{u.neto > 0 ? '+' : ''}{money(u.neto)}
</span>
</div>
<p className="text-[11px] text-theme-muted">
{u.cortes} {u.cortes === 1 ? 'corte' : 'cortes'}
{u.faltantes !== 0 ? ` · faltantes ${money(u.faltantes)}` : ''}
{u.sobrantes !== 0 ? ` · sobrantes ${money(u.sobrantes)}` : ''}
</p>
</div>
))}
</div>
</div>
</div>
)}
</Card.Body>
</Card>
<CortePrint corte={printCorte} onClose={() => setPrintCorte(null)} />
</Layout>
);