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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user