import type { FC } from 'react'; import { useEffect, useState } from 'react'; import { Plus, Lock, Receipt, Printer, ChevronLeft, ChevronRight, Download } from 'lucide-react'; import Layout from '../components/Layout'; import { Card, Button, Input, Modal, Badge, EmptyState, PageHeader, Skeleton, MobileCard, TextArea, toast, badgeForCashClosingState, } from '../components/ui'; import { CortePrint } from '../components/CortePrint'; import { odooApi, type CashClosing } from '../services/odoo'; import { useAuth } from '../lib/auth'; 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' }, { key: 'transfer', titulo: 'Transferencia' }, { key: 'other', titulo: 'Otros' }, ] as const; const Cortes: FC = () => { const { brand, can } = useAuth(); const [closings, setClosings] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [createOpen, setCreateOpen] = useState(false); const [wizard, setWizard] = useState(null); const [step, setStep] = useState(0); // 0..3 métodos, 4 = resumen const [countedCash, setCountedCash] = useState(''); const [countedCard, setCountedCard] = useState(''); const [countedTransfer, setCountedTransfer] = useState(''); const [countedOther, setCountedOther] = useState(''); const [notes, setNotes] = useState(''); const [printCorte, setPrintCorte] = useState(null); 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(); 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-${mes}`, sheetName: 'Cortes', title: `Cortes de caja ${brand.nombre}`, 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 { setLoading(true); setError(null); const res = await odooApi.getCashClosings(); if (res.status === 'success') setClosings(res.cash_closings); } catch (err) { setError('Error al cargar cortes'); toast.error('Error al cargar cortes'); console.error(err); } finally { setLoading(false); } }; useEffect(() => { load(); }, []); const abrirWizard = (c: CashClosing) => { setWizard(c); setStep(0); setCountedCash(''); setCountedCard(''); setCountedTransfer(''); setCountedOther(''); setNotes(''); }; const esperado = (c: CashClosing, key: string) => { if (key === 'cash') return c.opening_cash + c.total_cash; if (key === 'card') return c.total_card; if (key === 'transfer') return c.total_transfer; return c.total_other; }; const valorContado = (key: string) => { if (key === 'cash') return parseFloat(countedCash || '0') || 0; if (key === 'card') return parseFloat(countedCard || '0') || 0; if (key === 'transfer') return parseFloat(countedTransfer || '0') || 0; return parseFloat(countedOther || '0') || 0; }; const diffColor = (diff: number) => diff < 0 ? 'text-rose-600' : diff > 0 ? 'text-emerald-600' : 'text-theme-muted'; const create = async () => { const value = parseFloat(openingCash); if (Number.isNaN(value) || value < 0) { toast.error('Ingresa un efectivo inicial válido'); return; } try { setSubmitting(true); await odooApi.createCashClosing({ date, opening_cash: value }); toast.success('Caja abierta'); setCreateOpen(false); setOpeningCash(''); await load(); } catch (err) { toast.error('Error al crear corte'); console.error(err); } finally { setSubmitting(false); } }; const cerrarCaja = async () => { if (!wizard) return; try { setSubmitting(true); await odooApi.closeCashClosing(wizard.id, { counted_cash: valorContado('cash'), counted_card: valorContado('card'), counted_transfer: valorContado('transfer'), counted_other: valorContado('other'), notes, }); toast.success('Caja cerrada'); setWizard(null); await load(); } catch (err) { toast.error('Error al cerrar caja'); console.error(err); } finally { setSubmitting(false); } }; const renderUser = (user: string | false) => { if (!user) return 'Usuario no disponible'; return user; }; const differenceColor = (diff: number) => { if (diff > 0) return 'text-emerald-600'; if (diff < 0) return 'text-rose-600'; return 'text-theme-muted'; }; // --- Contenido de un paso del asistente --- const renderPaso = () => { if (!wizard) return null; if (step < PASOS.length) { const { key, titulo } = PASOS[step]; const esp = esperado(wizard, key); const cont = valorContado(key); const diff = cont - esp; const setters: Record void> = { cash: setCountedCash, card: setCountedCard, transfer: setCountedTransfer, other: setCountedOther, }; const values: Record = { cash: countedCash, card: countedCard, transfer: countedTransfer, other: countedOther, }; const informativo = key !== 'cash'; return (

{key === 'cash' ? 'Efectivo esperado (apertura + cobros)' : `${titulo} registrado en el sistema`}

{money(esp)}

{cont > 0 || values[key] ? 'Diferencia' : 'Aún sin contar'}

{values[key] ? `${diff > 0 ? 'Sobrante +' : diff < 0 ? 'Faltante ' : ''}${money(diff)}` : '—'}

setters[key](e.target.value)} autoFocus /> {key === 'cash' && countedCash && (

{diff === 0 && 'El efectivo cuadra.'} {diff < 0 && `Faltan ${money(-diff)} en la caja.`} {diff > 0 && `Sobran ${money(diff)} en la caja.`}

)}
); } // Resumen return (
{PASOS.map(({ key, titulo }) => { const esp = esperado(wizard, key); const cont = valorContado(key); const diff = cont - esp; return ( ); })}
Método Esperado Contado Diferencia
{titulo} {money(esp)} {money(cont)} {diff > 0 ? '+' : ''}{money(diff)}