import type { FC } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; import { Search, Ticket, XCircle, RefreshCw, AlertTriangle, Printer, Receipt, User, Pencil } from 'lucide-react'; import Layout from '../components/Layout'; import { Card, Button, Input, Select, Modal, TextArea, Badge, EmptyState, Skeleton, PageHeader, toast } from '../components/ui'; import TicketPrint from '../components/TicketPrint'; import TicketEditor from '../components/TicketEditor'; import { odooApi, type Sale } from '../services/odoo'; import { useAuth } from '../lib/auth'; import { readDateFilterPrefs, writeDateFilterPrefs, DATE_PRESETS, rangeForPreset, type DatePreset } from '../lib/dateRange'; const PREF_KEY = 'skeen_tickets_filtros'; const METODO_LABELS: Record = { cash: 'Efectivo', card: 'Tarjeta', transfer: 'Transferencia', stripe: 'Stripe', mercadopago: 'MercadoPago', points: 'Saldo a favor', mixed: 'Mixto', '': '—', }; const money = (v: number) => '$' + (v ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const ESTADOS = [ { value: '', label: 'Todos los estados' }, { value: 'paid', label: 'Pagados' }, { value: 'partial', label: 'Parciales' }, { value: 'confirmed', label: 'Confirmados' }, { value: 'cancelled', label: 'Cancelados' }, ]; const METODOS_FILTRO = [ { value: '', label: 'Todos los métodos' }, { value: 'cash', label: 'Efectivo' }, { value: 'card', label: 'Tarjeta' }, { value: 'transfer', label: 'Transferencia' }, { value: 'points', label: 'Saldo a favor' }, { value: 'mixed', label: 'Mixto' }, { value: 'stripe', label: 'Stripe' }, { value: 'mercadopago', label: 'MercadoPago' }, ]; const PAGE_SIZE = 50; /** * Tickets: encontrar un cobro del POS y cancelarlo sin navegar el módulo de * Ventas. Permiso de cancelación: admin y gerencia (el API lo valida). */ const Tickets: FC = () => { const { user } = useAuth(); const puedeCorregir = user?.role === 'admin' || user?.role === 'gerencia'; const prefs = readDateFilterPrefs(PREF_KEY); const [preset, setPreset] = useState(prefs?.preset || 'hoy'); const [desde, setDesde] = useState(prefs?.start || rangeForPreset('hoy').start); const [hasta, setHasta] = useState(prefs?.end || rangeForPreset('hoy').end); const [estado, setEstado] = useState(''); const [metodo, setMetodo] = useState(''); const [usuario, setUsuario] = useState(''); const [busqueda, setBusqueda] = useState(''); const [page, setPage] = useState(1); const [sales, setSales] = useState([]); const [total, setTotal] = useState(0); const [totalPages, setTotalPages] = useState(1); const [loading, setLoading] = useState(true); const [detalle, setDetalle] = useState(null); const [cancelando, setCancelando] = useState(null); const [motivo, setMotivo] = useState(''); const [enviando, setEnviando] = useState(false); const [reimprimir, setReimprimir] = useState(null); const [editando, setEditando] = useState(null); const [catalogo, setCatalogo] = useState<{ servicios: { id: number; name: string; price: number }[]; items: { id: number; name: string; price: number; qty: number }[]; }>({ servicios: [], items: [] }); const [doctores, setDoctores] = useState<{ id: number; name: string }[]>([]); // El catálogo y los doctores solo hacen falta para corregir un ticket: se // cargan perezosamente la primera vez que se abre el editor. const cargarCatalogos = useCallback(async () => { if (catalogo.servicios.length) return; try { const [sv, iv, dc] = await Promise.all([ odooApi.getServices({ page_size: 200 }), odooApi.getInventory(), odooApi.getDoctors(), ]); setCatalogo({ servicios: (sv.services || []).map((x) => ({ id: x.id, name: x.name, price: x.price })), items: (iv.items || []).map((x) => ({ id: x.id, name: x.name, price: x.price ?? 0, qty: x.qty })), }); setDoctores((dc.doctors || []).map((x) => ({ id: x.id, name: x.name }))); } catch { toast.error('No se pudo cargar el catálogo para corregir el ticket'); } }, [catalogo.servicios.length]); const aplicarPreset = (p: DatePreset) => { setPreset(p); if (p !== 'custom') { const r = rangeForPreset(p); setDesde(r.start); setHasta(r.end); writeDateFilterPrefs(PREF_KEY, { preset: p, start: r.start, end: r.end }); } }; const load = useCallback(async () => { try { setLoading(true); const res = await odooApi.getSales({ date_from: desde, date_to: hasta, ...(estado ? { state: estado } : {}), ...(metodo ? { payment_method: metodo } : {}), ...(usuario ? { user: usuario } : {}), ...(busqueda ? { search: busqueda } : {}), page, page_size: PAGE_SIZE, }); if (res.status === 'success') { setSales(res.sales || []); setTotal(res.total ?? 0); setTotalPages(res.total_pages ?? 1); } } catch { toast.error('No se pudieron cargar los tickets'); } finally { setLoading(false); } }, [desde, hasta, estado, metodo, usuario, busqueda, page]); useEffect(() => { const t = setTimeout(load, 250); return () => clearTimeout(t); }, [load]); useEffect(() => { setPage(1); }, [desde, hasta, estado, metodo, usuario, busqueda]); const totalPeriodo = useMemo( () => sales.filter((s) => s.state !== 'cancelled').reduce((a, s) => a + s.total, 0), [sales], ); const canceladosPeriodo = useMemo( () => sales.filter((s) => s.state === 'cancelled').length, [sales], ); const abrirCancelar = (s: Sale) => { setCancelando(s); setMotivo(''); }; const submitCancelar = async () => { if (!cancelando) return; if (!motivo.trim()) { toast.error('El motivo de cancelación es obligatorio'); return; } try { setEnviando(true); const res = await odooApi.cancelSale(cancelando.id, { reason: motivo.trim() }); const r = res.result; const partes = [ r?.points_reversed ? `${r.points_reversed} pts revertidos` : '', r?.points_restored ? `${r.points_restored} pts devueltos al saldo` : '', r?.stock_moves ? `${r.stock_moves} artículos devueltos a inventario` : '', ].filter(Boolean); toast.success(`Ticket ${cancelando.name} cancelado${partes.length ? ` · ${partes.join(' · ')}` : ''}`); setCancelando(null); setDetalle(res.sale); await load(); } catch (err) { const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; toast.error(msg || 'No se pudo cancelar el ticket'); } finally { setEnviando(false); } }; return (
{DATE_PRESETS.map((p) => ( ))}
{ setDesde(e.target.value); setPreset('custom'); }} /> { setHasta(e.target.value); setPreset('custom'); }} /> setMetodo(e.target.value)} /> setUsuario(e.target.value)} />
setBusqueda(e.target.value)} className="pl-10" />

Tickets en el filtro

{total}

Importe (página)

{money(totalPeriodo)}

Cancelados (página)

0 ? 'text-rose-600' : 'text-theme-heading'}`}> {canceladosPeriodo}

Página

{page} / {totalPages || 1}

{loading ? ( ) : sales.length === 0 ? ( } /> ) : (
{sales.map((s) => ( ))}
Folio Fecha Paciente Total Método Cobró Estado Acciones
{s.name} {(s.date || '').slice(0, 10)} {s.patient} {money(s.total)} {METODO_LABELS[s.payment_method || ''] || s.payment_method || '—'} {s.user_login || '—'} {s.state === 'cancelled' ? 'Cancelado' : s.state === 'paid' ? 'Pagado' : s.state === 'partial' ? 'Parcial' : 'Confirmado'}
{s.state !== 'cancelled' && puedeCorregir && ( )} {s.state !== 'cancelled' && ( )}
)} {totalPages > 1 && (
)}
{/* Detalle del ticket */} setDetalle(null)} title={detalle ? `Ticket ${detalle.name}` : 'Ticket'} maxWidth="lg" footer={ <> {detalle && detalle.state === 'cancelled' && ( )} {detalle && detalle.state !== 'cancelled' && puedeCorregir && ( )} {detalle && detalle.state !== 'cancelled' && ( )} } > {detalle && (

Paciente

{detalle.patient}

Total

{money(detalle.total)}

Método

{METODO_LABELS[detalle.payment_method || ''] || '—'}

Cobró

{detalle.user_login || '—'}

Conceptos

{detalle.lines.map((l) => ( ))}
{l.description || l.service || l.item || '—'} {l.quantity} × {money(l.price_unit)} {money(l.subtotal)}

Pagos registrados

{(detalle.pagos || []).length === 0 ? (

Sin pagos ligados a este ticket (los cobros anteriores al vínculo de pagos no se pueden asociar).

) : ( {(detalle.pagos || []).map((p) => ( ))}
{p.name} {METODO_LABELS[p.payment_method] || p.payment_method} {p.create_date || '—'} {money(p.amount)}
)}
{detalle.state === 'cancelled' && (

Ticket cancelado

Motivo: {detalle.cancel_reason || '—'}

Canceló: {detalle.cancelled_by || '—'} · {detalle.cancelled_at || '—'}

Puntos revertidos: {detalle.cancel_points_reversed || 0} · Saldo devuelto al paciente: {detalle.cancel_points_restored || 0}

)}
)}
{/* Diálogo de cancelación (nota de crédito) */} setCancelando(null)} title={`Cancelar ticket ${cancelando?.name || ''}`} maxWidth="sm" footer={ <> } > {cancelando && (

Paciente: {cancelando.patient} {' · '}Total: {money(cancelando.total)} {cancelando.user_login ? ` · Cobró: ${cancelando.user_login}` : ''}

Se emitirá la nota de crédito:

La venta queda cancelada y deja de contar en ingresos, comisiones y reportes.

Se revierten los puntos otorgados y se devuelve al saldo a favor lo que el paciente canjeó.

Los artículos vendidos vuelven al inventario.

Importante: el pago NO se revierte automáticamente

El cobro queda registrado en caja y en el historial de pagos del paciente ({(cancelando.pagos || []).length > 0 ? `${(cancelando.pagos || []).length} pago(s) por ${money((cancelando.pagos || []).reduce((a, p) => a + p.amount, 0))}` : 'sin pagos ligados a este ticket'}).

Si corresponde devolver el dinero, hay que ajustar la caja por separado.